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
@@ -0,0 +1,253 @@
1
+ # Testing
2
+
3
+ How the plugin is verified: the unit suite (`node --test tests/`, run by the
4
+ pre-commit hook) and the live checks that need a real omp session and a real
5
+ OpenRouter key. Node type-stripping does not typecheck, so a green suite plus a
6
+ real run is the gate — see [`quirks.md`](quirks.md).
7
+
8
+ ## Unit seams
9
+
10
+ Each seam names the test file that covers it and the scenarios it must keep
11
+ passing.
12
+
13
+ 1. **Engine metrics** (`engine-metrics.test.ts`) — `buildModels` maps every
14
+ leaderboard field into `metrics`; an absent field stays null (0-filled, not
15
+ imputed); every metric has a `METRIC_META` entry with the right kind; the
16
+ affine/identity transforms; a role weighting a new metric ranks it.
17
+ 2. **Tier gate** (`tier-gate.test.ts`) — paid+credit → billed-only; free-tier
18
+ key → `:free`-only; zero budget both branches → abort; non-openrouter
19
+ providers filtered.
20
+ 3. **Variant resolution** (`variant-resolution.test.ts`) — exact/dated/bare/alias
21
+ ordering; `:batch` never emitted; `:free` only on the free branch; org
22
+ prefixes (incl. `~`) ignored; ties lexicographic.
23
+ 4. **Surgical edit** (`config-edit.test.ts`) — commented config, unknown keys,
24
+ missing roles upsert, chain-key replace-in-place (no duplicate YAML keys),
25
+ unchanged file → zero-byte diff and no mtime change; structural surprises
26
+ rejected; the empty-collection spellings omp's own writer emits (a null token
27
+ on the key line, a flow `[]`/`{}` on its own indented line — and an empty
28
+ *parent* block, `task:`/`retry:` + ` {}`) read as empty and are normalized
29
+ into the block form on write, with a prune-only patch creating nothing (issue
30
+ #54); a sibling `retry.*` key neither aborts the chain scan nor swallows an
31
+ appended chain key (issue #55); the patched output parses as real YAML.
32
+ 5. **Hysteresis** (`hysteresis.test.ts`) — no-current adopt; ineligible current
33
+ switch; margin-below keep; margin-above switch; `switchMargin: 0` always takes
34
+ best; cost override (`switched-cost`); `priceSwitchFraction: 0` keeps the
35
+ margin in charge; an inside-margin challenger short of the fraction stays
36
+ kept; the day gate.
37
+ 6. **Chain pruning** (`chain-pruning.test.ts`) — plugin-written stale keys
38
+ removed; owner-written keys preserved; `writeFallbackChains: false`; a role
39
+ that left the managed set is pruned; the chain prefers candidates at or below
40
+ the chosen price.
41
+ 7. **Chain suffixes** (`chain-suffix.test.ts`) — chain values carry the role's
42
+ suffix, the key stays bare; an entry without thinking support gets no suffix;
43
+ two roles on one model share a level-free chain.
44
+ 8. **Probe gate** (`probe-gate.test.ts`) — 200 → ok; the no-allowed-providers 404
45
+ → blocked; any other failure → usable; blocked best → next adopted; blocked
46
+ current → replaced; chains contain only probe-clean candidates; verdicts
47
+ cached across roles; all-blocked → role untouched.
48
+ 9. **Keyed catalog** (`keyed-catalog.test.ts`) — key-blocked candidates pruned
49
+ from the walk; only candidates in neither catalog are probed; key-blocked
50
+ never in a chain; no-filter/unavailable degrade to the probe walk.
51
+ 10. **Key availability** (`key-availability.test.ts`) — proper subset → active;
52
+ equal sets → no-filter; empty → unavailable; a keyed id absent from public →
53
+ no-filter; aliases collapse; two parallel GETs, one authenticated; never
54
+ throws.
55
+ 11. **Allowed-providers whitelist** (`allowed-providers.test.ts`,
56
+ `whitelist.test.ts`) — `fetchAllowedProviders` parses the 404 body's
57
+ "permits only:" list into a set and returns `null` for a 200, a 404 without
58
+ the clause, or a transport throw, with the canary request shape (POST,
59
+ bearer, `provider.only`, `max_tokens: 1`); the updater prunes every model's
60
+ route pool to allowed providers before ranking, so a blocked best route
61
+ falls to the next-best allowed route as the auto-pin, the chain carries only
62
+ allowed routes, a bare entry matches a tiered slug by prefix, the notify line
63
+ reports the active whitelist and the pruned count (or "no whitelist"), and a
64
+ `null` whitelist reproduces today's output byte-for-byte.
65
+ 12. **Endpoint ceilings** (`endpoint-ceilings.test.ts`) — the narrower reads
66
+ context/output/tools, a missing field is null; a failing route drops, a null
67
+ field is kept; a model with no capable route is ineligible; a filtered role
68
+ is priced on the surviving pool; `maxPriceUsdPerM` caps the thinking-adjusted
69
+ price.
70
+ 13. **Route pricing (provider pin)** (`route-pricing.test.ts`) — a pinned role
71
+ prices the matching route, not the blend; a tiered pin matches verbatim;
72
+ `maxPriceUsdPerM` caps the route price; throughput is the route p50 with a
73
+ blend fallback; no matching route → ineligible; the `@<slug>` selector and
74
+ chain carry the pin; a pin matching no route leaves the role unchanged. The
75
+ route-ordering score (spec #71): `rankedRoutes` orders by the value-for-money
76
+ score `vfm = wT·tNorm − wP·pNorm` (a throughput-heavy role picks a faster
77
+ route than a price-heavy role); the soft `filters.preferredMinThroughput`
78
+ floor sorts a below-floor route last **without dropping it**; a route with no
79
+ p50 uses the model's blended throughput (for both the score and the floor
80
+ check); equal scores break by `providerSlug` then `id`; `explainModel`'s
81
+ `autoPin` is the score-max route.
82
+ 14. **Thinking price** (`thinking-price.test.ts`) — bare/off unadjusted; a level
83
+ scales only thinking-capable models; the frontier uses the effective price;
84
+ `resolveSettings` validates thinking and rejects the legacy `suffixes` map;
85
+ catalog `thinking[]` wins over the OR flag; meta levels never gate.
86
+ 15. **OpenRouter blend** (`openrouter-blend.test.ts`) — price/throughput are the
87
+ `1/price²`-weighted means over the page routes; flex/priority/degraded/free/
88
+ `:batch` stay out; throughput renormalizes; the find-row fallback; the weight
89
+ follows the input price.
90
+ 16. **Writing metric** (`writing.test.ts`) — `parseWritingEvidence` keys finite
91
+ scores and rejects junk; `applyWritingScores` fills uncovered models; a
92
+ covered model ranks above an uncovered one.
93
+ 17. **Benchmark sources** (`benchmark-sources.test.ts`) — `resolveBenchmarkSource`
94
+ maps an llm-stats benchmark page, a bare id, the writing leaderboard and a
95
+ Design Arena link, `null` for an unknown host; `normalizeMetricKey` is
96
+ dot-free; `parseBenchmarkPayload` reads the llm-stats `entries[]` and writing
97
+ evidence shapes and rejects junk; `parseBenchmarkPayloadMeta` reads the
98
+ per-entry `self_reported`/`verified` flags (19/20 → `trust`, no flag → `null`,
99
+ no entries shape → `null`); `parseBenchmarkCatalog` carries `isCommunity`,
100
+ `modality` and the maintenance fields (`updatedAt`/`versionCount`/
101
+ `latestVersionRowCount`/`starCount`, each `null` when missing or non-finite);
102
+ the catalog cache rejects a pre-`isCommunity`/`modality`/maintenance cache
103
+ (and an empty one); the scores cache rejects a cache lacking the `meta` key
104
+ (and accepts `meta: null`);
105
+ `applyBenchmarkScores` fills uncovered
106
+ models; `loadBenchmarkScores` follows fresh → live → stale with a temp cache
107
+ dir and an injected fetch; a declaration executes (payload path, id/score
108
+ fields, `scoreMax`, join) and a metric colliding with a shipped key is
109
+ rejected; `dryRunDeclaration` reports coverage/leader and rejects a zero-join;
110
+ `extractBenchmarkLinks` pulls URLs from prose.
111
+ 18. **Agent creation** (`create-agent.test.ts`) — a purpose fits the expected
112
+ archetype; a `--weights` set violating Σ(non-price) = 1 − w_price is refused;
113
+ an existing agent file without `--force` is refused and writes no role;
114
+ `--dry-run` writes neither file; an architect `spec` replaces the
115
+ description/body; `applyFocusBenchmarks` gives a named benchmark a decisive
116
+ share, is a no-op for the archetype's own specialist set, and keeps both
117
+ invariants; the nine-axis gate (the maintenance, provenance and cross-source
118
+ axes are annotations, never a drop).
119
+ 19. **Agent removal** (`remove-agent.test.ts`) — the role's lock-file keys and the
120
+ agent file are deleted (backup written); a shipped default role, a reserved
121
+ name and an invalid name are refused; nothing-to-remove errors; `--dry-run`
122
+ writes nothing; the updater drops `modelRoles.<n>` and the plugin-managed
123
+ `task.disabledAgents` entry.
124
+ 20. **Agent pins / disable** (`agent-pins.test.ts`, `agent-disable.test.ts`) —
125
+ `parseAgentPin` takes the first `@role`; `discoverAgentPins` scans the three
126
+ scopes, most specific wins; the sync is not day-gated; an unrelated entry
127
+ keeps its position; an unknown role is never added.
128
+ 21. **Role enable / lock** (`role-enable.test.ts`, `role-lock.test.ts`) —
129
+ disabling drops the role from the resolved set and its `modelRoles` line; a
130
+ locked role is not switched and its chain survives the prune; unlocking
131
+ restores the switch.
132
+ 22. **Settings** (`settings.test.ts`, `settings-schema.test.ts`) — opt-in roles;
133
+ `priceSwitchFraction` range; flat/nested merge; `required` as string or
134
+ array; an external metric key is weightable, a dotted one rejected;
135
+ `package.json` `omp.settings` deep-equals `deriveSettingsSchema()`. The
136
+ `providerPinning` preset sets `preferOwnProvider: true` +
137
+ `filters.preferredMinThroughput: 50` (the registry row and the expanded def
138
+ agree), an explicit `filters.preferredMinThroughput` beats the preset, and
139
+ the schema carries the `roles.<role>.filters.preferredMinThroughput` row
140
+ (number, min 0, default 0).
141
+ 23. **Explorer** (`explorer.test.ts`, `explorer-scopes.test.ts`) — rank deltas;
142
+ contributions sum to `q`; `inverseCardinal` round-trips; Export preserves
143
+ siblings and normalizes nested roles; the scope switcher; the availability
144
+ overlay.
145
+ 24. **Project scope** (`project-updater.test.ts`, `project-setup.test.ts`,
146
+ `project-roles-command.test.ts`) — project mode scopes config/state/history/
147
+ lock to `<cwd>/.omp`; the day gate is per scope; the registry write;
148
+ `setupProject` add/drop/keep, `--dry-run`, `--force`; the command is
149
+ registered.
150
+ 25. **Session model** (`session-model.test.ts`) — the hook fires only after a real
151
+ write on a real `default` change; a dry run and a kept default skip it; a
152
+ throwing hook is contained.
153
+ 26. **Docs** (`docs.test.ts`) — every relative markdown link resolves; no
154
+ reference to a moved doc; the CHANGELOG shape; `package.json` `files` entries
155
+ exist; the README has no dev-only headings.
156
+ 27. **Architect provenance** (`architect-provenance.test.ts`) — the vendored
157
+ prompt header matches `ARCHITECT_PROMPT_VERSION` and the version named in
158
+ [`agent-authoring.md`](agent-authoring.md).
159
+ 28. **Architect retry** (`agent-architect.test.ts`) — a malformed architect
160
+ response is retried once and the parsed spec returned; an always-malformed
161
+ run throws carrying the raw output; a `run()` failure propagates with no
162
+ retry.
163
+ 29. **Error reporting** (`error-report.test.ts`) — `reportUnexpectedError`
164
+ through its one entry point with fully injected deps: `off` → no fetch, no
165
+ ledger write, `action: "off"`; `auto` + token → exactly one create whose body
166
+ carries the fingerprint marker, plugin version, runtime, platform, label and
167
+ stack, with the `bug`/`needs-triage` labels; redaction (home dir → `~`,
168
+ `node_modules` prefix → `<pkg>/`, `sk-…`/`ghp_…`/`Bearer …` → `<redacted>`)
169
+ leaves nothing token-shaped in the title, body or URL; dedupe (an open match
170
+ is commented on, never a second create; a closed match on a later day
171
+ creates a fresh issue linking the old one); caps (the 4th distinct
172
+ fingerprint in one UTC day, and a fingerprint already created earlier the
173
+ same day even when its issue is closed, are both `suppressed` with no
174
+ request; a later UTC day re-creates the regression); permission fall-through
175
+ (`401`/`403`/`404` from the search or the create → `offered`, the creation
176
+ counters untouched); no token → no HTTP at all, an `issues/new` URL whose
177
+ query carries the title, body and labels opened exactly once; token
178
+ resolution (`GITHUB_TOKEN` → `GH_TOKEN` → the injected `gh` executor, with a
179
+ throwing or empty `gh` falling through to the URL path); containment (a
180
+ rejected fetch, a 500, a timeout and a malformed JSON body each → `failed`,
181
+ one notify line, no throw); fingerprint stability (a varying id inside the
182
+ message with the same first stack frame dedupes, a different label does not);
183
+ `ask` (a `confirm(true)` surfaces the payload and files exactly once, a
184
+ `confirm(false)` or a headless session sends nothing and offers the URL — it
185
+ never silently falls back to `auto`); an unrecognized `errorReporting` value
186
+ fails closed (no fetch, no ledger, `action: "off"`); a defect **string** (as
187
+ `reportDefect` passes it) is fingerprinted by its message, so two distinct
188
+ aborts under one label differ; the in-process guard; and a ledger that
189
+ tolerates junk and truncates a pathological stack — including a multi-byte
190
+ one — to the 64 KB **byte** budget.
191
+ 30. **CI/CD** (`ci.test.ts`) — the workflow parses and declares both jobs with
192
+ `release` needing `test`; the `test` job's command is identical to the
193
+ pre-commit hook's; the tag trigger covers every CHANGELOG tag link and the
194
+ package version; `.node-version` is the file the workflow reads; the release
195
+ job declares `id-token: write` and references no npm token; the release job
196
+ runs the `notes` guard before `npm publish` and both steps name the same
197
+ notes file; the four lockstep version fields agree; no tracked file lives
198
+ under `cache/`; and
199
+ `scripts/release.ts` against fixture repos — `due`'s exit codes, `notes`'s
200
+ five refusals and its exact section body, `cut --dry-run` writing nothing,
201
+ and `cut` bumping the four fields, cutting the changelog, committing and
202
+ tagging while refusing a dirty tree, an existing tag, an empty
203
+ `## [Unreleased]` and a HEAD that is not `origin/main`.
204
+ 31. **Auto-pin** (`auto-pin.test.ts`) — the selector gains the best route's
205
+ `@<slug>`; the chain carries every gate-passing route of the primary (all but
206
+ the pinned best) then of each fallback model, in score order (the `vfm` route
207
+ score desc, spec #71), deduped by
208
+ provider slug; a degraded / over-`maxPriceUsdPerM` / endpoint-filter-failing /
209
+ whitelist-blocked route is excluded; the manual pin wins and the no-pin path
210
+ is bare; a re-run is idempotent and a pin change prunes the old key.
211
+ 32. **Provider display** (`provider-display.test.ts`,
212
+ `provider-display-wiring.test.ts`) — `providerPinOf` reads the single
213
+ `openRouterRouting.only[0]` (else `vercelGatewayRouting`), preferring
214
+ OpenRouter, and returns null for a missing/empty/multi-entry block or a
215
+ non-string entry; `providerPinOfSelector` parses the trailing `@<slug>` with
216
+ and without a thinking suffix and returns null without one;
217
+ `withProviderSuffix` appends, strips a previous suffix, is idempotent, and
218
+ returns the base name for a null pin; `applyProviderDisplay` mutates a
219
+ record's `name` and no-ops on a non-record or a non-string name. The wiring
220
+ (a fake `pi`/`ctx` recording handlers) suffixes the live model on
221
+ `session_start`, applies the pin parsed from `event.to` on
222
+ `retry_fallback_applied` (falling back to the live model's routing block),
223
+ applies the pin from `event.model` on `retry_fallback_succeeded`, re-derives
224
+ from the live model on `turn_start` (the silent-restore case), strips when
225
+ neither source carries a pin, never calls `pi.setModel`, and contains a
226
+ throwing live-model read.
227
+
228
+ ## Live checks
229
+
230
+ - **E2E refresh** — `omp plugin link .` → new omp session → observe the day-gated
231
+ run; `/refresh-roles` → verify the `config.yml` diff + notification; a second
232
+ same-day session → no-op; a forced `/refresh-roles` matches the in-session
233
+ decisions; `/explore-roles --no-open` → `curl` the notified port for
234
+ `/api/bootstrap`, a repeat invocation keeps the same port, and the port is
235
+ released when the session ends.
236
+ - **Agent creation** — `/create-agent` in a session (RPC mode dispatches slash
237
+ commands) → the architect authors the body, the updater line
238
+ `@<n>: (unset) -> <selector>` + `modelRoles.<n>` land in `config.yml`, then a
239
+ headless spawn whose record reads
240
+ `{"agent":"<n>","agentSource":"user","modelRole":"<n>"}` with `resolvedModel`
241
+ equal to the role's selector.
242
+ - **Benchmark sources** — a role weighting `bench:<id>` fetches the source and
243
+ ranks on it; the explorer's `/api/bootstrap` lists the external metric with its
244
+ derived metadata.
245
+ - **Agent removal** — `/remove-agent --name <n>` in a session deletes both
246
+ artifacts and the updater pass removes the config entries.
247
+ - **CI/CD** — push a branch and open a PR (or push to `main`) and `gh run watch`
248
+ the `test` job green; push a tag that disagrees with the package version and
249
+ confirm the `release` job fails in the `notes` guard *before* any publish;
250
+ run the release job against an already-cut tag
251
+ (`gh workflow run ci.yml --ref v<version>`) and confirm npm's `gitHead` equals
252
+ the tag commit, `dist-tags.latest` moved, and the GitHub Release body is that
253
+ version's CHANGELOG section. See [`ci.md`](ci.md).
@@ -1,22 +1,25 @@
1
1
  # Writes and state
2
2
 
3
3
  Maintainer reference for the plugin's write path and the files it owns.
4
- Normative decisions: [`spec.md`](spec.md) §6.2, §6.4, §8 and §9. Implementation:
5
- `src/config-edit.ts` (the edit), `src/state.ts` (the files), `src/updater.ts`
6
- (orchestration), `src/agent-pins.ts` (the pin-derived sync).
4
+ Implementation: `src/config-edit.ts` (the edit), `src/state.ts` (the files),
5
+ `src/updater.ts` (orchestration), `src/agent-pins.ts` (the pin-derived sync).
7
6
 
8
7
  ## The surgical config edit
9
8
 
10
9
  `patchConfig(configText, patch)` (`src/config-edit.ts`) rewrites
11
10
  `~/.omp/agent/config.yml` as **text**, line-oriented — no runtime YAML
12
- dependency (required for marketplace installs). It touches exactly three
13
- surfaces:
11
+ dependency (required for marketplace installs). omp hot-reloads `config.yml`
12
+ (task/eval preflight re-reads the settings) and writes it under its own
13
+ `config.yml.lock`, so the plugin's mtime guard and refresh lock are what keep a
14
+ concurrent omp write from being clobbered. It touches exactly three surfaces:
14
15
 
15
16
  - `modelRoles.<role>` values (upsert missing roles at the block end, two-space
16
17
  indent; delete the line for a role that left the managed set);
17
18
  - the managed keys inside `retry.fallbackChains` (replace-in-place, never
18
19
  duplicate a YAML key; prune keys the plugin wrote on a previous run and no
19
- longer references);
20
+ longer references). A pinned role's chain key is the level-free form of its
21
+ selector and carries the `@<slug>` pin (`openrouter/<id>@<slug>`); the pin
22
+ rides before the thinking suffix on the values;
20
23
  - the plugin-managed names in `task.disabledAgents` (add/remove, other entries
21
24
  and their order preserved).
22
25
 
@@ -36,16 +39,40 @@ Rules the algorithm enforces:
36
39
  documents; `findTopLevel` rejects inline values, indented-only blocks and
37
40
  duplicates; `chainEntries` rejects odd indents, duplicate chain keys and
38
41
  unexpected lines. Any surprise throws `ConfigEditError` and the caller aborts
39
- without writing.
42
+ without writing. **Exception — an empty managed collection is not a
43
+ surprise.** omp re-serializes `config.yml` through its own writer (Bun's
44
+ `YAML.stringify` via `stringifyYamlConfig`), which spells an empty collection
45
+ either as a null token on the key line (`task.disabledAgents: null`) or as a
46
+ flow collection on its own indented line (`fallbackChains:` then ` {}`,
47
+ `task.disabledAgents:` then ` []`). Both read as the empty collection, and
48
+ the patch path rewrites that placeholder line into the block form
49
+ (`disabledAgents:` + `- "designer"`) instead of refusing the run — a user's
50
+ `disabledAgents: null` used to abort every session start with `config edit
51
+ refused` (issue #54). Any *other* inline value on a managed key line
52
+ (`disabledAgents: ["x"]`) still throws. The rule applies at both levels: a
53
+ managed key's own placeholder (`task.disabledAgents:` + ` []`) **and** an
54
+ empty **parent** block (`task:` + ` {}`, `retry:` + ` {}`), where the new
55
+ child key replaces the `{}` line — inserting *after* it emits YAML no parser
56
+ accepts, while the read-back self-check still passes, so that corruption was
57
+ silent. A prune-only patch creates no key at all.
58
+ - **Blocks are bounded by their own indent.** `blockEnd(lines, key, indent)`
59
+ stops at the first line at or above the key's indent, so `chainEntries` sees
60
+ only the `fallbackChains` block. A sibling `retry.<other>` key — omp ships ten
61
+ (`retry.maxRetries`, `retry.enabled`, `retry.baseDelayMs`, …) and writes them
62
+ into the same `retry:` block — therefore ends the block instead of throwing
63
+ `unexpected line in fallbackChains block`, and a new chain key is appended
64
+ inside the block rather than after it.
40
65
  - **Read-back self-check.** After patching, `patchConfig` re-reads the patched
41
66
  text through the same line-oriented reader and asserts it equals the intended
42
67
  state (every upserted role, every removal, the exact chain-key set, every
43
68
  disabled-agent add/remove). On any mismatch it throws — never corrupt.
44
69
  - **Atomic write.** `writeConfigAtomic(path, patchedText, mtimeBefore)` re-stats
45
70
  the mtime first; a changed mtime means the config moved underneath and returns
46
- `"conflict"` (the caller re-reads and recomputes). Otherwise it writes
47
- `<path>.llm-role-tmp` and `rename`s over the target. A file absent at read
48
- time (`mtimeBefore === 0`) is writable only while still absent.
71
+ `"conflict"` (the caller re-reads and recomputes). Otherwise it `mkdir -p`s the
72
+ target's directory, writes `<path>.llm-role-tmp` and `rename`s over the target.
73
+ A file absent at read time (`mtimeBefore === 0`) is writable only while still
74
+ absent. The `mkdir -p` is what lets the project scope write into
75
+ `<cwd>/.omp/plugins/`, which omp does not create until something writes there.
49
76
  - **3-attempt mtime retry.** `writePatch` (`src/updater.ts`) loops up to
50
77
  `CONFLICT_RETRIES = 3`: read → patch → atomic write; a `"conflict"` retries,
51
78
  three conflicts abort with `CONFLICT_ABORT`.
@@ -53,31 +80,191 @@ Rules the algorithm enforces:
53
80
  (below); a lock it cannot acquire returns `"locked"` and the caller aborts
54
81
  (or no-ops, for the day-gated sync).
55
82
 
83
+ ## The settings lock file and the feature flags
84
+
85
+ The plugin's settings live in `~/.omp/plugins/omp-plugins.lock.json` →
86
+ `settings["omp-llm-role"]` (or the project lock in project mode). The write path
87
+ (`src/role-settings.ts`) stores every role key as a **flat dotted key** because
88
+ omp's `/settings` Plugins tab shallow-merges the settings object and does not
89
+ flatten nested objects. The capability flags join that set:
90
+
91
+ - `features.<id>` — the global flag for capability `<id>`;
92
+ - `roles.<role>.features.<id>` — the per-role flag (wins over the global in
93
+ either direction);
94
+ - `roles.<role>.preferOwnProvider` — the `providerPinning` policy field.
95
+
96
+ `mergeExport` writes these as flat dotted keys alongside the existing role keys
97
+ (`roles.<name>.weights.<metric>`, `.description`, `.enabled`, `.locked`,
98
+ `.required`, `.thinking`, `.providerPin`, `.cacheHitRate`, `.lambda`,
99
+ `.filters.image`). `mergeExport` first deletes the role's existing
100
+ `roles.<name>.*` flat dotted keys, so an Export means "make this role's keys
101
+ match this def" — a capability flag set back to inherit (or a cleared knob) is
102
+ removed rather than left stale. `mergeRemove` needs no change: its existing
103
+ `roles.<name>.` prefix already covers the new keys, so `/remove-agent` deletes
104
+ them.
105
+
106
+ **Validation aborts** (notify, no write), in the existing one-error-string-per-
107
+ violation style (`resolveSettings` in `src/settings.ts`):
108
+
109
+ - `features.<id> is not a known capability` / `role <name>: features.<id> is not
110
+ a known capability` — an unknown id, global or per-role;
111
+ - `features.<id>: must be a boolean, got <json>` / `role <name>: features.<id>
112
+ must be a boolean, got <json>` — a non-boolean flag value;
113
+ - `features: must be an object, got <json>` / `role <name>: features must be an
114
+ object` — a non-record `features`;
115
+ - `role <name>: preferOwnProvider must be a boolean, got <json>` — a non-boolean
116
+ policy field.
117
+
118
+ **`mergeRawSettings` does NOT expand.** `resolveSettings` calls
119
+ `expandFeatures` per role, so its `roles` map is the **effective** def (the
120
+ authored def with the enabled flags' recommended knobs filled in, `features`
121
+ stripped). `mergeRawSettings` — and therefore `roleUniverse(...).def` — keeps the
122
+ **authored** def (shipped ⊕ raw overrides, no preset, `features` retained),
123
+ because that is the def the explorer edits and exports. Expanding inside
124
+ `mergeRawSettings` would make a role whose only authored change is a flag read as
125
+ dirty against its own baseline, and `mergeExport` (which writes every key of a
126
+ dirty def) would persist the expanded knobs as explicit values — silently
127
+ converting the flag into hard values and defeating both "adjust by hand" and
128
+ re-applying a changed recommendation. Export writes the **flag**, never the
129
+ expanded values.
130
+
131
+ **Near-collision warning.** omp's plugin-level `enabledFeatures` (present in the
132
+ lock file, `null` by default) is a **different gate** — omp's own feature
133
+ gating — that this plugin never reads (zero occurrences in `src/`, `web/`,
134
+ `tests/`). The plugin's `features.<id>` keys are plugin settings, not omp
135
+ feature flags; do not merge the two.
136
+
137
+ ## The provider-pin auto-configuration (spec #58)
138
+
139
+ When a role's **expanded** def sets `preferOwnProvider: true` (the
140
+ `providerPinning` capability's recommended bundle) and carries **no** manual
141
+ `providerPin`, the updater binds the role's requests to the chosen model's best
142
+ route by writing that route's provider slug as the selector's `@<slug>` suffix:
143
+
144
+ ```
145
+ openrouter/<id>@<slug>:<level>
146
+ ```
147
+
148
+ omp's `splitUpstreamRouting` parses the trailing `@<slug>` and applies
149
+ `compat.openRouterRouting = { only: [slug] }`, so the price the ranking assumed
150
+ is the price the request pays. The slug is `bestRoute(chosenModel, def)`'s
151
+ `providerSlug` — the max-value-for-money candidate route (see scoring.md,
152
+ *Route ordering*). The pin is re-derived on every run, so it follows the ranking when the
153
+ chosen model changes. A manual `roles.<role>.providerPin` **wins** over the
154
+ automatic choice and stays a **hard** gate (a model with no matching route is
155
+ ineligible); clearing it resumes the automatic choice.
156
+
157
+ **Chain shape.** The primary's chain key is the selector minus the trailing
158
+ `:level` — so it **includes** the pin (`openrouter/<id>@<slug>`). Its values, in
159
+ order:
160
+
161
+ 1. the same model on **every** gate-passing route except the pinned best, in
162
+ score order (the `vfm` route score desc — see scoring.md, *Route ordering*),
163
+ 2. then, for each of the `fallbackChainDepth` fallback models, that model on
164
+ **every** gate-passing route, in score order.
165
+
166
+ Length = `(R_primary − 1) + Σ R_fallback`, where `R` is the number of distinct
167
+ gate-passing provider slugs for that model — data-driven, not a fixed cap.
168
+ Entries dedup by chain value, so two routes sharing a provider slug collapse to
169
+ one. Every entry carries the same `@<slug>` suffix (and the role's thinking level
170
+ where the target advertises it). A model with no gate-passing route contributes
171
+ nothing. The gates are the engine's `rankedRoutes` set: non-degraded
172
+ (`status === 0`), the endpoint capability filters (`tools` / `minContextTokens` /
173
+ `minOutputTokens`), a usable billed price, and the `maxPriceUsdPerM` cap
174
+ (thinking-adjusted). The soft `filters.preferredMinThroughput` floor is a
175
+ **preference, not a gate** — it sorts below-floor routes last but never drops
176
+ them, so a below-floor route still appears in the chain. The allowed-providers
177
+ whitelist is not a `rankedRoutes` filter: the updater prunes each model's route
178
+ pool to allowed providers before ranking, which is what makes `rankedRoutes`
179
+ whitelist-aware. The probe walk target is unchanged (`1 + fallbackChainDepth` —
180
+ it counts models, not routes).
181
+
182
+ **Prune on a pin change.** Because the chain key includes the pin, a pin change
183
+ changes the key; the updater's chain prune (`state.pluginWrittenChainKeys`) drops
184
+ the old key so a stale pinned chain does not linger.
185
+
186
+ **No-ops.** The auto-pin writes nothing when: the capability is off
187
+ (`preferOwnProvider` not `true`); a manual `providerPin` is set; the role is
188
+ locked or disabled; the selector is not an OpenRouter selector; the model has no
189
+ route data; no route passes the role's endpoint filters; or the best route has no
190
+ usable billed price. With the capability off the written config is byte-identical
191
+ to before.
192
+
56
193
  ## State, history, lock
57
194
 
58
195
  All under the agent dir (`agentDir()` in `src/state.ts`), next to the config
59
196
  they describe. Agent-dir resolution: `OMP_LLM_ROLE_AGENT_DIR` (test hook) →
60
197
  `PI_CODING_AGENT_DIR` → non-default `OMP_PROFILE` → `~/.omp/agent`.
61
198
 
199
+ **Project scope (issue #27).** `runUpdater` resolves the project dir as
200
+ `<cwd>/.omp` (omp's project dir, no walk-up). When
201
+ `<cwd>/.omp/plugins/omp-plugins.lock.json` carries a non-empty `omp-llm-role`
202
+ settings entry, the whole run is scoped to the project: the config
203
+ (`<cwd>/.omp/config.yml`), the settings read (project lock over the user-level
204
+ one), and the state/history/lock files all live under `<cwd>/.omp`. The global
205
+ `~/.omp/agent/config.yml` is **never** written in project mode. Without a project
206
+ settings entry the global behavior is unchanged.
207
+
62
208
  | File | Shape | Purpose |
63
209
  |---|---|---|
64
210
  | `llm-role-state.json` | `{ lastRunDay, managedRoles, roleLastSelector, pluginWrittenChainKeys, previousModelRoles, managedDisabledAgents }` | day gate, managed-role set, last selectors, plugin-owned chain keys, pre-write `modelRoles` snapshot, **managed `task.disabledAgents` names** |
65
211
  | `llm-role-history.jsonl` | one JSON row per completed run: `{ ts, trigger, keyMeta{isFreeTier, limitRemaining, creditsRemaining}, decisions[] }` | append-only decision log |
212
+ | `llm-role-projects.json` | `{ projects: [{ root, lastUsed }] }` | the plugin's memory of the projects where `/project-roles` was used (the explorer's scope list) |
66
213
  | `.llm-role-refresh.lock` | `O_EXCL` create, holds `"<pid> <iso>"` | serializes concurrent session starts; stale (> 60 s) locks are unlinked and retried once |
214
+ | `llm-role-error-reports.json` | `{ reports: [{ fingerprint, label, title, firstSeen, lastSeen, createdDay?, offeredDay?, count, action, issueNumber?, issueUrl? }], dayCount: { day, count } }` | what the opt-in `errorReporting` path filed, offered or suppressed |
215
+
216
+ - `llm-role-projects.json` (issue #28) is **global knowledge** and lives in the
217
+ agent dir, never a project's `.omp`. `registerProject(root)` (`src/project-registry.ts`)
218
+ upserts the root (stamped now) and prunes entries whose project lock file is
219
+ gone, then replaces the file atomically (temp + rename) so two sessions in
220
+ different repos cannot interleave into a corrupt file. Reading is pure and
221
+ tolerant: a missing or unparseable file yields `[]`. The write point is the
222
+ project-aware `runUpdater`, **immediately after `resolveProjectDir()` and
223
+ before any early return**, guarded only by `dryRun` — so a day-stamped no-op
224
+ session start still registers the project (the whole point of "projects
225
+ configured before this feature appear on their next session start"), while a
226
+ `--dry-run`/cancelled `/project-roles` registers nothing. A write failure is
227
+ logged, never thrown: the registry is a convenience index, not a run
228
+ prerequisite.
229
+
230
+ - `llm-role-error-reports.json` is the ledger of the opt-in error-reporting path
231
+ (`src/error-report.ts`, `ERROR_REPORT_LEDGER_FILE`) — the plugin's memory of
232
+ what it filed, offered or suppressed. Like `llm-role-projects.json` it is
233
+ **global knowledge** and lives in the agent dir, never a project's `.omp`; the
234
+ write point is `writeLedger` (temp file + `rename`, so two sessions cannot
235
+ interleave into a corrupt file) and reading is pure and tolerant — missing,
236
+ unparseable or unexpected-shape content yields an empty ledger, so a corrupt
237
+ ledger never blocks reporting or the run. Each `reports[]` row remembers one
238
+ fingerprint's `action` (`created`/`commented`/`offered`/`suppressed`), its
239
+ `issueNumber`/`issueUrl`, the `createdDay`/`offeredDay` that gate re-creation
240
+ and re-offering, and a `count`; `dayCount` tracks the UTC-day creation total
241
+ that feeds the 3-creations-per-day cap. Nothing is written when
242
+ `errorReporting` is `off`.
67
243
 
68
244
  - `previousModelRoles` is the full snapshot of the last `modelRoles` block
69
245
  **before** the plugin changed it — a manual rollback aid; there is no rollback
70
- command (spec §12).
246
+ command.
71
247
  - `pluginWrittenChainKeys` is what makes chain pruning safe: only keys the
72
248
  plugin wrote on a previous run are candidates for pruning; an owner-written,
73
249
  unreferenced key is left alone.
74
250
  - `managedRoles` drives `roleRemovals`: a role the plugin managed on a previous
75
251
  run but no longer does (disabled via `enabled: false` / `weights: null`, or
76
252
  removed from settings) has its `modelRoles.<role>` line deleted, so a stale
77
- pin cannot keep routing `@<role>`.
253
+ pin cannot keep routing `@<role>`. A role whose provider pin matched no route
254
+ joins `managedRoles` without a rewrite, so its current chain survives the
255
+ prune.
78
256
  - `managedDisabledAgents` is the set of agent names the plugin added to
79
257
  `task.disabledAgents`; a later run removes a name whose agent file is gone
80
258
  (e.g. `/remove-agent`) or whose role is no longer disabled.
259
+ - Each `decisions[]` entry carries `availabilitySource` (`"keyed-catalog"` when the
260
+ keyed catalog was active for that role, else `"probe"`) and `keyBlockedCount` (the
261
+ candidates pruned as key-blocked). The keyed-blocked set itself is deliberately
262
+ **not** enumerated — it can be hundreds of ids; `blocked[]` keeps its old meaning,
263
+ the probe-blocked ids examined in the walk. `endpointBlocked[]` records the ranking
264
+ ids the role's endpoint filters dropped (no capable standard-tier route), rendered
265
+ `; endpoint-blocked: …` on the decision line; `pinBlocked[]` records the ranking ids
266
+ the role's provider pin dropped (no route matching the pin), rendered
267
+ `; pin-blocked: …`.
81
268
  - Role removal (`/remove-agent`) goes through `removeRoleSettings` in
82
269
  `src/role-settings.ts` — the same validate/backup/atomic-write path as
83
270
  `writeRoleSettings`, deleting the role's flat dotted keys and any nested entry.
@@ -92,7 +279,8 @@ no-op. `/refresh-roles` passes `force` and always runs.
92
279
  A concurrent second starter loses the lock and finds the day already stamped →
93
280
  no-op. Session-start runs are **awaited** before the first prompt is dispatched
94
281
  (a deferred timer would be cleared when a short-lived session exits before it
95
- fires).
282
+ fires). The gate is **per scope**: a project run loads the project's own
283
+ `llm-role-state.json`, so a project and the global config each refresh once a day.
96
284
 
97
285
  ## `task.disabledAgents` sync (pin-derived, not day-gated)
98
286
 
@@ -127,27 +315,43 @@ session, not the next day. Two consequences a maintainer must know:
127
315
  ## Error handling
128
316
 
129
317
  `runUpdater` wraps the whole run; `ConfigEditError` and any other throw become
130
- `omp-llm-role: aborted, no write: <message>`. Abort paths and what each leaves
131
- untouched:
318
+ `omp-llm-role: aborted, no write: <message>`. The aborts split into two classes,
319
+ and the `defect` column below marks which ones `RunResult` flags: an **exception
320
+ abort** (a bug this plugin should fix) carries `defect` and, when the opt-in
321
+ `errorReporting` setting is on, is reported through `src/error-report.ts`; an
322
+ **enumerated environment abort** (an expected condition) leaves `defect` unset
323
+ and is notify-only. Abort paths and what each leaves untouched:
132
324
 
133
- | Failure | Behavior | State / config |
134
- |---|---|---|
135
- | Settings validation fails (spec §7) | abort, notify the offending role/key | no write, no state change |
136
- | Ranking data unavailable (fetch fails, no fresh cache) | abort, notify | no write, no state change |
137
- | Key fetch (`omp token`/registry) or `/api/v1/key`, `/api/v1/credits` fails | abort, notify | no write, no state change |
138
- | Catalog fetch fails | abort, notify | no write, no state change |
139
- | Both tier branches false (no budget, no free quota) | abort, notify | no write, no state change |
140
- | Lock not acquired | abort (`LOCK_ABORT`) | no write, no state change |
141
- | Write conflict after 3 retries | abort (`CONFLICT_ABORT`) | no write, state file untouched |
142
- | `patchConfig` structural surprise / self-check mismatch | abort (`config edit refused: …`) | no write, no state change |
143
- | Zero decisions changed | no write at all (config mtime untouched) | history row appended (full run) |
144
- | Dry run | decisions reported, nothing written | no day-gate stamp, no history row |
325
+ | Failure | Behavior | State / config | `defect` |
326
+ |---|---|---|---|
327
+ | Settings validation fails | abort, notify the offending role/key | no write, no state change | — |
328
+ | Ranking data unavailable (fetch fails, no fresh cache) | abort, notify | no write, no state change | — |
329
+ | Key fetch (`omp token`/registry) or `/api/v1/key`, `/api/v1/credits` fails | abort, notify | no write, no state change | — |
330
+ | Catalog fetch fails | abort, notify | no write, no state change | yes |
331
+ | Both tier branches false (no budget, no free quota) | abort, notify | no write, no state change | — |
332
+ | Lock not acquired | abort (`LOCK_ABORT`) | no write, no state change | — |
333
+ | Write conflict after 3 retries | abort (`CONFLICT_ABORT`) | no write, state file untouched | — |
334
+ | `patchConfig` structural surprise / self-check mismatch | abort (`config edit refused: …`) | no write, no state change | yes |
335
+ | Any other throw inside the run (the catch-all) | abort, notify | no write, no state change | yes |
336
+ | Zero decisions changed | no write at all (config mtime untouched) | history row appended (full run) | — |
337
+ | Dry run | decisions reported, nothing written | no day-gate stamp, no history row | — |
338
+
339
+ - **`defect` vs the enumerated aborts.** The enumerated environment aborts —
340
+ settings invalid, ranking data unavailable, tier `none`, lock, write conflict
341
+ and the key-fetch failure — are conditions of the user's machine or account,
342
+ not plugin defects, so `runUpdater` returns them without `defect` and the
343
+ extension does not report them. Only the exception aborts carry `defect` (the
344
+ message), and the extension's `reportDefect` helper routes it to
345
+ `reportUnexpectedError`. A command handler that throws *outside* `runUpdater`
346
+ (the six catch sites) is reported the same way via `reportCommandError`.
145
347
 
146
348
  - The day-gated agent sync's lock/conflict failures are handled separately: a
147
349
  lock it cannot acquire is benign (`{ decisions: [], wrote: false }`); a
148
350
  conflict aborts with `CONFLICT_ABORT`.
149
351
  - A role whose probed candidates are all blocked is left untouched with a notify
150
- note (not an abort).
352
+ note (not an abort). A role whose provider pin matches no route is likewise
353
+ left untouched — no selector or chain upsert — with a notify note naming the
354
+ role and the pin.
151
355
  - The live-session model hook (`applySessionModel`) runs only after a real write
152
356
  and only on a real `default` change; a hook failure is notified but never
153
357
  fails the run (the config write already succeeded).