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
@@ -0,0 +1,206 @@
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. **Endpoint ceilings** (`endpoint-ceilings.test.ts`) — the narrower reads
56
+ context/output/tools, a missing field is null; a failing route drops, a null
57
+ field is kept; a model with no capable route is ineligible; a filtered role
58
+ is priced on the surviving pool; `maxPriceUsdPerM` caps the thinking-adjusted
59
+ price.
60
+ 12. **Route pricing (provider pin)** (`route-pricing.test.ts`) — a pinned role
61
+ prices the matching route, not the blend; a tiered pin matches verbatim;
62
+ `maxPriceUsdPerM` caps the route price; throughput is the route p50 with a
63
+ blend fallback; no matching route → ineligible; the `@<slug>` selector and
64
+ chain carry the pin; a pin matching no route leaves the role unchanged.
65
+ 13. **Thinking price** (`thinking-price.test.ts`) — bare/off unadjusted; a level
66
+ scales only thinking-capable models; the frontier uses the effective price;
67
+ `resolveSettings` validates thinking and rejects the legacy `suffixes` map;
68
+ catalog `thinking[]` wins over the OR flag; meta levels never gate.
69
+ 14. **OpenRouter blend** (`openrouter-blend.test.ts`) — price/throughput are the
70
+ `1/price²`-weighted means over the page routes; flex/priority/degraded/free/
71
+ `:batch` stay out; throughput renormalizes; the find-row fallback; the weight
72
+ follows the input price.
73
+ 15. **Writing metric** (`writing.test.ts`) — `parseWritingEvidence` keys finite
74
+ scores and rejects junk; `applyWritingScores` fills uncovered models; a
75
+ covered model ranks above an uncovered one.
76
+ 16. **Benchmark sources** (`benchmark-sources.test.ts`) — `resolveBenchmarkSource`
77
+ maps an llm-stats benchmark page, a bare id, the writing leaderboard and a
78
+ Design Arena link, `null` for an unknown host; `normalizeMetricKey` is
79
+ dot-free; `parseBenchmarkPayload` reads the llm-stats `entries[]` and writing
80
+ evidence shapes and rejects junk; `parseBenchmarkPayloadMeta` reads the
81
+ per-entry `self_reported`/`verified` flags (19/20 → `trust`, no flag → `null`,
82
+ no entries shape → `null`); `parseBenchmarkCatalog` carries `isCommunity`,
83
+ `modality` and the maintenance fields (`updatedAt`/`versionCount`/
84
+ `latestVersionRowCount`/`starCount`, each `null` when missing or non-finite);
85
+ the catalog cache rejects a pre-`isCommunity`/`modality`/maintenance cache
86
+ (and an empty one); the scores cache rejects a cache lacking the `meta` key
87
+ (and accepts `meta: null`);
88
+ `applyBenchmarkScores` fills uncovered
89
+ models; `loadBenchmarkScores` follows fresh → live → stale with a temp cache
90
+ dir and an injected fetch; a declaration executes (payload path, id/score
91
+ fields, `scoreMax`, join) and a metric colliding with a shipped key is
92
+ rejected; `dryRunDeclaration` reports coverage/leader and rejects a zero-join;
93
+ `extractBenchmarkLinks` pulls URLs from prose.
94
+ 17. **Agent creation** (`create-agent.test.ts`) — a purpose fits the expected
95
+ archetype; a `--weights` set violating Σ(non-price) = 1 − w_price is refused;
96
+ an existing agent file without `--force` is refused and writes no role;
97
+ `--dry-run` writes neither file; an architect `spec` replaces the
98
+ description/body; `applyFocusBenchmarks` gives a named benchmark a decisive
99
+ share, is a no-op for the archetype's own specialist set, and keeps both
100
+ invariants; the nine-axis gate (the maintenance, provenance and cross-source
101
+ axes are annotations, never a drop).
102
+ 18. **Agent removal** (`remove-agent.test.ts`) — the role's lock-file keys and the
103
+ agent file are deleted (backup written); a shipped default role, a reserved
104
+ name and an invalid name are refused; nothing-to-remove errors; `--dry-run`
105
+ writes nothing; the updater drops `modelRoles.<n>` and the plugin-managed
106
+ `task.disabledAgents` entry.
107
+ 19. **Agent pins / disable** (`agent-pins.test.ts`, `agent-disable.test.ts`) —
108
+ `parseAgentPin` takes the first `@role`; `discoverAgentPins` scans the three
109
+ scopes, most specific wins; the sync is not day-gated; an unrelated entry
110
+ keeps its position; an unknown role is never added.
111
+ 20. **Role enable / lock** (`role-enable.test.ts`, `role-lock.test.ts`) —
112
+ disabling drops the role from the resolved set and its `modelRoles` line; a
113
+ locked role is not switched and its chain survives the prune; unlocking
114
+ restores the switch.
115
+ 21. **Settings** (`settings.test.ts`, `settings-schema.test.ts`) — opt-in roles;
116
+ `priceSwitchFraction` range; flat/nested merge; `required` as string or
117
+ array; an external metric key is weightable, a dotted one rejected;
118
+ `package.json` `omp.settings` deep-equals `deriveSettingsSchema()`.
119
+ 22. **Explorer** (`explorer.test.ts`, `explorer-scopes.test.ts`) — rank deltas;
120
+ contributions sum to `q`; `inverseCardinal` round-trips; Export preserves
121
+ siblings and normalizes nested roles; the scope switcher; the availability
122
+ overlay.
123
+ 23. **Project scope** (`project-updater.test.ts`, `project-setup.test.ts`,
124
+ `project-roles-command.test.ts`) — project mode scopes config/state/history/
125
+ lock to `<cwd>/.omp`; the day gate is per scope; the registry write;
126
+ `setupProject` add/drop/keep, `--dry-run`, `--force`; the command is
127
+ registered.
128
+ 24. **Session model** (`session-model.test.ts`) — the hook fires only after a real
129
+ write on a real `default` change; a dry run and a kept default skip it; a
130
+ throwing hook is contained.
131
+ 25. **Docs** (`docs.test.ts`) — every relative markdown link resolves; no
132
+ reference to a moved doc; the CHANGELOG shape; `package.json` `files` entries
133
+ exist; the README has no dev-only headings.
134
+ 26. **Architect provenance** (`architect-provenance.test.ts`) — the vendored
135
+ prompt header matches `ARCHITECT_PROMPT_VERSION` and the version named in
136
+ [`agent-authoring.md`](agent-authoring.md).
137
+ 27. **Architect retry** (`agent-architect.test.ts`) — a malformed architect
138
+ response is retried once and the parsed spec returned; an always-malformed
139
+ run throws carrying the raw output; a `run()` failure propagates with no
140
+ retry.
141
+ 28. **Error reporting** (`error-report.test.ts`) — `reportUnexpectedError`
142
+ through its one entry point with fully injected deps: `off` → no fetch, no
143
+ ledger write, `action: "off"`; `auto` + token → exactly one create whose body
144
+ carries the fingerprint marker, plugin version, runtime, platform, label and
145
+ stack, with the `bug`/`needs-triage` labels; redaction (home dir → `~`,
146
+ `node_modules` prefix → `<pkg>/`, `sk-…`/`ghp_…`/`Bearer …` → `<redacted>`)
147
+ leaves nothing token-shaped in the title, body or URL; dedupe (an open match
148
+ is commented on, never a second create; a closed match on a later day
149
+ creates a fresh issue linking the old one); caps (the 4th distinct
150
+ fingerprint in one UTC day, and a fingerprint already created earlier the
151
+ same day even when its issue is closed, are both `suppressed` with no
152
+ request; a later UTC day re-creates the regression); permission fall-through
153
+ (`401`/`403`/`404` from the search or the create → `offered`, the creation
154
+ counters untouched); no token → no HTTP at all, an `issues/new` URL whose
155
+ query carries the title, body and labels opened exactly once; token
156
+ resolution (`GITHUB_TOKEN` → `GH_TOKEN` → the injected `gh` executor, with a
157
+ throwing or empty `gh` falling through to the URL path); containment (a
158
+ rejected fetch, a 500, a timeout and a malformed JSON body each → `failed`,
159
+ one notify line, no throw); fingerprint stability (a varying id inside the
160
+ message with the same first stack frame dedupes, a different label does not);
161
+ `ask` (a `confirm(true)` surfaces the payload and files exactly once, a
162
+ `confirm(false)` or a headless session sends nothing and offers the URL — it
163
+ never silently falls back to `auto`); an unrecognized `errorReporting` value
164
+ fails closed (no fetch, no ledger, `action: "off"`); a defect **string** (as
165
+ `reportDefect` passes it) is fingerprinted by its message, so two distinct
166
+ aborts under one label differ; the in-process guard; and a ledger that
167
+ tolerates junk and truncates a pathological stack — including a multi-byte
168
+ one — to the 64 KB **byte** budget.
169
+ 29. **CI/CD** (`ci.test.ts`) — the workflow parses and declares both jobs with
170
+ `release` needing `test`; the `test` job's command is identical to the
171
+ pre-commit hook's; the tag trigger covers every CHANGELOG tag link and the
172
+ package version; `.node-version` is the file the workflow reads; the release
173
+ job declares `id-token: write` and references no npm token; the four
174
+ lockstep version fields agree; no tracked file lives under `cache/`; and
175
+ `scripts/release.ts` against fixture repos — `due`'s exit codes, `notes`'s
176
+ five refusals and its exact section body, `cut --dry-run` writing nothing,
177
+ and `cut` bumping the four fields, cutting the changelog, committing and
178
+ tagging while refusing a dirty tree, an existing tag, an empty
179
+ `## [Unreleased]` and a HEAD that is not `origin/main`.
180
+
181
+ ## Live checks
182
+
183
+ - **E2E refresh** — `omp plugin link .` → new omp session → observe the day-gated
184
+ run; `/refresh-roles` → verify the `config.yml` diff + notification; a second
185
+ same-day session → no-op; a forced `/refresh-roles` matches the in-session
186
+ decisions; `/explore-roles --no-open` → `curl` the notified port for
187
+ `/api/bootstrap`, a repeat invocation keeps the same port, and the port is
188
+ released when the session ends.
189
+ - **Agent creation** — `/create-agent` in a session (RPC mode dispatches slash
190
+ commands) → the architect authors the body, the updater line
191
+ `@<n>: (unset) -> <selector>` + `modelRoles.<n>` land in `config.yml`, then a
192
+ headless spawn whose record reads
193
+ `{"agent":"<n>","agentSource":"user","modelRole":"<n>"}` with `resolvedModel`
194
+ equal to the role's selector.
195
+ - **Benchmark sources** — a role weighting `bench:<id>` fetches the source and
196
+ ranks on it; the explorer's `/api/bootstrap` lists the external metric with its
197
+ derived metadata.
198
+ - **Agent removal** — `/remove-agent --name <n>` in a session deletes both
199
+ artifacts and the updater pass removes the config entries.
200
+ - **CI/CD** — push a branch and open a PR (or push to `main`) and `gh run watch`
201
+ the `test` job green; push a tag that disagrees with the package version and
202
+ confirm the `release` job fails in the `notes` guard *before* any publish;
203
+ run the release job against an already-cut tag
204
+ (`gh workflow run ci.yml --ref v<version>`) and confirm npm's `gitHead` equals
205
+ the tag commit, `dist-tags.latest` moved, and the GitHub Release body is that
206
+ 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,135 @@ 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
+
56
137
  ## State, history, lock
