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
@@ -11,15 +11,16 @@
11
11
  * updater in-process right after (the same path `/refresh-roles` takes).
12
12
  */
13
13
 
14
- import { existsSync } from "node:fs";
14
+ import { existsSync, readFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
- import { AGENT_NAME_RE, RESERVED_AGENT_NAMES, isReadOnlyTools, projectAgentsDir, renderAgentFile, userAgentsDir, writeAgentFile } from "./agent-file.ts";
17
- import type { RoleDef, SuffixLevel } from "./engine.ts";
16
+ import { AGENT_NAME_RE, RESERVED_AGENT_NAMES, isReadOnlyTools, projectAgentsDir, removeAgentFile, renderAgentFile, userAgentsDir, writeAgentFile } from "./agent-file.ts";
17
+ import { CAPABILITY_FILL, cardinalMetric, rankRole, type Model, type RoleDef, type SuffixLevel } from "./engine.ts";
18
+ import { FEATURES, featureById } from "./features.ts";
18
19
  import { metricMeta } from "./explorer/explain.ts";
19
20
  import { ARCHETYPES, archetypeById, fitArchetype, type Archetype } from "./role-archetypes.ts";
20
- import { writeRoleSettings } from "./role-settings.ts";
21
+ import { writeRoleSettings, type RoleWriteResult } from "./role-settings.ts";
21
22
  import { isKnownMetric, KNOWN_METRICS, PLUGIN_SETTINGS_PATH } from "./settings.ts";
22
- import { catalogMetric, type BenchmarkCatalogEntry } from "./benchmark-sources.ts";
23
+ import { catalogBenchmarkDeclaration, catalogMetric, sourceForMetric, type BenchmarkCatalogEntry, type FocusSourceInfo, type SourceDeclaration } from "./benchmark-sources.ts";
23
24
 
24
25
  /** The architect's output (omp's `/agents` hub contract): the routing rule and
25
26
  * the system prompt, plus the identifier omp would use for the file name. */
@@ -42,6 +43,14 @@ export type CreateAgentRequest = {
42
43
  tools?: string[];
43
44
  scope: "user" | "project";
44
45
  image?: boolean;
46
+ /**
47
+ * Capability flags to author on the role (`roles.<name>.features.<id>`). The
48
+ * request wins over the archetype's own `features` list; an unknown id is
49
+ * rejected by the parser before any write. The flag is persisted, not the
50
+ * knobs it expands to, so a later change to the recommendation still reaches
51
+ * the role.
52
+ */
53
+ features?: Record<string, boolean>;
45
54
  /** Override the generated body. */
46
55
  body?: string;
47
56
  /**
@@ -66,6 +75,13 @@ export type CreateAgentRequest = {
66
75
  * body's `<criteria>` (the architect path gets them in its prompt instead).
67
76
  */
68
77
  benchmarkLabels?: string[];
78
+ /**
79
+ * Per-metric coverage of the ranking field, for the focus-coverage gate and
80
+ * the report annotation. The extension loads the ranking universe once and
81
+ * supplies it; omitted (the CLI, tests) means the field size is unknown and
82
+ * the share rule is skipped.
83
+ */
84
+ coverage?: FocusCoverage;
69
85
  /** Overwrite an existing agent file. */
70
86
  force: boolean;
71
87
  dryRun: boolean;
@@ -148,7 +164,7 @@ function buildBody(request: CreateAgentRequest, archetype: Archetype, readOnly:
148
164
  * half-enforces (it accepts Σ ∈ [0.99, 1.01]; `rankRole` computes
149
165
  * `q = Σ (wᵢ/(1−w_price))·tᵢ`, so any other split silently rescales q against λ).
150
166
  */
151
- function checkWeightMath(name: string, weights: Record<string, number>, required: readonly string[]): string[] {
167
+ export function checkWeightMath(name: string, weights: Record<string, number>, required: readonly string[]): string[] {
152
168
  const errors: string[] = [];
153
169
  const metrics = Object.keys(weights);
154
170
  if (metrics.length === 0) return [`role ${name}: no weights given`];
@@ -183,6 +199,10 @@ export type BenchmarkApplication = {
183
199
  added: string[];
184
200
  duplicates: string[];
185
201
  unknown: string[];
202
+ /** Focus metrics dropped by the top-K cap (priority order preserved). */
203
+ dropped: string[];
204
+ /** Nine-axis assessment per focus metric, for the report annotation. */
205
+ assessments: Record<string, FocusMetricAssessment>;
186
206
  };
187
207
 
188
208
  /** The backbone every role keeps: the general/reasoning quality axes plus the
@@ -194,6 +214,493 @@ const BACKBONE_METRICS: Record<string, true> = { general: true, reasoning: true,
194
214
  const FOCUS_SHARE_FLOOR = 0.25;
195
215
  const FOCUS_SHARE_CAP = 0.4;
196
216
 
217
+ /** The most focus metrics one role may weight, so one benchmark keeps a decisive
218
+ * share instead of five sharing the budget. */
219
+ export const FOCUS_METRIC_CAP = 3;
220
+
221
+ /** The coverage share a fill-0 focus metric must clear to be safe to weight. */
222
+ export const FOCUS_COVERAGE_FLOOR = 0.35;
223
+
224
+ /** The dispersion floor: IQR/median of the cardinal-normalized covered values.
225
+ * A metric flatter than this cannot separate models (saturation). */
226
+ export const FOCUS_DISPERSION_FLOOR = 0.05;
227
+
228
+ /** The staleness bar: months the covered set may trail the pool's newest model
229
+ * before the source is flagged as frozen. */
230
+ export const FOCUS_STALENESS_MONTHS = 1;
231
+
232
+ /** The dataset-staleness bar: months since the catalog row's `updated_at` before
233
+ * a benchmark's dataset is flagged as unmaintained. The issue's user story is
234
+ * "not updated in over a year", so 12 months. This is the **dataset-date** axis,
235
+ * distinct from `FOCUS_STALENESS_MONTHS` (the **model-date** freshness axis):
236
+ * freshness asks whether the benchmark tracks the model field, maintenance asks
237
+ * whether the benchmark *project* is alive. A benchmark whose dataset was last
238
+ * touched years ago but whose rows happen to include one recent model passes
239
+ * freshness and is caught here. */
240
+ export const FOCUS_DATASET_STALENESS_MONTHS = 12;
241
+
242
+ /** The pool share above which an org's absence from the covered set is flagged
243
+ * (a smaller provider's absence is not a false positive). */
244
+ export const FOCUS_ORG_MIN_SHARE = 0.1;
245
+
246
+ /** The self-reported share above which a source is flagged as vendor-submitted.
247
+ * The per-entry `self_reported` flag is the varying trust signal (measured
248
+ * 2026-10-07: gpqa 19/20, deepswe-1.1 14/20, terminal-bench-4.0 10/20,
249
+ * aa-omniscience-index 2/3, automationbench-aa 0/1, the general benchmarks
250
+ * ~20/20). A strict majority (0.5) flags the mostly-self-reported general/gpqa
251
+ * sources while passing an independently measured one (automationbench 0/1) and
252
+ * a half-and-half source (terminal-bench 10/20). `verified` is uniformly `false`
253
+ * today, so it is carried but not gated. */
254
+ export const FOCUS_SELF_REPORTED_MAX_SHARE = 0.5;
255
+
256
+ /** The per-entry org share above which a source is flagged as vendor-populated:
257
+ * one lab's own scores dominate the benchmark's entries. A **chosen, tunable
258
+ * heuristic** (a strict majority), not a measured value — the source's per-entry
259
+ * org mix is not measurable from the repo's caches at implementation time, so
260
+ * this bar is picked to flag a single-lab source while passing a mixed one. The
261
+ * axis is an annotation, never a gate: a `below-bar` provenance is warned but
262
+ * never drops a discovered candidate. */
263
+ export const FOCUS_PROVENANCE_DOMINANT_SHARE = 0.5;
264
+
265
+ /** The relative divergence above which the zeroeval per-entry price/context is
266
+ * flagged as disagreeing with the plugin's own value. A **chosen, tunable
267
+ * heuristic** (a factor-of-two band), not a measured value — the two sources are
268
+ * independent and a small divergence is expected (rounding, a different route
269
+ * mix). The axis is an annotation, never a gate. */
270
+ export const PRICE_AGREEMENT_TOLERANCE = 0.5;
271
+
272
+ /** The status vocabulary shared by every focus-metric axis. */
273
+ export type FocusSignal = "ok" | "below-bar" | "unknown";
274
+
275
+ /** Per-metric coverage of the ranking field (the coverage axis). */
276
+ export type FocusCoverageEntry = {
277
+ covered: number;
278
+ total: number | null;
279
+ share: number | null;
280
+ fill: number;
281
+ status: FocusSignal;
282
+ };
283
+
284
+ /** The nine-axis assessment of one focus metric: coverage, dispersion, provider
285
+ * composition, freshness, source trust, modality, benchmark maintenance,
286
+ * provenance and cross-source agreement. The single object the gate and every
287
+ * report share. */
288
+ export type FocusMetricAssessment = {
289
+ coverage: FocusCoverageEntry;
290
+ dispersion: { value: number | null; status: FocusSignal };
291
+ composition: { omittedOrgs: string[]; status: FocusSignal };
292
+ freshness: { newestCovered: string | null; newestPool: string | null; monthsBehind: number | null; status: FocusSignal };
293
+ /** The source's per-entry trust: the self-reported share of the covered
294
+ * entries. Zeros + `unknown` when the payload carried no entry flags (a
295
+ * declared/writing source) or the metric's scores were not loaded. `community`
296
+ * is the catalog row's `is_community` flag: a community-submitted benchmark is
297
+ * still visible even when the payload half is absent (its scores carry no
298
+ * entry flags). It is an **annotation, never a gate** — it never changes
299
+ * `status` and `belowBarReason` has no community branch. */
300
+ trust: { selfReported: number; covered: number; status: FocusSignal; community: boolean };
301
+ /** The benchmark's modality: the catalog row's `modality` and the payload's
302
+ * multimodal share. Informational only — there is no role modality field, so
303
+ * this axis is never `below-bar` and never a drop reason; it is `unknown` only
304
+ * when neither the catalog row nor the payload carries a modality signal. */
305
+ modality: { value: string | null; multimodalShare: number | null; status: FocusSignal };
306
+ /** The benchmark's own maintenance: the catalog row's `updated_at` age in
307
+ * months against `FOCUS_DATASET_STALENESS_MONTHS`, with `version_count` and
308
+ * `star_count` carried as supporting detail. This is the **dataset-date** axis,
309
+ * distinct from `freshness` (the **model-date** axis): it measures whether the
310
+ * benchmark *project* is alive, not whether it tracks the model field. It is an
311
+ * **annotation, never a gate** — `belowBarReason` has no maintenance branch, so
312
+ * a stale dataset is warned but never drops a discovered candidate. `unknown`
313
+ * when the catalog row is absent or its `updated_at` is missing/unparseable. */
314
+ maintenance: { updatedAt: string | null; monthsOld: number | null; versionCount: number | null; starCount: number | null; status: FocusSignal };
315
+ /** The benchmark's provenance: the catalog row's owning organisation
316
+ * (`dataset_org_id`, falling back to `dataset_slug`) and the source's own
317
+ * per-entry org mix (the dominant lab and its share). This is the **source's
318
+ * own claim** about who publishes the benchmark and whose scores it carries —
319
+ * an independent cross-check on the pool-derived `composition` axis, not a
320
+ * replacement for it. `compositionAgreement` records whether the two concur:
321
+ * `"disagree"` when exactly one of the two flags `below-bar`, `"agree"` when
322
+ * both or neither do, `"unknown"` when the payload carries no org mix. It is an
323
+ * **annotation, never a gate** — `belowBarReason` has no provenance branch, so
324
+ * a vendor-populated source is warned but never drops a discovered candidate.
325
+ * `unknown` when there is neither an owner nor a payload org mix. */
326
+ provenance: {
327
+ owner: string | null;
328
+ dominantOrg: string | null;
329
+ dominantShare: number | null;
330
+ compositionAgreement: "agree" | "disagree" | "unknown";
331
+ status: FocusSignal;
332
+ };
333
+ /** The cross-source agreement: the zeroeval per-entry price/throughput/context
334
+ * (`BenchmarkPayloadMeta.crossSource`) compared against the plugin's own
335
+ * `price`/`throughput`/`context` for the models that carry the metric. The join
336
+ * is **direct on the bare `model_id`** (the generic llm-stats benchmark's
337
+ * `entries[].model_id` is the llm-stats id, the same space as `Model.id`).
338
+ * `compared` counts the models present in the map AND carrying the metric;
339
+ * `priceDivergence`/`contextDivergence` are the median relative divergence
340
+ * (`|a − b| / max(a, b, 1e-9)`) over the models compared on that axis, `null`
341
+ * when none. `priceAgreement`/`contextAgreement` are `unknown` when nothing was
342
+ * compared on the axis, else `ok` within `PRICE_AGREEMENT_TOLERANCE`, else
343
+ * `below-bar`. `speedAgreement` is **informational only** and excluded from
344
+ * `status`: the units differ (zeroeval `speed_rps` is requests/s, the plugin's
345
+ * `throughput` is output tok/s) and re-deriving throughput from `speed_rps` is
346
+ * explicitly out of scope. `status` is `unknown` when `compared === 0`, else
347
+ * `below-bar` when `priceAgreement` or `contextAgreement` is `below-bar`, else
348
+ * `ok`. It is an **annotation, never a gate** — `belowBarReason` has no
349
+ * cross-source branch, and the axis never feeds `price`/`weightPrice` or any
350
+ * ranking input. */
351
+ crossSource: {
352
+ compared: number;
353
+ priceAgreement: FocusSignal;
354
+ contextAgreement: FocusSignal;
355
+ speedAgreement: FocusSignal;
356
+ priceDivergence: number | null;
357
+ contextDivergence: number | null;
358
+ status: FocusSignal;
359
+ };
360
+ };
361
+
362
+ /** Per-metric coverage of the ranking field, for the focus-coverage gate. */
363
+ export type FocusCoverage = {
364
+ /** Total models in the ranking universe; null when the load failed. */
365
+ total: number | null;
366
+ /** metric -> models that actually carry it (not the imputed fill). */
367
+ covered: Record<string, number>;
368
+ /** Declared sources, so a declared metric's fill resolves. */
369
+ declared?: readonly SourceDeclaration[];
370
+ /** Nine-axis assessment per metric, when the caller assessed the loaded pool.
371
+ * Absent → the eight new axes are `unknown` (the metric's scores were not
372
+ * loaded), never a silent `ok`. */
373
+ assessments?: Record<string, FocusMetricAssessment>;
374
+ };
375
+
376
+ /** The capability fill a metric's absence is scored at (0 = a coverage penalty). */
377
+ export function metricFill(metric: string, declared: readonly SourceDeclaration[] = []): number {
378
+ return CAPABILITY_FILL[metric] ?? sourceForMetric(metric, declared)?.fill ?? 0;
379
+ }
380
+
381
+ /**
382
+ * The one coverage rule, shared by the discovery gate and the report annotation:
383
+ * a focus metric is safe when its source is capability-filled (`fill > 0`, so
384
+ * absence is a known non-penalty) or its coverage share clears
385
+ * `FOCUS_COVERAGE_FLOOR`. When the field size is unknown the share rule is
386
+ * skipped and the result is `unknown` (annotated, not warned).
387
+ */
388
+ export function focusCoverageOk(
389
+ metric: string,
390
+ covered: number,
391
+ total: number | null,
392
+ declared: readonly SourceDeclaration[] = [],
393
+ ): FocusSignal {
394
+ if (metricFill(metric, declared) > 0) return "ok";
395
+ if (total === null || total <= 0) return "unknown";
396
+ return covered / total >= FOCUS_COVERAGE_FLOOR ? "ok" : "below-bar";
397
+ }
398
+
399
+ /** Count the models a metric actually covers: a non-null value that is not the
400
+ * source's imputed fill. */
401
+ export function countMetricCoverage(models: readonly Model[], metric: string, declared: readonly SourceDeclaration[] = []): number {
402
+ const fill = metricFill(metric, declared);
403
+ let covered = 0;
404
+ for (const model of models) {
405
+ const value = model.metrics[metric];
406
+ if (value == null) continue;
407
+ if (fill > 0 && value === fill) continue;
408
+ covered++;
409
+ }
410
+ return covered;
411
+ }
412
+
413
+ /** The covered models' values for a metric, cardinal-normalized: the engine's
414
+ * transform first (index metrics are raw −16..+60, benchmarks 0–1), so one
415
+ * dispersion floor is comparable across metrics. The imputed fill is skipped. */
416
+ function coveredValues(models: readonly Model[], metric: string, fill: number): number[] {
417
+ const values: number[] = [];
418
+ for (const model of models) {
419
+ const value = model.metrics[metric];
420
+ if (value == null) continue;
421
+ if (fill > 0 && value === fill) continue;
422
+ values.push(cardinalMetric(metric, value));
423
+ }
424
+ return values;
425
+ }
426
+
427
+ /** Linear-interpolated quantile (R type 7) of a value list. */
428
+ function quantile(values: readonly number[], p: number): number {
429
+ const sorted = [...values].sort((a, b) => a - b);
430
+ const h = (sorted.length - 1) * p;
431
+ const lo = Math.floor(h);
432
+ const hi = Math.ceil(h);
433
+ return sorted[lo] + (h - lo) * (sorted[hi] - sorted[lo]);
434
+ }
435
+
436
+ /** The dispersion axis: IQR/median of the cardinal-normalized covered values.
437
+ * Fewer than two values, or a zero median, is `unknown` (no scale to divide by). */
438
+ function dispersionAxis(models: readonly Model[], metric: string, fill: number): FocusMetricAssessment["dispersion"] {
439
+ const values = coveredValues(models, metric, fill);
440
+ if (values.length < 2) return { value: null, status: "unknown" };
441
+ const median = quantile(values, 0.5);
442
+ if (median === 0) return { value: null, status: "unknown" };
443
+ const value = (quantile(values, 0.75) - quantile(values, 0.25)) / median;
444
+ return { value, status: value >= FOCUS_DISPERSION_FLOOR ? "ok" : "below-bar" };
445
+ }
446
+
447
+ /** The composition axis: an org present in the pool above `FOCUS_ORG_MIN_SHARE`
448
+ * but absent from the covered set is flagged. Orgs below the minimum pool share
449
+ * are ignored (a small provider's absence is not a false positive). */
450
+ function compositionAxis(models: readonly Model[], metric: string, fill: number): FocusMetricAssessment["composition"] {
451
+ const poolCounts = new Map<string, number>();
452
+ const coveredCounts = new Map<string, number>();
453
+ for (const model of models) {
454
+ poolCounts.set(model.org, (poolCounts.get(model.org) ?? 0) + 1);
455
+ const value = model.metrics[metric];
456
+ if (value == null) continue;
457
+ if (fill > 0 && value === fill) continue;
458
+ coveredCounts.set(model.org, (coveredCounts.get(model.org) ?? 0) + 1);
459
+ }
460
+ const total = models.length;
461
+ const omittedOrgs: string[] = [];
462
+ for (const [org, count] of poolCounts) {
463
+ if (count / total < FOCUS_ORG_MIN_SHARE) continue;
464
+ if ((coveredCounts.get(org) ?? 0) === 0) omittedOrgs.push(org);
465
+ }
466
+ omittedOrgs.sort();
467
+ return { omittedOrgs, status: omittedOrgs.length === 0 ? "ok" : "below-bar" };
468
+ }
469
+
470
+ /** The freshness axis: the newest covered model's date against the pool's newest.
471
+ * A missing date on either side degrades to `unknown` (a sparse date field must
472
+ * not false-positive). */
473
+ function freshnessAxis(models: readonly Model[], metric: string, fill: number): FocusMetricAssessment["freshness"] {
474
+ let newestPool: string | null = null;
475
+ let newestCovered: string | null = null;
476
+ for (const model of models) {
477
+ const date = model.releaseDate;
478
+ if (date == null) continue;
479
+ if (newestPool === null || date > newestPool) newestPool = date;
480
+ const value = model.metrics[metric];
481
+ if (value == null) continue;
482
+ if (fill > 0 && value === fill) continue;
483
+ if (newestCovered === null || date > newestCovered) newestCovered = date;
484
+ }
485
+ if (newestPool === null || newestCovered === null) {
486
+ return { newestCovered, newestPool, monthsBehind: null, status: "unknown" };
487
+ }
488
+ // 30.4375 = 365.25/12 days per month, so a calendar-month gap reads as ~1.
489
+ const monthsBehind = (Date.parse(newestPool) - Date.parse(newestCovered)) / (1000 * 60 * 60 * 24 * 30.4375);
490
+ return { newestCovered, newestPool, monthsBehind, status: monthsBehind > FOCUS_STALENESS_MONTHS ? "below-bar" : "ok" };
491
+ }
492
+
493
+ /** The trust axis: the self-reported share of the covered entries against
494
+ * `FOCUS_SELF_REPORTED_MAX_SHARE`. A payload with no entry flags (a declared or
495
+ * writing source) or no covered entries is `unknown` — never a silent `ok`. */
496
+ function trustAxis(source?: FocusSourceInfo): FocusMetricAssessment["trust"] {
497
+ const community = source?.catalog?.isCommunity ?? false;
498
+ const trust = source?.payload?.trust ?? null;
499
+ if (trust === null || trust.covered === 0) return { selfReported: 0, covered: 0, status: "unknown", community };
500
+ return {
501
+ selfReported: trust.selfReported,
502
+ covered: trust.covered,
503
+ status: trust.selfReported / trust.covered > FOCUS_SELF_REPORTED_MAX_SHARE ? "below-bar" : "ok",
504
+ community,
505
+ };
506
+ }
507
+
508
+ /** The modality axis: the catalog row's `modality` and the payload's multimodal
509
+ * share. Informational only — there is no role modality field, so this axis is
510
+ * never `below-bar` and never a drop reason; it is `unknown` only when neither
511
+ * the catalog row nor the payload carries a modality signal. */
512
+ function modalityAxis(source?: FocusSourceInfo): FocusMetricAssessment["modality"] {
513
+ const value = source?.catalog?.modality ?? null;
514
+ const summary = source?.payload?.modality ?? null;
515
+ const multimodalShare = summary !== null && summary.covered > 0 ? summary.multimodal / summary.covered : null;
516
+ return { value, multimodalShare, status: value === null && multimodalShare === null ? "unknown" : "ok" };
517
+ }
518
+
519
+ /** The maintenance axis: the catalog row's dataset age against
520
+ * `FOCUS_DATASET_STALENESS_MONTHS`, with `version_count`/`star_count` carried as
521
+ * supporting detail. This is the **dataset-date** axis, distinct from `freshness`
522
+ * (the **model-date** axis). It is an **annotation, never a gate**:
523
+ * `belowBarReason` has no maintenance branch, so a stale dataset is warned but
524
+ * never drops a discovered candidate. `unknown` when the catalog row is absent or
525
+ * its `updated_at` is missing/unparseable. */
526
+ function maintenanceAxis(source?: FocusSourceInfo): FocusMetricAssessment["maintenance"] {
527
+ const catalog = source?.catalog ?? null;
528
+ const updatedAt = catalog?.updatedAt ?? null;
529
+ const versionCount = catalog?.versionCount ?? null;
530
+ const starCount = catalog?.starCount ?? null;
531
+ const parsed = updatedAt === null ? Number.NaN : Date.parse(updatedAt);
532
+ if (Number.isNaN(parsed)) return { updatedAt, monthsOld: null, versionCount, starCount, status: "unknown" };
533
+ // 30.4375 = 365.25/12 days per month, matching the freshness axis.
534
+ const monthsOld = (Date.now() - parsed) / (1000 * 60 * 60 * 24 * 30.4375);
535
+ return { updatedAt, monthsOld, versionCount, starCount, status: monthsOld > FOCUS_DATASET_STALENESS_MONTHS ? "below-bar" : "ok" };
536
+ }
537
+
538
+ /** The provenance axis: the benchmark's owner (the catalog row's `dataset_org_id`,
539
+ * falling back to `dataset_slug`) and the source's own per-entry org mix (the
540
+ * dominant lab and its share). This is the **source's own claim** about who
541
+ * publishes the benchmark and whose scores it carries — an independent
542
+ * cross-check on the pool-derived `composition` axis, not a replacement for it.
543
+ * `compositionAgreement` records whether the two concur: `"disagree"` when
544
+ * exactly one of the two flags `below-bar`, `"agree"` when both or neither do,
545
+ * `"unknown"` when the payload carries no org mix. It is an **annotation, never a
546
+ * gate**: `belowBarReason` has no provenance branch, so a vendor-populated source
547
+ * is warned but never drops a discovered candidate. `unknown` when there is
548
+ * neither an owner nor a payload org mix. */
549
+ function provenanceAxis(source: FocusSourceInfo | undefined, compositionStatus: FocusSignal): FocusMetricAssessment["provenance"] {
550
+ const owner = source?.catalog?.datasetOrgId ?? source?.catalog?.datasetSlug ?? null;
551
+ const mix = source?.payload?.provenance ?? null;
552
+ const dominantOrg = mix?.dominantOrg ?? null;
553
+ const dominantShare = mix?.dominantShare ?? null;
554
+ const status: FocusSignal =
555
+ owner === null && mix === null ? "unknown" : dominantShare !== null && dominantShare > FOCUS_PROVENANCE_DOMINANT_SHARE ? "below-bar" : "ok";
556
+ const compositionAgreement: "agree" | "disagree" | "unknown" =
557
+ mix === null ? "unknown" : (compositionStatus === "below-bar") !== (status === "below-bar") ? "disagree" : "agree";
558
+ return { owner, dominantOrg, dominantShare, compositionAgreement, status };
559
+ }
560
+
561
+ /** The cross-source axis: the zeroeval per-entry price/throughput/context
562
+ * (`BenchmarkPayloadMeta.crossSource`, keyed by the entry's `model_id`) compared
563
+ * against the plugin's own `price`/`throughput`/`context`. The join is **direct
564
+ * on the bare id**: the generic llm-stats benchmark's `entries[].model_id` is the
565
+ * llm-stats id, the same space as `Model.id` (the same assumption
566
+ * `parseLlmStatsBenchmark`'s direct join makes). Only models that are BOTH
567
+ * present in the map AND carry the metric (`m.metrics[metric] != null` and not
568
+ * the fill) count toward `compared`.
569
+ *
570
+ * price compares zeroeval's 3:1 blend `(3·input + output)/4` against `m.price`
571
+ * ($/M); context compares `row.context` against `m.context`; speed compares
572
+ * `row.speed` (requests/s) against `m.throughput` (output tok/s). The relative
573
+ * divergence is `|a − b| / max(a, b, 1e-9)`; `priceDivergence`/
574
+ * `contextDivergence` are the median over the models compared on that axis
575
+ * (`null` when none). An axis is `unknown` when nothing was compared on it, else
576
+ * `ok` within `PRICE_AGREEMENT_TOLERANCE`, else `below-bar`.
577
+ *
578
+ * `speedAgreement` is **informational only** and excluded from `status`: the
579
+ * units differ (rps vs tok/s) and re-deriving throughput from `speed_rps` is
580
+ * explicitly out of scope. `status` is `unknown` when `compared === 0`, else
581
+ * `below-bar` when `priceAgreement` or `contextAgreement` is `below-bar`, else
582
+ * `ok`. It is an **annotation, never a gate** — `belowBarReason` has no
583
+ * cross-source branch, and the axis never feeds `price`/`weightPrice` or any
584
+ * ranking input. (A future option, recorded in the docs only: use zeroeval's
585
+ * list price as a fallback fill for a model with no OpenRouter route.) */
586
+ function crossSourceAxis(models: readonly Model[], metric: string, fill: number, source?: FocusSourceInfo): FocusMetricAssessment["crossSource"] {
587
+ const map = source?.payload?.crossSource ?? null;
588
+ const priceDivergences: number[] = [];
589
+ const contextDivergences: number[] = [];
590
+ const speedDivergences: number[] = [];
591
+ let compared = 0;
592
+ for (const model of models) {
593
+ const value = model.metrics[metric];
594
+ if (value == null) continue;
595
+ if (fill > 0 && value === fill) continue;
596
+ const row = map?.[model.id];
597
+ if (row === undefined) continue;
598
+ compared++;
599
+ if (row.input !== null && row.output !== null && model.price !== null) {
600
+ const zeroPrice = (3 * row.input + row.output) / 4;
601
+ priceDivergences.push(Math.abs(zeroPrice - model.price) / Math.max(zeroPrice, model.price, 1e-9));
602
+ }
603
+ if (row.context !== null && model.context !== null) {
604
+ contextDivergences.push(Math.abs(row.context - model.context) / Math.max(row.context, model.context, 1e-9));
605
+ }
606
+ if (row.speed !== null && model.throughput !== null) {
607
+ speedDivergences.push(Math.abs(row.speed - model.throughput) / Math.max(row.speed, model.throughput, 1e-9));
608
+ }
609
+ }
610
+ const priceDivergence = priceDivergences.length === 0 ? null : quantile(priceDivergences, 0.5);
611
+ const contextDivergence = contextDivergences.length === 0 ? null : quantile(contextDivergences, 0.5);
612
+ const speedDivergence = speedDivergences.length === 0 ? null : quantile(speedDivergences, 0.5);
613
+ const priceAgreement: FocusSignal = priceDivergence === null ? "unknown" : priceDivergence <= PRICE_AGREEMENT_TOLERANCE ? "ok" : "below-bar";
614
+ const contextAgreement: FocusSignal = contextDivergence === null ? "unknown" : contextDivergence <= PRICE_AGREEMENT_TOLERANCE ? "ok" : "below-bar";
615
+ const speedAgreement: FocusSignal = speedDivergence === null ? "unknown" : speedDivergence <= PRICE_AGREEMENT_TOLERANCE ? "ok" : "below-bar";
616
+ const status: FocusSignal = compared === 0 ? "unknown" : priceAgreement === "below-bar" || contextAgreement === "below-bar" ? "below-bar" : "ok";
617
+ return { compared, priceAgreement, contextAgreement, speedAgreement, priceDivergence, contextDivergence, status };
618
+ }
619
+
620
+ /** The all-unknown assessment: the field size is unknown, or the metric's scores
621
+ * were not loaded. Annotated, never warned. */
622
+ function unknownAssessment(coverage: FocusCoverageEntry, source?: FocusSourceInfo): FocusMetricAssessment {
623
+ return {
624
+ coverage,
625
+ dispersion: { value: null, status: "unknown" },
626
+ composition: { omittedOrgs: [], status: "unknown" },
627
+ freshness: { newestCovered: null, newestPool: null, monthsBehind: null, status: "unknown" },
628
+ trust: { selfReported: 0, covered: 0, status: "unknown", community: source?.catalog?.isCommunity ?? false },
629
+ modality: { value: null, multimodalShare: null, status: "unknown" },
630
+ maintenance: { updatedAt: null, monthsOld: null, versionCount: null, starCount: null, status: "unknown" },
631
+ provenance: { owner: null, dominantOrg: null, dominantShare: null, compositionAgreement: "unknown", status: "unknown" },
632
+ crossSource: { compared: 0, priceAgreement: "unknown", contextAgreement: "unknown", speedAgreement: "unknown", priceDivergence: null, contextDivergence: null, status: "unknown" },
633
+ };
634
+ }
635
+
636
+ /**
637
+ * The one assessment rule, shared by the discovery gate, the report and the
638
+ * explorer: coverage, dispersion, provider composition, freshness, source trust,
639
+ * modality, benchmark maintenance, provenance and cross-source agreement of one
640
+ * focus metric over the loaded pool. `focusCoverageOk`/`countMetricCoverage` are
641
+ * its coverage branch; `source` (the cached trust, modality, catalog, provenance
642
+ * and cross-source inputs) is its trust/modality/maintenance/provenance/
643
+ * cross-source branch.
644
+ *
645
+ * An empty pool (unknown field size) or a metric with no covered values (its
646
+ * scores were not loaded) reports every axis `unknown` — never a silent `ok`.
647
+ */
648
+ export function assessFocusMetric(
649
+ models: readonly Model[],
650
+ metric: string,
651
+ declared: readonly SourceDeclaration[] = [],
652
+ source?: FocusSourceInfo,
653
+ ): FocusMetricAssessment {
654
+ const total = models.length;
655
+ const covered = countMetricCoverage(models, metric, declared);
656
+ const fill = metricFill(metric, declared);
657
+ const coverage: FocusCoverageEntry = {
658
+ covered,
659
+ total: total > 0 ? total : null,
660
+ share: total > 0 ? covered / total : null,
661
+ fill,
662
+ status: focusCoverageOk(metric, covered, total > 0 ? total : null, declared),
663
+ };
664
+ if (total === 0 || covered === 0) return unknownAssessment({ ...coverage, status: "unknown" }, source);
665
+ const composition = compositionAxis(models, metric, fill);
666
+ return {
667
+ coverage,
668
+ dispersion: dispersionAxis(models, metric, fill),
669
+ composition,
670
+ freshness: freshnessAxis(models, metric, fill),
671
+ trust: trustAxis(source),
672
+ modality: modalityAxis(source),
673
+ maintenance: maintenanceAxis(source),
674
+ provenance: provenanceAxis(source, composition.status),
675
+ crossSource: crossSourceAxis(models, metric, fill, source),
676
+ };
677
+ }
678
+
679
+ /** The one-line reason a below-bar assessment is dropped/warned, naming the
680
+ * failed axis; null when every axis is ok or unknown. Deliberately has **no
681
+ * maintenance, provenance or cross-source branch**: those axes are annotations,
682
+ * never drop reasons, so a stale dataset, a vendor-populated source or a
683
+ * price/context divergence cannot remove a discovered candidate. */
684
+ export function belowBarReason(assessment: FocusMetricAssessment): string | null {
685
+ const { coverage, dispersion, composition, freshness, trust } = assessment;
686
+ if (coverage.status === "below-bar") {
687
+ return `coverage ${coverage.covered}/${coverage.total} is below the ${Math.round(FOCUS_COVERAGE_FLOOR * 100)}% bar`;
688
+ }
689
+ if (dispersion.status === "below-bar") {
690
+ return `dispersion ${dispersion.value?.toFixed(3)} below the ${FOCUS_DISPERSION_FLOOR} bar`;
691
+ }
692
+ if (composition.status === "below-bar") {
693
+ return `composition omits ${composition.omittedOrgs.join(", ")}`;
694
+ }
695
+ if (freshness.status === "below-bar") {
696
+ return `freshness ${freshness.monthsBehind?.toFixed(1)} months behind the pool`;
697
+ }
698
+ if (trust.status === "below-bar") {
699
+ return `trust ${trust.selfReported}/${trust.covered} self-reported is above the ${FOCUS_SELF_REPORTED_MAX_SHARE} bar`;
700
+ }
701
+ return null;
702
+ }
703
+
197
704
  /**
198
705
  * Fold user-named benchmarks into a role's weights as a decisive *focus* share.
199
706
  *
@@ -211,8 +718,19 @@ const FOCUS_SHARE_CAP = 0.4;
211
718
  * known-metric set (a shipped key or an external `<ns>:<local>`) is unknown.
212
719
  * Naming the archetype's own specialist set is a no-op (the focus share equals
213
720
  * its archetype share). Nothing changes when the list is empty.
721
+ *
722
+ * The focus set is capped at `FOCUS_METRIC_CAP` in priority order (named/linked
723
+ * first, then discovery order), so the caller's explicit choices survive and one
724
+ * benchmark keeps a decisive share; the metrics beyond the cap are reported as
725
+ * `dropped`. `coverage.assessments` (the nine-axis assessment the caller computed
726
+ * over the loaded pool) drives the per-metric annotation; without it the eight
727
+ * new axes are `unknown` (the metric's scores were not loaded).
214
728
  */
215
- export function applyFocusBenchmarks(weights: Record<string, number>, metrics: readonly string[]): BenchmarkApplication {
729
+ export function applyFocusBenchmarks(
730
+ weights: Record<string, number>,
731
+ metrics: readonly string[],
732
+ coverage?: FocusCoverage,
733
+ ): BenchmarkApplication {
216
734
  const added: string[] = [];
217
735
  const duplicates: string[] = [];
218
736
  const unknown: string[] = [];
@@ -225,22 +743,45 @@ export function applyFocusBenchmarks(weights: Record<string, number>, metrics: r
225
743
  continue;
226
744
  }
227
745
  if (focus.includes(metric)) continue; // the same metric named twice
746
+ focus.push(metric);
747
+ }
748
+ const kept = focus.slice(0, FOCUS_METRIC_CAP);
749
+ const dropped = focus.slice(FOCUS_METRIC_CAP);
750
+ for (const metric of kept) {
228
751
  if (metric in weights) duplicates.push(metric);
229
752
  else added.push(metric);
230
- focus.push(metric);
231
753
  }
232
- if (focus.length === 0) return { weights: { ...weights }, added, duplicates, unknown };
754
+
755
+ const declared = coverage?.declared ?? [];
756
+ const total = coverage?.total ?? null;
757
+ const assessments: Record<string, FocusMetricAssessment> = {};
758
+ for (const metric of kept) {
759
+ const assessed = coverage?.assessments?.[metric];
760
+ if (assessed !== undefined) {
761
+ assessments[metric] = assessed;
762
+ continue;
763
+ }
764
+ const covered = coverage?.covered[metric] ?? 0;
765
+ assessments[metric] = unknownAssessment({
766
+ covered,
767
+ total,
768
+ share: total !== null && total > 0 ? covered / total : null,
769
+ fill: metricFill(metric, declared),
770
+ status: focusCoverageOk(metric, covered, total, declared),
771
+ });
772
+ }
773
+ if (kept.length === 0) return { weights: { ...weights }, added, duplicates, unknown, dropped, assessments };
233
774
 
234
775
  const price = weights.price ?? 0;
235
776
  const specialistShare = Object.keys(weights)
236
777
  .filter((key) => key !== "price" && !BACKBONE_METRICS[key])
237
778
  .reduce((sum, key) => sum + weights[key], 0);
238
779
  const focusShare = Math.min(FOCUS_SHARE_CAP, Math.max(FOCUS_SHARE_FLOOR, specialistShare));
239
- const perFocus = focusShare / focus.length;
780
+ const perFocus = focusShare / kept.length;
240
781
 
241
782
  const next: Record<string, number> = { ...weights };
242
- for (const metric of focus) next[metric] = perFocus;
243
- const rest = Object.keys(next).filter((key) => key !== "price" && !focus.includes(key));
783
+ for (const metric of kept) next[metric] = perFocus;
784
+ const rest = Object.keys(next).filter((key) => key !== "price" && !kept.includes(key));
244
785
  const restSum = rest.reduce((sum, key) => sum + next[key], 0);
245
786
  const target = 1 - price - focusShare;
246
787
  if (restSum > 0) for (const key of rest) next[key] = (next[key] * target) / restSum;
@@ -256,11 +797,27 @@ export function applyFocusBenchmarks(weights: Record<string, number>, metrics: r
256
797
  const largest = keys.reduce((a, b) => (next[a] >= next[b] ? a : b));
257
798
  next[largest] = Math.round((next[largest] + residual) * 1e4) / 1e4;
258
799
  }
259
- return { weights: next, added, duplicates, unknown };
800
+ return { weights: next, added, duplicates, unknown, dropped, assessments };
260
801
  }
261
802
 
262
- /** A benchmark discovered for a purpose: the metric it feeds and its label. */
263
- export type DiscoveredBenchmark = { metric: string; label: string };
803
+ /** A benchmark discovered for a purpose: the metric it feeds, its label, the
804
+ * catalog's model count (the pre-fetch coverage numerator for the report
805
+ * annotation), and — for an id whose raw form the metric key cannot reconstruct
806
+ * (a dotted id) — the declaration the caller must persist so the updater can
807
+ * re-fetch it. */
808
+ export type DiscoveredBenchmark = { metric: string; label: string; covered: number; declaration?: SourceDeclaration };
809
+
810
+ /** A candidate the discovery gate dropped, with the reason (for the report). */
811
+ export type DroppedBenchmark = { metric: string; label: string; reason: string };
812
+
813
+ /** The discovery outcome: the benchmarks to fold in, and the ones the coverage
814
+ * gate dropped (non-fatal, reported). */
815
+ export type DiscoveryOutcome = { discovered: DiscoveredBenchmark[]; dropped: DroppedBenchmark[] };
816
+
817
+ /** The post-fetch assessor the discovery gate injects: probe-fetch a candidate's
818
+ * source, join it onto the loaded pool and assess it. The extension supplies the
819
+ * real one (via `loadBenchmarkScores`); tests inject a stub. */
820
+ export type FocusAssessor = (metric: string) => Promise<FocusMetricAssessment>;
264
821
 
265
822
  /** Catalog benchmarks with fewer models than this are skipped (a 1-model
266
823
  * benchmark would distort the ranking). */
@@ -329,28 +886,59 @@ function rankCatalogCandidates(purpose: string, candidates: readonly BenchmarkCa
329
886
  * capped (the catalog is alphabetical, so a blind cap would drop late-sorting
330
887
  * benchmarks), a selected benchmark resolves to its shipped metric when one
331
888
  * exists (else `bench:<id>`), and a metric already in `exclude` is skipped.
889
+ *
890
+ * `fieldSize` is the ranking universe's model count (null when unknown). A
891
+ * selected benchmark whose metric is fill-0 and whose catalog coverage is below
892
+ * `FOCUS_COVERAGE_FLOOR` of the field is dropped (non-fatal) with a reason, so a
893
+ * sparse pass-rate benchmark cannot turn `q` into a coverage score. When the
894
+ * field size is unknown the share rule is skipped and only the fill rule applies.
895
+ *
896
+ * `assess` is the post-fetch gate: the create-agent pool carries no `bench:<id>`
897
+ * scores, so the caller probe-fetches each survivor's source and assesses the
898
+ * joined pool. A survivor below-bar on any axis is dropped with a reason naming
899
+ * the failed axis. Without `assess` only the pre-fetch count gate runs (the
900
+ * catalog has no scores to assess).
332
901
  */
333
902
  export async function discoverBenchmarks(
334
903
  purpose: string,
335
904
  catalog: readonly BenchmarkCatalogEntry[],
336
905
  decide: (purpose: string, candidates: readonly BenchmarkCatalogEntry[]) => Promise<readonly string[]>,
337
906
  exclude: readonly string[] = [],
338
- ): Promise<DiscoveredBenchmark[]> {
907
+ fieldSize: number | null = null,
908
+ assess?: FocusAssessor,
909
+ ): Promise<DiscoveryOutcome> {
339
910
  const candidates = catalog.filter((entry) => entry.modelCount >= MIN_CATALOG_MODELS);
340
- if (candidates.length === 0) return [];
911
+ if (candidates.length === 0) return { discovered: [], dropped: [] };
341
912
  const ranked = rankCatalogCandidates(purpose, candidates);
342
- if (ranked.length === 0) return [];
913
+ if (ranked.length === 0) return { discovered: [], dropped: [] };
343
914
  const selected = new Set(await decide(purpose, ranked));
344
915
  const seen = new Set(exclude);
345
- const out: DiscoveredBenchmark[] = [];
916
+ const discovered: DiscoveredBenchmark[] = [];
917
+ const dropped: DroppedBenchmark[] = [];
346
918
  for (const entry of ranked) {
347
919
  if (!selected.has(entry.id)) continue;
348
920
  const metric = catalogMetric(entry.id);
349
921
  if (seen.has(metric)) continue;
350
922
  seen.add(metric);
351
- out.push({ metric, label: entry.name });
923
+ if (focusCoverageOk(metric, entry.modelCount, fieldSize) === "below-bar") {
924
+ dropped.push({
925
+ metric,
926
+ label: entry.name,
927
+ reason: `coverage ${entry.modelCount}/${fieldSize} is below the ${Math.round(FOCUS_COVERAGE_FLOOR * 100)}% bar`,
928
+ });
929
+ continue;
930
+ }
931
+ if (assess !== undefined) {
932
+ const reason = belowBarReason(await assess(metric));
933
+ if (reason !== null) {
934
+ dropped.push({ metric, label: entry.name, reason });
935
+ continue;
936
+ }
937
+ }
938
+ const declaration = catalogBenchmarkDeclaration(entry.id);
939
+ discovered.push({ metric, label: entry.name, covered: entry.modelCount, ...(declaration !== null ? { declaration } : {}) });
352
940
  }
353
- return out;
941
+ return { discovered, dropped };
354
942
  }
355
943
 
356
944
  /**
@@ -372,8 +960,12 @@ export function formatBenchmarks(weights?: Record<string, number>, extra?: reado
372
960
  /** Shared empty match list for an explicitly chosen archetype (no keyword hits). */
373
961
  const EMPTY_MATCHED: string[] = [];
374
962
 
375
- /** Resolve the archetype, then layer the caller's explicit overrides on top. */
376
- function resolveRole(request: CreateAgentRequest): { archetype: Archetype; matched: string[]; def: RoleDef } | { errors: string[] } {
963
+ /**
964
+ * Resolve the archetype, then layer the caller's explicit overrides on top.
965
+ * Exported so the extension can preview the request's weights (for the live
966
+ * "already in this role's weights" marker) without writing anything.
967
+ */
968
+ export function resolveRole(request: CreateAgentRequest): { archetype: Archetype; matched: string[]; def: RoleDef } | { errors: string[] } {
377
969
  const match = request.archetypeId === undefined ? fitArchetype(stripBenchmarkLinks(request.purpose)) : { archetype: archetypeById(request.archetypeId), matched: EMPTY_MATCHED };
378
970
  if (match.archetype === null) {
379
971
  return { errors: [`unknown archetype "${request.archetypeId}" — one of ${ARCHETYPES.map((a) => a.id).join(", ")}`] };
@@ -385,12 +977,27 @@ function resolveRole(request: CreateAgentRequest): { archetype: Archetype; match
385
977
  const thinking = request.thinking ?? archetype.thinking;
386
978
  if (thinking !== undefined) def.thinking = thinking;
387
979
  if (request.image === true || (request.image === undefined && archetype.image === true)) def.filters = { image: true };
980
+ // The request's flags win; otherwise the archetype's own list (no shipped
981
+ // archetype sets one, so a plugin-created role starts from nothing unless the
982
+ // user asks). The flag is authored, not expanded — `resolveSettings` expands it.
983
+ let features: Record<string, boolean> | undefined = request.features;
984
+ if (features === undefined && archetype.features !== undefined && archetype.features.length > 0) {
985
+ features = {};
986
+ for (const id of archetype.features) features[id] = true;
987
+ }
988
+ if (features !== undefined) def.features = features;
388
989
  return { archetype, matched: match.matched, def };
389
990
  }
390
991
 
391
992
  /**
392
993
  * Create the role and the agent file for `request`. Nothing is written when any
393
994
  * check fails; `dryRun` runs every check and stops before the writes.
995
+ *
996
+ * The agent file is written **before** the role: an agent with no role is
997
+ * harmless (its `@<name>, @default` chain falls back to `@default`), while a
998
+ * role with no agent is a ranked-but-dead entry the updater would still wire. If
999
+ * the role write fails after the agent file landed, the agent file is removed
1000
+ * (rollback), so a retry needs no `--force`.
394
1001
  */
395
1002
  export function createAgent(request: CreateAgentRequest): CreateAgentResult {
396
1003
  const errors: string[] = [];
@@ -404,7 +1011,7 @@ export function createAgent(request: CreateAgentRequest): CreateAgentResult {
404
1011
 
405
1012
  const resolved = resolveRole(request);
406
1013
  if ("errors" in resolved) return { ok: false, errors: [...errors, ...resolved.errors] };
407
- const bench = applyFocusBenchmarks(resolved.def.weights, request.extraBenchmarks ?? []);
1014
+ const bench = applyFocusBenchmarks(resolved.def.weights, request.extraBenchmarks ?? [], request.coverage);
408
1015
  resolved.def.weights = bench.weights;
409
1016
  errors.push(...checkWeightMath(name, resolved.def.weights, resolved.def.required));
410
1017
 
@@ -413,8 +1020,8 @@ export function createAgent(request: CreateAgentRequest): CreateAgentResult {
413
1020
  const agentsDir = request.scope === "project" ? projectAgentsDir(request.projectAnchor ?? process.cwd()) : userAgentsDir();
414
1021
  const agentPath = join(agentsDir, `${name}.md`);
415
1022
  const model = `@${name}, @default`;
416
- // Pre-flight: the role write must not land when the agent file is going to be
417
- // refused, or a failed run leaves a role with no agent behind it.
1023
+ // Pre-flight: the agent file must not be written when the role is going to be
1024
+ // refused, or a failed run leaves an agent behind for a role that never lands.
418
1025
  if (existsSync(agentPath) && !request.force) errors.push(`${agentPath} already exists — pass --force to overwrite`);
419
1026
  if (errors.length > 0) return { ok: false, errors };
420
1027
 
@@ -425,23 +1032,68 @@ export function createAgent(request: CreateAgentRequest): CreateAgentResult {
425
1032
  return { ok: true, name, archetype: resolved.archetype, matched: resolved.matched, def: resolved.def, agentPath, model, readOnly, backupPath: null, dryRun: true, bench };
426
1033
  }
427
1034
 
428
- const written = writeRoleSettings(request.lockPath, { [name]: resolved.def });
429
- if (!written.ok) return { ok: false, errors: written.errors };
430
-
431
1035
  // omp's architect supplies the routing rule and the body; the plugin adds the
432
1036
  // `model:`/`tools:` frontmatter omp's own writer omits. Without a spec (the
433
1037
  // CLI, or a caller that skipped the architect) the archetype template is used.
434
1038
  const description = request.spec?.whenToUse ?? routingDescription(request.purpose);
435
1039
  const body = request.spec?.systemPrompt ?? buildBody(request, resolved.archetype, readOnly);
436
1040
  const text = renderAgentFile({ name, description, model, tools, body });
1041
+ // Capture the prior text (only reachable with `--force`) so a failed role write
1042
+ // restores it instead of destroying the user's file and leaving the old role
1043
+ // dangling.
1044
+ const priorAgent = existsSync(agentPath) ? readFileSync(agentPath, "utf8") : null;
437
1045
  const agent = writeAgentFile(agentPath, text, request.force);
438
- if (!agent.ok) {
439
- return { ok: false, errors: [`roles.${name} was written, but the agent file was not: ${agent.error}`] };
1046
+ if (!agent.ok) return { ok: false, errors: [agent.error] };
1047
+
1048
+ // The real role write can return `{ ok: false }` (validation/lock/merge/mtime)
1049
+ // or throw (a filesystem error in the atomic writer), so both paths roll the
1050
+ // agent file back — a retry then needs no `--force`.
1051
+ let written: RoleWriteResult;
1052
+ try {
1053
+ written = writeRoleSettings(request.lockPath, { [name]: resolved.def });
1054
+ } catch (err) {
1055
+ return { ok: false, errors: [rollbackAgent(agentPath, priorAgent, err instanceof Error ? err.message : String(err))] };
440
1056
  }
1057
+ if (!written.ok) return { ok: false, errors: [rollbackAgent(agentPath, priorAgent, written.errors.join("; "))] };
441
1058
 
442
1059
  return { ok: true, name, archetype: resolved.archetype, matched: resolved.matched, def: resolved.def, agentPath, model, readOnly, backupPath: written.backupPath, dryRun: false, bench };
443
1060
  }
444
1061
 
1062
+ /**
1063
+ * Undo the agent write after a failed role write: restore the prior file when one
1064
+ * existed (a `--force` re-create), else remove the new one. Blind removal under
1065
+ * `--force` would destroy the user's agent and leave the old role dangling.
1066
+ */
1067
+ function rollbackAgent(agentPath: string, priorAgent: string | null, roleError: string): string {
1068
+ if (priorAgent === null) {
1069
+ const removed = removeAgentFile(agentPath);
1070
+ const note = removed.ok ? "the agent file was removed" : `removing the agent file failed: ${removed.error}`;
1071
+ return `the agent file ${agentPath} was written, but the role was not: ${roleError} (${note})`;
1072
+ }
1073
+ const restored = writeAgentFile(agentPath, priorAgent, true);
1074
+ const note = restored.ok ? "the prior agent file was restored" : `restoring the prior agent file failed: ${restored.error}`;
1075
+ return `the agent file ${agentPath} was written, but the role was not: ${roleError} (${note})`;
1076
+ }
1077
+
1078
+ /**
1079
+ * A warning when the new role's top pick equals the `default` role's top pick —
1080
+ * the role adds nothing over the default. `null` when they differ or the pool is
1081
+ * empty (nothing to compare).
1082
+ */
1083
+ export function differentiationWarning(
1084
+ newDef: RoleDef,
1085
+ defaultDef: RoleDef,
1086
+ models: readonly Model[],
1087
+ ): string | null {
1088
+ if (models.length === 0) return null;
1089
+ const pool = models as Model[];
1090
+ const next = rankRole(newDef, pool);
1091
+ const base = rankRole(defaultDef, pool);
1092
+ if (next.length === 0 || base.length === 0) return null;
1093
+ if (next[0].model.id !== base[0].model.id) return null;
1094
+ return `the new role's leader (${next[0].model.id}) is also the default role's leader — the role adds nothing; raise its distinctive metric or drop it`;
1095
+ }
1096
+
445
1097
  /** The ok branch of a create run, for the report formatters. */
446
1098
  export type CreatedAgent = Extract<CreateAgentResult, { ok: true }>;
447
1099
 
@@ -468,8 +1120,11 @@ export const CREATE_AGENT_USAGE = [
468
1120
  " --thinking <level> off|minimal|low|medium|high|xhigh|max|auto",
469
1121
  " --tools <a,b,...> builtin tool allowlist (default: the archetype's)",
470
1122
  " --benchmarks <m,...> extra benchmarks to fold into the weights (see --list-benchmarks)",
1123
+ " --feature <id,...> capability flag(s) to author on the role (see --list-features);",
1124
+ " repeatable, comma-separated, merged",
471
1125
  " --no-discover skip benchmark discovery (rank only on named benchmarks)",
472
1126
  " --list-benchmarks print the weightable benchmarks and exit",
1127
+ " --list-features print the capability flags and the settings each applies, then exit",
473
1128
  " --scope <user|project> where the agent file goes (default: user)",
474
1129
  " --image require image input (filters.image)",
475
1130
  " --body <text> override the generated agent body",
@@ -533,13 +1188,13 @@ function parseList(spec: string): string[] {
533
1188
  }
534
1189
 
535
1190
  export type ParsedCreateAgentArgs =
536
- | { ok: true; request: CreateAgentRequest; json: boolean; bodyFile: string | undefined; listArchetypes: boolean; listBenchmarks: boolean; help: boolean; freeText: boolean; yes: boolean; noDiscover: boolean; explicitBenchmarks: boolean }
1191
+ | { ok: true; request: CreateAgentRequest; json: boolean; bodyFile: string | undefined; listArchetypes: boolean; listBenchmarks: boolean; listFeatures: boolean; help: boolean; freeText: boolean; yes: boolean; noDiscover: boolean; explicitBenchmarks: boolean }
537
1192
  | { ok: false; error: string };
538
1193
 
539
1194
  /** The flag half of the parse, without the required-name/purpose check (the
540
1195
  * free-text form supplies those itself). */
541
1196
  type ParsedFlags =
542
- | { ok: true; request: CreateAgentRequest; json: boolean; bodyFile: string | undefined; listArchetypes: boolean; listBenchmarks: boolean; help: boolean; yes: boolean; noDiscover: boolean; explicitBenchmarks: boolean }
1197
+ | { ok: true; request: CreateAgentRequest; json: boolean; bodyFile: string | undefined; listArchetypes: boolean; listBenchmarks: boolean; listFeatures: boolean; help: boolean; yes: boolean; noDiscover: boolean; explicitBenchmarks: boolean }
543
1198
  | { ok: false; error: string };
544
1199
 
545
1200
  function parseFlags(argv: string[]): ParsedFlags {
@@ -555,6 +1210,7 @@ function parseFlags(argv: string[]): ParsedFlags {
555
1210
  let bodyFile: string | undefined;
556
1211
  let listArchetypes = false;
557
1212
  let listBenchmarks = false;
1213
+ let listFeatures = false;
558
1214
  let help = false;
559
1215
  let yes = false;
560
1216
  let noDiscover = false;
@@ -574,6 +1230,10 @@ function parseFlags(argv: string[]): ParsedFlags {
574
1230
  listBenchmarks = true;
575
1231
  continue;
576
1232
  }
1233
+ if (flag === "--list-features") {
1234
+ listFeatures = true;
1235
+ continue;
1236
+ }
577
1237
  if (flag === "--force") {
578
1238
  request.force = true;
579
1239
  continue;
@@ -613,6 +1273,17 @@ function parseFlags(argv: string[]): ParsedFlags {
613
1273
  else if (flag === "--benchmarks") {
614
1274
  explicitBenchmarks = true;
615
1275
  request.extraBenchmarks = parseList(value);
1276
+ } else if (flag === "--feature") {
1277
+ const ids = parseList(value);
1278
+ if (ids.length === 0) return { ok: false, error: `--feature: no capability id given\n\n${CREATE_AGENT_USAGE}` };
1279
+ const features = request.features ?? {};
1280
+ for (const id of ids) {
1281
+ if (featureById(id) === null) {
1282
+ return { ok: false, error: `--feature: unknown capability "${id}" — one of ${FEATURES.map((f) => f.id).join(", ")}\n\n${CREATE_AGENT_USAGE}` };
1283
+ }
1284
+ features[id] = true;
1285
+ }
1286
+ request.features = features;
616
1287
  } else if (flag === "--scope") {
617
1288
  if (value !== "user" && value !== "project") return { ok: false, error: `--scope must be user or project, got "${value}"` };
618
1289
  request.scope = value;
@@ -622,30 +1293,35 @@ function parseFlags(argv: string[]): ParsedFlags {
622
1293
  else return { ok: false, error: `unknown flag "${flag}"\n\n${CREATE_AGENT_USAGE}` };
623
1294
  }
624
1295
 
625
- return { ok: true, request, json, bodyFile, listArchetypes, listBenchmarks, help, yes, noDiscover, explicitBenchmarks };
1296
+ return { ok: true, request, json, bodyFile, listArchetypes, listBenchmarks, listFeatures, help, yes, noDiscover, explicitBenchmarks };
626
1297
  }
627
1298
 
628
1299
  /** Parse the flag form of `/create-agent` into a request. */
629
1300
  export function parseCreateAgentArgs(argv: string[]): ParsedCreateAgentArgs {
630
1301
  const parsed = parseFlags(argv);
631
1302
  if (!parsed.ok) return parsed;
632
- if (!parsed.help && !parsed.listArchetypes && !parsed.listBenchmarks) {
1303
+ if (!parsed.help && !parsed.listArchetypes && !parsed.listBenchmarks && !parsed.listFeatures) {
633
1304
  if (parsed.request.name === "") return { ok: false, error: `--name is required\n\n${CREATE_AGENT_USAGE}` };
634
1305
  if (parsed.request.purpose === "") return { ok: false, error: `--purpose is required\n\n${CREATE_AGENT_USAGE}` };
635
1306
  }
636
1307
  return { ...parsed, freeText: false };
637
1308
  }
638
1309
 
1310
+ /** Metrics every archetype already weights, so naming one in prose adds nothing
1311
+ * and must not be read as a benchmark. `reasoning` is deliberately absent: not
1312
+ * every archetype weights it, so it stays extractable. */
1313
+ const ALWAYS_WEIGHTED_METRICS: Record<string, true> = { general: true, price: true, throughput: true };
1314
+
639
1315
  /**
640
1316
  * Metric names a free-text request names explicitly. Whole-word, case-insensitive,
641
1317
  * with `_`/`-`/space interchangeable inside a name (`long_context` ↔ "long context").
642
- * `price`/`throughput` are excluded: every archetype already weights them, so
643
- * naming them adds nothing.
1318
+ * The always-weighted backbone (`general`/`price`/`throughput`) is excluded: every
1319
+ * archetype already weights them, so ordinary prose naming one adds nothing.
644
1320
  */
645
1321
  export function extractBenchmarks(text: string): string[] {
646
1322
  const found: string[] = [];
647
1323
  for (const metric of Object.keys(KNOWN_METRICS)) {
648
- if (metric === "price" || metric === "throughput") continue;
1324
+ if (metric in ALWAYS_WEIGHTED_METRICS) continue;
649
1325
  const pattern = metric.replace(/_/g, "[ _-]?");
650
1326
  if (new RegExp(`\\b${pattern}\\b`, "i").test(text)) found.push(metric);
651
1327
  }
@@ -689,7 +1365,7 @@ export function parseCreateAgentInput(raw: string): ParsedCreateAgentArgs {
689
1365
  if (purpose === "") return { ok: false, error: `nothing to create\n\n${CREATE_AGENT_USAGE}` };
690
1366
  const parsed = parseFlags(firstFlag === -1 ? [] : tokens.slice(firstFlag));
691
1367
  if (!parsed.ok) return parsed;
692
- if (parsed.help || parsed.listArchetypes || parsed.listBenchmarks) return { ...parsed, freeText: false };
1368
+ if (parsed.help || parsed.listArchetypes || parsed.listBenchmarks || parsed.listFeatures) return { ...parsed, freeText: false };
693
1369
 
694
1370
  parsed.request.purpose = purpose;
695
1371
  if (parsed.request.extraBenchmarks === undefined) {
@@ -710,10 +1386,48 @@ export function formatArchetypes(): string {
710
1386
  }).join("\n\n");
711
1387
  }
712
1388
 
1389
+ /** One focus-metric line: coverage plus the eight new signals. */
1390
+ function assessmentLine(metric: string, a: FocusMetricAssessment): string {
1391
+ const c = a.coverage;
1392
+ const head =
1393
+ c.status === "unknown"
1394
+ ? `${metric} unknown (field size unavailable)`
1395
+ : `${metric} ${c.covered}/${c.total} (${c.share === null ? "?" : `${(c.share * 100).toFixed(1)}%`}) ${c.status}`;
1396
+ const dispersion = a.dispersion.status === "unknown" ? "unknown" : `${a.dispersion.value?.toFixed(3)} ${a.dispersion.status}`;
1397
+ const composition =
1398
+ a.composition.status === "unknown" ? "unknown" : a.composition.status === "ok" ? "ok" : `omits ${a.composition.omittedOrgs.join(", ")}`;
1399
+ const freshness = a.freshness.status === "unknown" ? "unknown" : `${a.freshness.monthsBehind?.toFixed(1)}mo ${a.freshness.status}`;
1400
+ const trust =
1401
+ a.trust.status === "unknown"
1402
+ ? a.trust.community
1403
+ ? "community"
1404
+ : "unknown"
1405
+ : `${a.trust.selfReported}/${a.trust.covered} ${a.trust.status}${a.trust.community ? " community" : ""}`;
1406
+ const modality =
1407
+ a.modality.status === "unknown"
1408
+ ? "unknown"
1409
+ : `${a.modality.value ?? "?"}${a.modality.multimodalShare === null ? "" : ` ${(a.modality.multimodalShare * 100).toFixed(0)}% multimodal`}`;
1410
+ const maintenance = a.maintenance.status === "unknown" ? "unknown" : `${a.maintenance.monthsOld?.toFixed(1)}mo ${a.maintenance.status}`;
1411
+ const provenance =
1412
+ a.provenance.status === "unknown"
1413
+ ? "unknown"
1414
+ : `${a.provenance.owner ?? "?"}${a.provenance.dominantShare === null ? "" : ` ${(a.provenance.dominantShare * 100).toFixed(0)}% ${a.provenance.dominantOrg ?? "?"}`} ${a.provenance.compositionAgreement}`;
1415
+ const crossSource =
1416
+ a.crossSource.status === "unknown"
1417
+ ? "unknown"
1418
+ : `${a.crossSource.compared} priceΔ${a.crossSource.priceDivergence === null ? "?" : `${(a.crossSource.priceDivergence * 100).toFixed(0)}%`} ctxΔ${a.crossSource.contextDivergence === null ? "?" : `${(a.crossSource.contextDivergence * 100).toFixed(0)}%`} ${a.crossSource.status}`;
1419
+ return `${head} dispersion ${dispersion} composition ${composition} freshness ${freshness} trust ${trust} modality ${modality} maintenance ${maintenance} provenance ${provenance} cross-source ${crossSource}`;
1420
+ }
1421
+
713
1422
  /** The human-readable report for a completed (or dry-run) create. */
714
- export function formatCreateAgentReport(result: CreatedAgent, updaterHint: string): string {
1423
+ export function formatCreateAgentReport(
1424
+ result: CreatedAgent,
1425
+ updaterHint: string,
1426
+ opts: { differentiation?: string | null } = {},
1427
+ ): string {
715
1428
  const weights = Object.entries(result.def.weights).map(([metric, weight]) => `${metric}=${weight}`).join(",");
716
1429
  const fit = result.matched.length > 0 ? `matched ${result.matched.join(", ")}` : "fallback (no purpose keyword matched)";
1430
+ const featureEntries = Object.entries(result.def.features ?? {});
717
1431
  const lines = [
718
1432
  `create-agent: ${result.dryRun ? "(dry run) " : ""}${result.name}`,
719
1433
  ` archetype: ${result.archetype.id} (${result.archetype.label}) — ${fit}`,
@@ -722,9 +1436,55 @@ export function formatCreateAgentReport(result: CreatedAgent, updaterHint: strin
722
1436
  ` agent: ${result.agentPath}${result.readOnly ? " (read-only)" : ""}`,
723
1437
  ` model: ${result.model}`,
724
1438
  ];
1439
+ if (featureEntries.length > 0) lines.push(` features: ${featureEntries.map(([id, on]) => `${id}=${on}`).join(",")}`);
725
1440
  if (result.bench.added.length > 0) lines.push(` benchmarks: added ${result.bench.added.join(", ")}`);
726
1441
  if (result.bench.duplicates.length > 0) lines.push(` benchmarks: already weighted (skipped) ${result.bench.duplicates.join(", ")}`);
727
1442
  if (result.bench.unknown.length > 0) lines.push(` benchmarks: unknown (skipped) ${result.bench.unknown.join(", ")}`);
1443
+ if (result.bench.dropped.length > 0) lines.push(` benchmarks: dropped (focus cap ${FOCUS_METRIC_CAP}) ${result.bench.dropped.join(", ")}`);
1444
+ for (const [metric, a] of Object.entries(result.bench.assessments)) lines.push(` coverage: ${assessmentLine(metric, a)}`);
1445
+ for (const [metric, a] of Object.entries(result.bench.assessments)) {
1446
+ if (a.coverage.status === "below-bar") {
1447
+ lines.push(
1448
+ ` warning: ${metric} coverage ${a.coverage.covered}/${a.coverage.total} is below the ${Math.round(FOCUS_COVERAGE_FLOOR * 100)}% bar — the role may rank on coverage, not quality`,
1449
+ );
1450
+ }
1451
+ if (a.dispersion.status === "below-bar") {
1452
+ lines.push(` warning: ${metric} dispersion ${a.dispersion.value?.toFixed(3)} is below the ${FOCUS_DISPERSION_FLOOR} bar — the metric cannot separate models`);
1453
+ }
1454
+ if (a.composition.status === "below-bar") {
1455
+ lines.push(` warning: ${metric} omits ${a.composition.omittedOrgs.join(", ")} — the source is provider-skewed`);
1456
+ }
1457
+ if (a.freshness.status === "below-bar") {
1458
+ lines.push(` warning: ${metric} trails the pool by ${a.freshness.monthsBehind?.toFixed(1)} months — the source is stale`);
1459
+ }
1460
+ if (a.trust.status === "below-bar") {
1461
+ lines.push(
1462
+ ` warning: ${metric} trust ${a.trust.selfReported}/${a.trust.covered} self-reported is above the ${FOCUS_SELF_REPORTED_MAX_SHARE} bar — the source is mostly vendor-submitted`,
1463
+ );
1464
+ }
1465
+ if (a.maintenance.status === "below-bar") {
1466
+ lines.push(
1467
+ ` warning: ${metric} dataset last updated ${a.maintenance.monthsOld?.toFixed(1)} months ago — the benchmark may be unmaintained`,
1468
+ );
1469
+ }
1470
+ if (a.provenance.status === "below-bar") {
1471
+ const share = a.provenance.dominantShare === null ? "?" : `${(a.provenance.dominantShare * 100).toFixed(0)}%`;
1472
+ lines.push(
1473
+ ` warning: ${metric} provenance ${a.provenance.dominantOrg ?? "?"} dominates ${share} of the source's entries — the benchmark is vendor-populated`,
1474
+ );
1475
+ }
1476
+ if (a.provenance.compositionAgreement === "disagree") {
1477
+ lines.push(
1478
+ ` warning: ${metric} provenance disagrees with the composition axis — the source's own org mix and the pool-derived check do not concur`,
1479
+ );
1480
+ }
1481
+ if (a.crossSource.status === "below-bar") {
1482
+ lines.push(
1483
+ ` warning: ${metric} cross-source price/context diverges from the source's own per-entry values — the price basis may be stale or mis-joined`,
1484
+ );
1485
+ }
1486
+ }
1487
+ if (opts.differentiation) lines.push(` warning: ${opts.differentiation}`);
728
1488
  if (result.backupPath !== null) lines.push(` backup: ${result.backupPath}`);
729
1489
  if (result.dryRun) lines.push(" nothing written (--dry-run)");
730
1490
  else lines.push(` next: ${updaterHint}`);