57
138
 
58
139
  All under the agent dir (`agentDir()` in `src/state.ts`), next to the config
59
140
  they describe. Agent-dir resolution: `OMP_LLM_ROLE_AGENT_DIR` (test hook) →
60
141
  `PI_CODING_AGENT_DIR` → non-default `OMP_PROFILE` → `~/.omp/agent`.
61
142
 
143
+ **Project scope (issue #27).** `runUpdater` resolves the project dir as
144
+ `<cwd>/.omp` (omp's project dir, no walk-up). When
145
+ `<cwd>/.omp/plugins/omp-plugins.lock.json` carries a non-empty `omp-llm-role`
146
+ settings entry, the whole run is scoped to the project: the config
147
+ (`<cwd>/.omp/config.yml`), the settings read (project lock over the user-level
148
+ one), and the state/history/lock files all live under `<cwd>/.omp`. The global
149
+ `~/.omp/agent/config.yml` is **never** written in project mode. Without a project
150
+ settings entry the global behavior is unchanged.
151
+
62
152
  | File | Shape | Purpose |
63
153
  |---|---|---|
64
154
  | `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
155
  | `llm-role-history.jsonl` | one JSON row per completed run: `{ ts, trigger, keyMeta{isFreeTier, limitRemaining, creditsRemaining}, decisions[] }` | append-only decision log |
156
+ | `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
157
  | `.llm-role-refresh.lock` | `O_EXCL` create, holds `"<pid> <iso>"` | serializes concurrent session starts; stale (> 60 s) locks are unlinked and retried once |
158
+ | `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 |
159
+
160
+ - `llm-role-projects.json` (issue #28) is **global knowledge** and lives in the
161
+ agent dir, never a project's `.omp`. `registerProject(root)` (`src/project-registry.ts`)
162
+ upserts the root (stamped now) and prunes entries whose project lock file is
163
+ gone, then replaces the file atomically (temp + rename) so two sessions in
164
+ different repos cannot interleave into a corrupt file. Reading is pure and
165
+ tolerant: a missing or unparseable file yields `[]`. The write point is the
166
+ project-aware `runUpdater`, **immediately after `resolveProjectDir()` and
167
+ before any early return**, guarded only by `dryRun` — so a day-stamped no-op
168
+ session start still registers the project (the whole point of "projects
169
+ configured before this feature appear on their next session start"), while a
170
+ `--dry-run`/cancelled `/project-roles` registers nothing. A write failure is
171
+ logged, never thrown: the registry is a convenience index, not a run
172
+ prerequisite.
173
+
174
+ - `llm-role-error-reports.json` is the ledger of the opt-in error-reporting path
175
+ (`src/error-report.ts`, `ERROR_REPORT_LEDGER_FILE`) — the plugin's memory of
176
+ what it filed, offered or suppressed. Like `llm-role-projects.json` it is
177
+ **global knowledge** and lives in the agent dir, never a project's `.omp`; the
178
+ write point is `writeLedger` (temp file + `rename`, so two sessions cannot
179
+ interleave into a corrupt file) and reading is pure and tolerant — missing,
180
+ unparseable or unexpected-shape content yields an empty ledger, so a corrupt
181
+ ledger never blocks reporting or the run. Each `reports[]` row remembers one
182
+ fingerprint's `action` (`created`/`commented`/`offered`/`suppressed`), its
183
+ `issueNumber`/`issueUrl`, the `createdDay`/`offeredDay` that gate re-creation
184
+ and re-offering, and a `count`; `dayCount` tracks the UTC-day creation total
185
+ that feeds the 3-creations-per-day cap. Nothing is written when
186
+ `errorReporting` is `off`.
67
187
 
68
188
  - `previousModelRoles` is the full snapshot of the last `modelRoles` block
69
189
  **before** the plugin changed it — a manual rollback aid; there is no rollback
70
- command (spec §12).
190
+ command.
71
191
  - `pluginWrittenChainKeys` is what makes chain pruning safe: only keys the
72
192
  plugin wrote on a previous run are candidates for pruning; an owner-written,
73
193
  unreferenced key is left alone.
74
194
  - `managedRoles` drives `roleRemovals`: a role the plugin managed on a previous
75
195
  run but no longer does (disabled via `enabled: false` / `weights: null`, or
76
196
  removed from settings) has its `modelRoles.<role>` line deleted, so a stale
77
- pin cannot keep routing `@<role>`.
197
+ pin cannot keep routing `@<role>`. A role whose provider pin matched no route
198
+ joins `managedRoles` without a rewrite, so its current chain survives the
199
+ prune.
78
200
  - `managedDisabledAgents` is the set of agent names the plugin added to
79
201
  `task.disabledAgents`; a later run removes a name whose agent file is gone
80
202
  (e.g. `/remove-agent`) or whose role is no longer disabled.
203
+ - Each `decisions[]` entry carries `availabilitySource` (`"keyed-catalog"` when the
204
+ keyed catalog was active for that role, else `"probe"`) and `keyBlockedCount` (the
205
+ candidates pruned as key-blocked). The keyed-blocked set itself is deliberately
206
+ **not** enumerated — it can be hundreds of ids; `blocked[]` keeps its old meaning,
207
+ the probe-blocked ids examined in the walk. `endpointBlocked[]` records the ranking
208
+ ids the role's endpoint filters dropped (no capable standard-tier route), rendered
209
+ `; endpoint-blocked: …` on the decision line; `pinBlocked[]` records the ranking ids
210
+ the role's provider pin dropped (no route matching the pin), rendered
211
+ `; pin-blocked: …`.
81
212
  - Role removal (`/remove-agent`) goes through `removeRoleSettings` in
82
213
  `src/role-settings.ts` — the same validate/backup/atomic-write path as
83
214
  `writeRoleSettings`, deleting the role's flat dotted keys and any nested entry.
@@ -92,7 +223,8 @@ no-op. `/refresh-roles` passes `force` and always runs.
92
223
  A concurrent second starter loses the lock and finds the day already stamped →
93
224
  no-op. Session-start runs are **awaited** before the first prompt is dispatched
94
225
  (a deferred timer would be cleared when a short-lived session exits before it
95
- fires).
226
+ fires). The gate is **per scope**: a project run loads the project's own
227
+ `llm-role-state.json`, so a project and the global config each refresh once a day.
96
228
 
97
229
  ## `task.disabledAgents` sync (pin-derived, not day-gated)
98
230
 
@@ -127,27 +259,43 @@ session, not the next day. Two consequences a maintainer must know:
127
259
  ## Error handling
128
260
 
129
261
  `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:
262
+ `omp-llm-role: aborted, no write: <message>`. The aborts split into two classes,
263
+ and the `defect` column below marks which ones `RunResult` flags: an **exception
264
+ abort** (a bug this plugin should fix) carries `defect` and, when the opt-in
265
+ `errorReporting` setting is on, is reported through `src/error-report.ts`; an
266
+ **enumerated environment abort** (an expected condition) leaves `defect` unset
267
+ and is notify-only. Abort paths and what each leaves untouched:
132
268
 
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 |
269
+ | Failure | Behavior | State / config | `defect` |
270
+ |---|---|---|---|
271
+ | Settings validation fails | abort, notify the offending role/key | no write, no state change | — |
272
+ | Ranking data unavailable (fetch fails, no fresh cache) | abort, notify | no write, no state change | — |
273
+ | Key fetch (`omp token`/registry) or `/api/v1/key`, `/api/v1/credits` fails | abort, notify | no write, no state change | — |
274
+ | Catalog fetch fails | abort, notify | no write, no state change | yes |
275
+ | Both tier branches false (no budget, no free quota) | abort, notify | no write, no state change | — |
276
+ | Lock not acquired | abort (`LOCK_ABORT`) | no write, no state change | — |
277
+ | Write conflict after 3 retries | abort (`CONFLICT_ABORT`) | no write, state file untouched | — |
278
+ | `patchConfig` structural surprise / self-check mismatch | abort (`config edit refused: …`) | no write, no state change | yes |
279
+ | Any other throw inside the run (the catch-all) | abort, notify | no write, no state change | yes |
280
+ | Zero decisions changed | no write at all (config mtime untouched) | history row appended (full run) | — |
281
+ | Dry run | decisions reported, nothing written | no day-gate stamp, no history row | — |
282
+
283
+ - **`defect` vs the enumerated aborts.** The enumerated environment aborts —
284
+ settings invalid, ranking data unavailable, tier `none`, lock, write conflict
285
+ and the key-fetch failure — are conditions of the user's machine or account,
286
+ not plugin defects, so `runUpdater` returns them without `defect` and the
287
+ extension does not report them. Only the exception aborts carry `defect` (the
288
+ message), and the extension's `reportDefect` helper routes it to
289
+ `reportUnexpectedError`. A command handler that throws *outside* `runUpdater`
290
+ (the six catch sites) is reported the same way via `reportCommandError`.
145
291
 
146
292
  - The day-gated agent sync's lock/conflict failures are handled separately: a
147
293
  lock it cannot acquire is benign (`{ decisions: [], wrote: false }`); a
148
294
  conflict aborts with `CONFLICT_ABORT`.
149
295
  - A role whose probed candidates are all blocked is left untouched with a notify
150
- note (not an abort).
296
+ note (not an abort). A role whose provider pin matches no route is likewise
297
+ left untouched — no selector or chain upsert — with a notify note naming the
298
+ role and the pin.
151
299
  - The live-session model hook (`applySessionModel`) runs only after a real write
152
300
  and only on a real `default` change; a hook failure is notified but never
153
301
  fails the run (the config write already succeeded).