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
package/src/extension.ts CHANGED
@@ -10,20 +10,26 @@
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
13
- import { execFile } from "node:child_process";
14
- import { readFileSync } from "node:fs";
13
+ import { execFile, execFileSync } from "node:child_process";
14
+ import { readFileSync, readdirSync } from "node:fs";
15
+ import { join } from "node:path";
15
16
  import { fileURLToPath } from "node:url";
16
- import { createAgent, CREATE_AGENT_USAGE, discoverBenchmarks, formatArchetypes, formatBenchmarks, formatCreateAgentReport, parseCreateAgentInput, tokenizeArgs, type DiscoveredBenchmark } from "./agent-create.ts";
17
+ import { assessFocusMetric, countMetricCoverage, createAgent, CREATE_AGENT_USAGE, differentiationWarning, discoverBenchmarks, formatArchetypes, formatBenchmarks, formatCreateAgentReport, parseCreateAgentInput, resolveRole, tokenizeArgs, type DiscoveryOutcome, type FocusAssessor, type FocusMetricAssessment } from "./agent-create.ts";
17
18
  import { formatRemoveAgentReport, parseRemoveAgentArgs, removeAgent, REMOVE_AGENT_USAGE } from "./agent-remove.ts";
18
- import { generateAgentSpec } from "./agent-architect.ts";
19
+ import { generateAgentSpec, generateProjectProfile } from "./agent-architect.ts";
19
20
  import { authorBenchmarkSource } from "./benchmark-author.ts";
20
- import { declarationToSource, declaredSourceForLink, dryRunDeclaration, fetchJson, loadBenchmarkCatalog, loadBenchmarkScores, loadDeclaredSources, resolveBenchmarkSource, saveDeclaredSource, validateDeclaration, type BenchmarkCatalogEntry, type BenchmarkSource, type SourceDeclaration } from "./benchmark-sources.ts";
21
+ import { applyBenchmarkScores, BENCHMARK_ENTRY_CAP, cachedSourceInfo, declarationToSource, declaredSourceForLink, dryRunDeclaration, fetchJson, loadBenchmarkCatalog, loadBenchmarkScores, loadDeclaredSources, resolveBenchmarkSource, saveDeclaredSource, sourceForMetric, validateDeclaration, type BenchmarkCatalogEntry, type BenchmarkPayloadMeta, type BenchmarkSource, type SourceDeclaration } from "./benchmark-sources.ts";
21
22
  import { judgeBenchmarkRelevance } from "./benchmark-discovery.ts";
22
- import { THINKING_LEVELS, catalogFromOmpModelsJson } from "./availability.ts";
23
- import { loadRankData } from "./engine.ts";
23
+ import { catalogFromOmpModelsJson, fetchKeyAvailability, splitThinkingSuffix, type KeyAvailability } from "./availability.ts";
24
+ import { loadRankData, rankRole, type Model } from "./engine.ts";
25
+ import { defaultErrorReportDeps, reportUnexpectedError, type ErrorReportInput } from "./error-report.ts";
26
+ import { formatFeatures } from "./features.ts";
24
27
  import { startExplorer, type ExplorerHandle } from "./explorer/boot.ts";
28
+ import { resolveScopes, unionRoles } from "./explorer/scopes.ts";
25
29
  import { isRecord } from "./guards.ts";
26
- import { KNOWN_METRICS, readPluginSettingsMap, resolveSettings } from "./settings.ts";
30
+ import { applyProviderDisplay, providerPinOf, providerPinOfSelector } from "./provider-display.ts";
31
+ import { applyProfileOverrides, formatProjectRolesReport, parseProjectRolesArgs, PROJECT_ROLES_USAGE, setupProject, type ProjectProfile } from "./project-setup.ts";
32
+ import { DEFAULT_ROLES, findProjectAnchor, KNOWN_METRICS, PLUGIN_SETTINGS_PATH, projectLockPath, readPluginSettingsMap, resolveSettings } from "./settings.ts";
27
33
  import { runUpdater, type Deps } from "./updater.ts";
28
34
 
29
35
  /** SPA directory shipped beside this extension (repo `web/`). */
@@ -66,17 +72,41 @@ function hasEmptyConversation(ctx: ExtContext): boolean {
66
72
  return true;
67
73
  }
68
74
 
69
- /** True when the live session model resolves from the old default selector (same provider/id, any level). */
70
- function sessionBootedOnOldDefault(ctx: ExtContext, oldSelector: string): boolean {
75
+ /**
76
+ * True when `selector` names the live session model: the selector's base
77
+ * (thinking suffix stripped) resolves to a model with the same `provider` and
78
+ * `id` as `ctx.models.current()`. Any thinking level or provider pin is
79
+ * ignored. Used both to detect a session booted on the old default role and to
80
+ * decide whether a fallback event's selector is authoritative for the session
81
+ * (advisor fallbacks emit the same events with the advisor's selector).
82
+ */
83
+ function liveModelIsSelector(ctx: ExtContext, selector: string): boolean {
71
84
  const current = ctx.models?.current();
72
- const colon = oldSelector.lastIndexOf(":");
73
- const level = colon !== -1 && oldSelector.slice(colon + 1) in THINKING_LEVELS ? oldSelector.slice(colon + 1) : null;
74
- const base = level === null ? oldSelector : oldSelector.slice(0, oldSelector.length - level.length - 1);
75
- const oldModel = ctx.models?.resolve(base);
76
- if (current === undefined || current === null || oldModel === undefined || oldModel === null) return false;
77
- if (typeof current !== "object" || typeof oldModel !== "object") return false;
78
- if (!("provider" in current) || !("id" in current) || !("provider" in oldModel) || !("id" in oldModel)) return false;
79
- return current.provider === oldModel.provider && current.id === oldModel.id;
85
+ const { base } = splitThinkingSuffix(selector);
86
+ const target = ctx.models?.resolve(base);
87
+ if (current === undefined || current === null || target === undefined || target === null) return false;
88
+ if (typeof current !== "object" || typeof target !== "object") return false;
89
+ if (!("provider" in current) || !("id" in current) || !("provider" in target) || !("id" in target)) return false;
90
+ return current.provider === target.provider && current.id === target.id;
91
+ }
92
+
93
+ /**
94
+ * Apply the provider display suffix to the live session model (spec #77).
95
+ * `selector` is the event's authoritative selector string when the event
96
+ * carries one; the live model's routing block is the fallback. The event's
97
+ * selector is trusted only when it names the live session model — the fallback
98
+ * events are also emitted for advisor fallbacks, where only the advisor's model
99
+ * changes and the session model is untouched. Contained: a throw must not tear
100
+ * down the session or escape into omp's dispatch.
101
+ */
102
+ function applyProviderDisplayFor(ctx: ExtContext, selector?: string): void {
103
+ try {
104
+ const live = ctx.models?.current();
105
+ const eventPin = typeof selector === "string" && liveModelIsSelector(ctx, selector) ? providerPinOfSelector(selector) : null;
106
+ applyProviderDisplay(live, eventPin ?? providerPinOf(live));
107
+ } catch {
108
+ // display-only; never let a failure escape into omp's dispatch
109
+ }
80
110
  }
81
111
 
82
112
  /**
@@ -92,10 +122,8 @@ function makeApplySessionModel(pi: ExtensionAPI, ctx: ExtContext, notify: (lines
92
122
  if (ctx.agent.kind !== "main") return;
93
123
  if (!hasEmptyConversation(ctx)) return;
94
124
  if (previous === null) return; // first run ever wrote a fresh default; the session predates no old role
95
- if (!sessionBootedOnOldDefault(ctx, previous)) return;
96
- const colon = selector.lastIndexOf(":");
97
- const level = colon !== -1 && selector.slice(colon + 1) in THINKING_LEVELS ? selector.slice(colon + 1) : null;
98
- const base = level === null ? selector : selector.slice(0, selector.length - level.length - 1);
125
+ if (!liveModelIsSelector(ctx, previous)) return;
126
+ const { base, level } = splitThinkingSuffix(selector);
99
127
  const model = ctx.models?.resolve(base);
100
128
  if (model === undefined || model === null) {
101
129
  notify([`omp-llm-role: session model not updated — "${base}" matches no available model`]);
@@ -160,12 +188,59 @@ function parseExplorerArgs(args: string): { port: number | undefined; open: bool
160
188
  return { port: valid, open };
161
189
  }
162
190
 
163
- /** Mirror an explorer line to the UI, and to stderr when the mode has no UI. */
164
- function notifyLines(ctx: ExtContext, line: string): void {
165
- ctx.ui.notify(line, "info");
191
+ /** Mirror a line to the UI, and to stderr when the mode has no UI. */
192
+ function notifyLine(ctx: ExtContext, line: string, level: "info" | "warning"): void {
193
+ ctx.ui.notify(line, level);
166
194
  if (!ctx.hasUI) console.error(line);
167
195
  }
168
196
 
197
+ /** The explorer/authoring line sink: the shared sink at `info`. */
198
+ function notifyLines(ctx: ExtContext, line: string): void {
199
+ notifyLine(ctx, line, "info");
200
+ }
201
+
202
+ /** The `ask` posture's yes/no prompt, built from `ctx.ui.select?` — present only
203
+ * in a UI session, so a headless `ask` degrades to notify-plus-URL. */
204
+ function reportConfirm(ctx: ExtContext): ((title: string, payload: string) => Promise<boolean>) | undefined {
205
+ if (!ctx.hasUI || !ctx.ui.select) return undefined;
206
+ return async (title, payload) => {
207
+ const answer = await ctx.ui.select?.(`${title}\n\n${payload}`, [
208
+ { label: "File", description: "create the GitHub issue with the text above" },
209
+ { label: "Cancel" },
210
+ ]);
211
+ return answer === "File";
212
+ };
213
+ }
214
+
215
+ /** The reporting module's input for one failure: the shared line sink and the
216
+ * `ask` prompt, both bound to this context. */
217
+ function reportInput(ctx: ExtContext, label: string, error: unknown): ErrorReportInput {
218
+ return {
219
+ label,
220
+ error,
221
+ hasUI: ctx.hasUI,
222
+ notify: (line, level) => notifyLine(ctx, line, level),
223
+ confirm: reportConfirm(ctx),
224
+ };
225
+ }
226
+
227
+ /** Route an unexpected command failure through the reporting module. The
228
+ * one-line summary is unchanged from the pre-reporting behaviour; the module
229
+ * adds its own lines (filed/commented/offered) when reporting is on. */
230
+ function reportCommandError(ctx: ExtContext, label: string, err: unknown, prefix = "llm-role"): void {
231
+ ctx.ui.notify(`${prefix}: ${err instanceof Error ? err.message : err}`, "warning");
232
+ void reportUnexpectedError(reportInput(ctx, label, err), defaultErrorReportDeps());
233
+ }
234
+
235
+ /** Report an updater defect (the abort line is already notified by runUpdater).
236
+ * The defect is passed as a string, not wrapped in an `Error`: the module then
237
+ * derives the failure site from the message, so each distinct abort gets its
238
+ * own fingerprint instead of collapsing onto this construction site. */
239
+ function reportDefect(ctx: ExtContext, label: string, defect: string | undefined): void {
240
+ if (defect === undefined) return;
241
+ void reportUnexpectedError(reportInput(ctx, label, defect), defaultErrorReportDeps());
242
+ }
243
+
169
244
  /** External metrics already weighted by a resolved role, for `--list-benchmarks`. */
170
245
  function externalMetricsInUse(): string[] {
171
246
  const { settings } = resolveSettings(readPluginSettingsMap());
@@ -252,11 +327,23 @@ async function resolveBenchmarkLinks(ctx: ExtContext, links: readonly string[],
252
327
  source = await authorLink(ctx, link, yes);
253
328
  if (source === null) return null;
254
329
  }
330
+ // A generic llm-stats benchmark's raw id lives only in its declaration (the
331
+ // metric key folds `.`→`_`), so persist it before the update run — otherwise
332
+ // the updater's metric-key fallback fetches the wrong, 404-ing URL.
333
+ const declaration = source.declaration;
334
+ if (declaration !== undefined && source.labelFromPayload !== undefined && !declared.some((d) => d.metric === declaration.metric)) {
335
+ const saved = saveDeclaredSource(declaration);
336
+ if (saved.ok) declared.push(declaration);
337
+ else notifyLines(ctx, `create-agent: could not persist ${source.metric} source: ${saved.error}`);
338
+ }
255
339
  const loaded = await loadBenchmarkScores(source, false);
256
340
  if (loaded === null) {
257
341
  notifyLines(ctx, `create-agent: ${source.metric} (${source.label}) — no data fetched; the role will rank on its other weights`);
258
342
  } else {
259
- notifyLines(ctx, `create-agent: ${source.metric} (${loaded.label}) — ${Object.keys(loaded.scores).length} rows`);
343
+ notifyLines(
344
+ ctx,
345
+ `create-agent: ${source.metric} (${loaded.label}) — ${loaded.loaded} rows${loaded.total !== null && loaded.loaded < loaded.total ? ` of ${loaded.total} (endpoint caps entries at ${BENCHMARK_ENTRY_CAP})` : ""}`,
346
+ );
260
347
  }
261
348
  metrics.push(source.metric);
262
349
  labels.push(loaded?.label ?? source.label);
@@ -267,9 +354,10 @@ async function resolveBenchmarkLinks(ctx: ExtContext, links: readonly string[],
267
354
  /**
268
355
  * Discover the catalog benchmarks relevant to `purpose` (non-fatal): a catalog
269
356
  * fetch or judge failure just skips discovery, so the command still creates the
270
- * role. Returns null when discovery could not run.
357
+ * role. `fieldSize` is the ranking universe's model count (null when unknown),
358
+ * for the coverage gate. Returns null when discovery could not run.
271
359
  */
272
- async function discoverForPurpose(ctx: ExtContext, purpose: string, exclude: readonly string[]): Promise<DiscoveredBenchmark[] | null> {
360
+ async function discoverForPurpose(ctx: ExtContext, purpose: string, exclude: readonly string[], fieldSize: number | null, assess: FocusAssessor): Promise<DiscoveryOutcome | null> {
273
361
  let catalog: BenchmarkCatalogEntry[] | null;
274
362
  try {
275
363
  catalog = await loadBenchmarkCatalog(false);
@@ -282,13 +370,57 @@ async function discoverForPurpose(ctx: ExtContext, purpose: string, exclude: rea
282
370
  return null;
283
371
  }
284
372
  try {
285
- return await discoverBenchmarks(purpose, catalog, (p, candidates) => judgeBenchmarkRelevance(p, candidates, ctx.cwd), exclude);
373
+ return await discoverBenchmarks(purpose, catalog, (p, candidates) => judgeBenchmarkRelevance(p, candidates, ctx.cwd), exclude, fieldSize, assess);
286
374
  } catch (err) {
287
375
  notifyLines(ctx, `create-agent: benchmark discovery failed (${err instanceof Error ? err.message : err}) — skipping`);
288
376
  return null;
289
377
  }
290
378
  }
291
379
 
380
+ /** Bounded repository artifacts for the `/project-roles` profile architect:
381
+ * README, package.json, AGENTS.md, a docs listing, the recent git log and a
382
+ * shallow file tree. Every read is best-effort — a missing file or a non-git cwd
383
+ * just contributes nothing. */
384
+ function gatherProjectArtifacts(cwd: string): string {
385
+ const parts: string[] = [];
386
+ const readBounded = (rel: string, cap = 4000): string | null => {
387
+ try {
388
+ return readFileSync(join(cwd, rel), "utf8").slice(0, cap);
389
+ } catch {
390
+ return null;
391
+ }
392
+ };
393
+ for (const rel of ["README.md", "package.json", "AGENTS.md", "pyproject.toml", "Cargo.toml", "go.mod"]) {
394
+ const text = readBounded(rel);
395
+ if (text !== null) parts.push(`### ${rel}\n${text}`);
396
+ }
397
+ try {
398
+ const docs = readdirSync(join(cwd, "docs"), { recursive: true })
399
+ .map(String)
400
+ .filter((p) => p.endsWith(".md"))
401
+ .slice(0, 40);
402
+ if (docs.length > 0) parts.push(`### docs/\n${docs.join("\n")}`);
403
+ } catch {
404
+ // no docs dir
405
+ }
406
+ try {
407
+ const log = execFileSync("git", ["log", "--oneline", "-20"], { cwd, encoding: "utf8", timeout: 5000 });
408
+ parts.push(`### git log\n${log}`);
409
+ } catch {
410
+ // not a git repo
411
+ }
412
+ try {
413
+ const entries = readdirSync(cwd, { withFileTypes: true })
414
+ .filter((entry) => !entry.name.startsWith(".") && entry.name !== "node_modules")
415
+ .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
416
+ .slice(0, 60);
417
+ parts.push(`### files\n${entries.join("\n")}`);
418
+ } catch {
419
+ // unreadable cwd
420
+ }
421
+ return parts.join("\n\n");
422
+ }
423
+
292
424
  export default function (pi: ExtensionAPI) {
293
425
  /** Loopback explorer owned by this session binding (null until launched). */
294
426
  let explorer: ExplorerHandle | null = null;
@@ -302,19 +434,41 @@ export default function (pi: ExtensionAPI) {
302
434
  // mid-loop, no write). runUpdater catches its own abort paths; a throw
303
435
  // here is contained by handler dispatch (extension error channel).
304
436
  try {
305
- await runUpdater("session-start", extDeps(pi, ctx));
437
+ const runResult = await runUpdater("session-start", extDeps(pi, ctx));
438
+ reportDefect(ctx, "session-start", runResult.defect);
306
439
  } catch (err) {
307
- ctx.ui.notify(`llm-role: ${err instanceof Error ? err.message : err}`, "warning");
440
+ reportCommandError(ctx, "session-start", err);
308
441
  }
309
442
  });
310
443
 
444
+ // Provider display (spec #77): keep the live model's name suffixed with its
445
+ // provider pin. Never pi.setModel — that would wipe omp's in-flight fallback
446
+ // state and clobber the thinking level; mutate the live model's name only.
447
+ // Registered after the updater so the session-start pin reflects the model
448
+ // the run just selected.
449
+ pi.on("session_start", async (_event, ctx: ExtContext) => {
450
+ applyProviderDisplayFor(ctx);
451
+ });
452
+ pi.on("retry_fallback_applied", async (event, ctx: ExtContext) => {
453
+ applyProviderDisplayFor(ctx, event.to);
454
+ });
455
+ pi.on("retry_fallback_succeeded", async (event, ctx: ExtContext) => {
456
+ applyProviderDisplayFor(ctx, event.model);
457
+ });
458
+ // Self-healing hook: omp restores the primary after a fallback cooldown with
459
+ // no session event, so re-derive the pin from the live model before each turn.
460
+ pi.on("turn_start", async (_event, ctx: ExtContext) => {
461
+ applyProviderDisplayFor(ctx);
462
+ });
463
+
311
464
  pi.registerCommand("refresh-roles", {
312
465
  description: "Refresh model roles from today's llm-stats/OpenRouter rankings",
313
466
  handler: async (_args, ctx: ExtContext) => {
314
467
  try {
315
- await runUpdater("manual", extDeps(pi, ctx), { force: true });
468
+ const runResult = await runUpdater("manual", extDeps(pi, ctx), { force: true });
469
+ reportDefect(ctx, "refresh-roles", runResult.defect);
316
470
  } catch (err) {
317
- ctx.ui.notify(`llm-role: ${err instanceof Error ? err.message : err}`, "warning");
471
+ reportCommandError(ctx, "refresh-roles", err);
318
472
  }
319
473
  },
320
474
  });
@@ -333,13 +487,35 @@ export default function (pi: ExtensionAPI) {
333
487
  return;
334
488
  }
335
489
  notifyLines(ctx, "llm-role explorer: loading today's rankings…");
336
- const rank = await loadRankData({});
490
+ // The explorer ranks the resolved roles of every known scope, so its
491
+ // dataset must carry every metric any of them weights — including
492
+ // generic llm-stats benchmarks (`bench:<id>`) that only a project role
493
+ // pulls in. `loadRankData({})` loads declared sources only, so a role
494
+ // weighting an undeclared benchmark would rank on a dataset missing that
495
+ // metric and the explorer would disagree with the updater. Pass the
496
+ // union of every scope's resolved roles plus the shipped keys, so the
497
+ // dataset stays a superset of any def the UI can rank (the UI can weight
498
+ // `website`/`writing` even when no role does) and a scope switch is free.
499
+ const scopeInputs = { userLockPath: PLUGIN_SETTINGS_PATH, cwd: ctx.cwd };
500
+ const explorerRoles = () => unionRoles(resolveScopes(scopeInputs), PLUGIN_SETTINGS_PATH);
501
+ const rank = await loadRankData({ roles: explorerRoles(), extraMetrics: Object.keys(KNOWN_METRICS) });
337
502
  const catalog = await extDeps(pi, ctx).getCatalog();
503
+ // Best-effort key-availability overlay: a missing key or a failed fetch
504
+ // degrades to "unavailable" and never blocks Explorer boot.
505
+ const loadAvailability = async (): Promise<KeyAvailability> => {
506
+ const token = await ctx.modelRegistry.getApiKeyForProvider("openrouter").catch(() => undefined);
507
+ return token
508
+ ? fetchKeyAvailability(token)
509
+ : { active: false, reason: "unavailable", allowed: new Set(), blocked: new Set(), publicCount: 0, keyedCount: 0, fetchedAt: new Date().toISOString() };
510
+ };
338
511
  explorer = await startExplorer({
339
512
  webDir: WEB_DIR,
513
+ cwd: ctx.cwd,
340
514
  rank,
341
515
  catalog,
342
- reload: (refresh) => loadRankData({ refresh }),
516
+ availability: await loadAvailability(),
517
+ reloadAvailability: loadAvailability,
518
+ reload: (refresh) => loadRankData({ refresh, roles: explorerRoles(), extraMetrics: Object.keys(KNOWN_METRICS) }),
343
519
  port,
344
520
  open,
345
521
  unref: true,
@@ -347,7 +523,7 @@ export default function (pi: ExtensionAPI) {
347
523
  });
348
524
  notifyLines(ctx, `llm-role explorer: ${explorer.url}`);
349
525
  } catch (err) {
350
- ctx.ui.notify(`llm-role explorer: ${err instanceof Error ? err.message : err}`, "warning");
526
+ reportCommandError(ctx, "explore-roles", err, "llm-role explorer");
351
527
  }
352
528
  },
353
529
  });
@@ -376,6 +552,10 @@ export default function (pi: ExtensionAPI) {
376
552
  notifyLines(ctx, formatBenchmarks(undefined, externalMetricsInUse()));
377
553
  return;
378
554
  }
555
+ if (parsed.listFeatures) {
556
+ notifyLines(ctx, formatFeatures());
557
+ return;
558
+ }
379
559
  if (parsed.bodyFile !== undefined) {
380
560
  try {
381
561
  parsed.request.body = readFileSync(parsed.bodyFile, "utf8");
@@ -397,15 +577,64 @@ export default function (pi: ExtensionAPI) {
397
577
  parsed.request.benchmarkLabels = resolved.labels;
398
578
  }
399
579
 
580
+ // 1a. Load today's ranking universe once: the coverage denominator, the
581
+ // per-metric coverage of named benchmarks, and the differentiation
582
+ // comparison all need it. Cached (the updater re-reads the same daily
583
+ // cache), and non-fatal — an unknown field size skips the share rule.
584
+ let rankModels: Model[] | null = null;
585
+ try {
586
+ rankModels = (await loadRankData({})).models;
587
+ } catch (err) {
588
+ notifyLines(ctx, `create-agent: ranking universe unavailable (${err instanceof Error ? err.message : err}) — coverage share checks skipped`);
589
+ }
590
+ const fieldSize = rankModels === null ? null : rankModels.length;
591
+
592
+ // 1a-bis. The focus-metric gate must not run on the score-less create-agent
593
+ // pool: it carries no `bench:<id>` scores, so a gate over it would see
594
+ // nothing for exactly the benchmarks it targets. Probe-fetch each
595
+ // candidate's source, join it onto a copy of the pool, and assess the
596
+ // joined pool. A metric whose scores were not loaded reports every new
597
+ // axis `unknown` (never a silent `ok`).
598
+ const declared = loadDeclaredSources();
599
+ const focusPool: Model[] = rankModels === null ? [] : rankModels.map((m) => ({ ...m, metrics: { ...m.metrics } }));
600
+ const assess: FocusAssessor = async (metric) => {
601
+ const source = sourceForMetric(metric, declared);
602
+ let payload: BenchmarkPayloadMeta | null = null;
603
+ if (source !== null) {
604
+ const loaded = await loadBenchmarkScores(source, false);
605
+ if (loaded !== null) {
606
+ applyBenchmarkScores(focusPool, source, loaded.scores);
607
+ payload = loaded.meta;
608
+ }
609
+ }
610
+ return assessFocusMetric(focusPool, metric, declared, { payload, catalog: cachedSourceInfo(metric, declared).catalog });
611
+ };
612
+
400
613
  // 1b. Discover the catalog benchmarks relevant to the purpose (unless
401
614
  // --no-discover or an explicit --benchmarks list was given). Non-fatal:
402
- // a catalog or judge failure just skips discovery.
615
+ // a catalog or judge failure just skips discovery. A candidate whose
616
+ // coverage is too low to rank on is dropped, not folded in.
617
+ const discoveredCovered = new Map<string, number>();
403
618
  if (!parsed.noDiscover && !parsed.explicitBenchmarks && parsed.request.purpose.trim() !== "") {
404
- const discovered = await discoverForPurpose(ctx, parsed.request.purpose, parsed.request.extraBenchmarks ?? []);
405
- if (discovered !== null && discovered.length > 0) {
406
- parsed.request.extraBenchmarks = [...(parsed.request.extraBenchmarks ?? []), ...discovered.map((d) => d.metric)];
407
- parsed.request.benchmarkLabels = [...(parsed.request.benchmarkLabels ?? []), ...discovered.map((d) => d.label)];
408
- notifyLines(ctx, `create-agent: discovered benchmarks: ${discovered.map((d) => `${d.label} (${d.metric})`).join(", ")}`);
619
+ const outcome = await discoverForPurpose(ctx, parsed.request.purpose, parsed.request.extraBenchmarks ?? [], fieldSize, assess);
620
+ if (outcome !== null) {
621
+ if (outcome.dropped.length > 0) {
622
+ notifyLines(ctx, `create-agent: dropped benchmarks: ${outcome.dropped.map((d) => `${d.label} (${d.metric}) — ${d.reason}`).join(", ")}`);
623
+ }
624
+ if (outcome.discovered.length > 0) {
625
+ parsed.request.extraBenchmarks = [...(parsed.request.extraBenchmarks ?? []), ...outcome.discovered.map((d) => d.metric)];
626
+ parsed.request.benchmarkLabels = [...(parsed.request.benchmarkLabels ?? []), ...outcome.discovered.map((d) => d.label)];
627
+ for (const d of outcome.discovered) discoveredCovered.set(d.metric, d.covered);
628
+ // A dotted id's raw form cannot be reconstructed from its metric key,
629
+ // so persist its declaration now — otherwise the updater fetches the
630
+ // normalized (404-ing) URL and the metric is dead weight.
631
+ for (const d of outcome.discovered) {
632
+ if (d.declaration === undefined) continue;
633
+ const saved = saveDeclaredSource(d.declaration);
634
+ if (!saved.ok) notifyLines(ctx, `create-agent: could not persist ${d.metric} source: ${saved.error}`);
635
+ }
636
+ notifyLines(ctx, `create-agent: discovered benchmarks: ${outcome.discovered.map((d) => `${d.label} (${d.metric})`).join(", ")}`);
637
+ }
409
638
  }
410
639
  }
411
640
 
@@ -441,7 +670,10 @@ export default function (pi: ExtensionAPI) {
441
670
  // user can see what is already in use and avoid duplicates. `--benchmarks`
442
671
  // covers headless runs, where there is no prompt.
443
672
  if (ctx.hasUI && ctx.ui.input && parsed.request.extraBenchmarks === undefined) {
444
- notifyLines(ctx, formatBenchmarks(undefined, externalMetricsInUse()));
673
+ // Preview the request's resolved weights so the list marks what the role
674
+ // already weights (the "already in this role's weights" marker).
675
+ const preview = resolveRole(parsed.request);
676
+ notifyLines(ctx, formatBenchmarks("errors" in preview ? undefined : preview.def.weights, externalMetricsInUse()));
445
677
  const answer = await ctx.ui.input(
446
678
  "Additional benchmarks",
447
679
  "comma-separated metric names to add to the weights, or empty",
@@ -454,18 +686,201 @@ export default function (pi: ExtensionAPI) {
454
686
  }
455
687
  }
456
688
 
689
+ // 3b. Assess the focus metrics: a discovered metric's coverage is the
690
+ // catalog's model count; a named metric's is the loaded models that
691
+ // carry it. The nine-axis assessment is computed over the joined pool
692
+ // (the same object the gate and the report consume).
693
+ const covered: Record<string, number> = {};
694
+ const assessments: Record<string, FocusMetricAssessment> = {};
695
+ for (const metric of parsed.request.extraBenchmarks ?? []) {
696
+ assessments[metric] = await assess(metric);
697
+ covered[metric] = discoveredCovered.get(metric) ?? countMetricCoverage(focusPool, metric, declared);
698
+ }
699
+ parsed.request.coverage = { total: fieldSize, covered, declared, assessments };
700
+
457
701
  // 4. Write the role + the agent file, then wire `modelRoles.<name>` in-process.
458
702
  const result = createAgent(parsed.request);
459
703
  if (!result.ok) {
460
704
  notifyLines(ctx, `create-agent: ${result.errors.join("\n")}`);
461
705
  return;
462
706
  }
463
- notifyLines(ctx, formatCreateAgentReport(result, "ranking the new role…"));
707
+ const defaultDef = resolveSettings(readPluginSettingsMap({ global: parsed.request.lockPath, project: null })).settings.roles.default;
708
+ const differentiation = rankModels !== null && defaultDef !== undefined ? differentiationWarning(result.def, defaultDef, rankModels) : null;
709
+ if (parsed.json) notifyLines(ctx, JSON.stringify({ ...result, differentiation }, null, 2));
710
+ else notifyLines(ctx, formatCreateAgentReport(result, "ranking the new role…", { differentiation }));
464
711
  if (result.dryRun) return;
465
712
  try {
466
- await runUpdater("manual", extDeps(pi, ctx), { force: true });
713
+ const runResult = await runUpdater("manual", extDeps(pi, ctx), { force: true });
714
+ reportDefect(ctx, "create-agent", runResult.defect);
715
+ } catch (err) {
716
+ reportCommandError(ctx, "create-agent", err);
717
+ }
718
+ },
719
+ });
720
+
721
+ // Discover the project's usecase from its own artifacts and apply a
722
+ // project-scoped role set: the project plugin settings, the new roles' agents,
723
+ // and the project's modelRoles in <cwd>/.omp/config.yml. The project-aware
724
+ // updater (run in-process at the end) keeps the global config untouched.
725
+ pi.registerCommand("project-roles", {
726
+ description: "Discover the project's usecase and apply a project-scoped role set",
727
+ handler: async (args, ctx: ExtContext) => {
728
+ const parsed = parseProjectRolesArgs(typeof args === "string" ? args : "");
729
+ if (!parsed.ok) {
730
+ notifyLines(ctx, parsed.error);
731
+ return;
732
+ }
733
+ if (parsed.help) {
734
+ notifyLines(ctx, PROJECT_ROLES_USAGE);
735
+ return;
736
+ }
737
+
738
+ const cwd = ctx.cwd;
739
+ const projectDir = join(cwd, ".omp");
740
+ const lockPath = projectLockPath(cwd);
741
+ const configPath = join(projectDir, "config.yml");
742
+
743
+ // omp reads <cwd>/.omp with no walk-up; the plugin's walk-up anchor may
744
+ // differ (a session launched in a subdirectory). The written config is what
745
+ // omp reads, but the anchor would read/write a different .omp — warn.
746
+ const warnings: string[] = [];
747
+ const anchor = findProjectAnchor(cwd);
748
+ if (anchor !== null && anchor !== cwd) {
749
+ warnings.push(
750
+ `the session cwd (${cwd}) is not the project anchor (${anchor}); the config is written to ${projectDir}, which is what omp reads — the plugin's walk-up anchor would read a different .omp`,
751
+ );
752
+ }
753
+
754
+ // 1. Gather the repo artifacts and run the profile architect.
755
+ notifyLines(ctx, "project-roles: reading the project…");
756
+ let profile: ProjectProfile;
757
+ try {
758
+ profile = await generateProjectProfile({
759
+ artifacts: gatherProjectArtifacts(cwd),
760
+ cwd,
761
+ model: ctx.models?.current(),
762
+ modelRegistry: ctx.modelRegistry,
763
+ purpose: parsed.purpose,
764
+ });
765
+ } catch (err) {
766
+ notifyLines(ctx, `project-roles: profile discovery failed: ${err instanceof Error ? err.message : err}`);
767
+ return;
768
+ }
769
+ profile = applyProfileOverrides(profile, { purpose: parsed.purpose, roles: parsed.roles });
770
+
771
+ // 2. Load the ranking universe once: the coverage denominator, the focus
772
+ // assessment pool and the top picks all need it. Non-fatal.
773
+ let rankModels: Model[] | null = null;
774
+ try {
775
+ rankModels = (await loadRankData({})).models;
776
+ } catch (err) {
777
+ notifyLines(ctx, `project-roles: ranking universe unavailable (${err instanceof Error ? err.message : err}) — coverage checks skipped`);
778
+ }
779
+ const fieldSize = rankModels === null ? null : rankModels.length;
780
+ const declared = loadDeclaredSources();
781
+ const focusPool: Model[] = rankModels === null ? [] : rankModels.map((m) => ({ ...m, metrics: { ...m.metrics } }));
782
+ const assess: FocusAssessor = async (metric) => {
783
+ const source = sourceForMetric(metric, declared);
784
+ let payload: BenchmarkPayloadMeta | null = null;
785
+ if (source !== null) {
786
+ const loaded = await loadBenchmarkScores(source, false);
787
+ if (loaded !== null) {
788
+ applyBenchmarkScores(focusPool, source, loaded.scores);
789
+ payload = loaded.meta;
790
+ }
791
+ }
792
+ return assessFocusMetric(focusPool, metric, declared, { payload, catalog: cachedSourceInfo(metric, declared).catalog });
793
+ };
794
+
795
+ // 3. Per new role: discover benchmarks (judge-backed) and author the agent.
796
+ const covered: Record<string, number> = {};
797
+ const assessments: Record<string, FocusMetricAssessment> = {};
798
+ for (const role of profile.roles) {
799
+ if (role.keep === false) continue;
800
+ if (role.name.trim() in DEFAULT_ROLES) continue; // shipped role — no new agent
801
+ if (role.benchmarks === undefined || role.benchmarks.length === 0) {
802
+ const outcome = await discoverForPurpose(ctx, role.purpose, [], fieldSize, assess);
803
+ if (outcome !== null) {
804
+ if (outcome.dropped.length > 0) {
805
+ notifyLines(ctx, `project-roles: dropped benchmarks (coverage): ${outcome.dropped.map((d) => `${d.label} (${d.metric}) — ${d.reason}`).join(", ")}`);
806
+ }
807
+ role.benchmarks = outcome.discovered.map((d) => d.metric);
808
+ for (const d of outcome.discovered) {
809
+ if (d.declaration === undefined) continue;
810
+ const saved = saveDeclaredSource(d.declaration);
811
+ if (!saved.ok) notifyLines(ctx, `project-roles: could not persist ${d.metric} source: ${saved.error}`);
812
+ }
813
+ }
814
+ }
815
+ for (const metric of role.benchmarks ?? []) {
816
+ assessments[metric] = await assess(metric);
817
+ covered[metric] = rankModels === null ? 0 : countMetricCoverage(focusPool, metric, declared);
818
+ }
819
+ try {
820
+ role.spec = await generateAgentSpec({
821
+ description: role.purpose,
822
+ cwd,
823
+ model: ctx.models?.current(),
824
+ modelRegistry: ctx.modelRegistry,
825
+ benchmarks: role.benchmarks,
826
+ });
827
+ } catch (err) {
828
+ notifyLines(ctx, `project-roles: architect failed for "${role.name}": ${err instanceof Error ? err.message : err}`);
829
+ return;
830
+ }
831
+ }
832
+
833
+ // 4. Plan first (a dry run of the same all-or-nothing core), print it, then
834
+ // apply. `--dry-run` stops after the plan; without `--force` an existing
835
+ // project role config is refused by the core.
836
+ const planOpts = {
837
+ projectDir,
838
+ lockPath,
839
+ configPath,
840
+ force: parsed.force,
841
+ yes: parsed.yes,
842
+ features: parsed.features,
843
+ coverage: { total: fieldSize, covered, declared, assessments },
844
+ };
845
+ const plan = setupProject(profile, { ...planOpts, dryRun: true });
846
+ if (!plan.ok) {
847
+ notifyLines(ctx, `project-roles: ${plan.errors.join("\n")}`);
848
+ return;
849
+ }
850
+ const topPicks: Record<string, string> = {};
851
+ if (rankModels !== null) {
852
+ for (const name of plan.added) {
853
+ const ranked = rankRole(plan.roles[name], rankModels);
854
+ if (ranked.length > 0) topPicks[name] = ranked[0].model.id;
855
+ }
856
+ }
857
+ if (parsed.json) notifyLines(ctx, JSON.stringify({ profile, ...plan, warnings }, null, 2));
858
+ else notifyLines(ctx, formatProjectRolesReport(profile, plan, { topPicks, warnings }));
859
+ if (parsed.dryRun) return;
860
+
861
+ if (!parsed.yes && ctx.hasUI && ctx.ui.select) {
862
+ const answer = await ctx.ui.select("Apply this project role set?", [
863
+ { label: "Apply", description: "write the project settings, agents and modelRoles" },
864
+ { label: "Cancel" },
865
+ ]);
866
+ if (answer !== "Apply") {
867
+ notifyLines(ctx, "project-roles: cancelled");
868
+ return;
869
+ }
870
+ }
871
+
872
+ const result = setupProject(profile, { ...planOpts, dryRun: false });
873
+ if (!result.ok) {
874
+ notifyLines(ctx, `project-roles: ${result.errors.join("\n")}`);
875
+ return;
876
+ }
877
+
878
+ // 5. Run the updater in-process so the project's modelRoles land.
879
+ try {
880
+ const runResult = await runUpdater("manual", extDeps(pi, ctx), { force: true });
881
+ reportDefect(ctx, "project-roles", runResult.defect);
467
882
  } catch (err) {
468
- ctx.ui.notify(`llm-role: ${err instanceof Error ? err.message : err}`, "warning");
883
+ reportCommandError(ctx, "project-roles", err);
469
884
  }
470
885
  },
471
886
  });
@@ -503,9 +918,10 @@ export default function (pi: ExtensionAPI) {
503
918
  notifyLines(ctx, formatRemoveAgentReport(result, "cleaning up config.yml…"));
504
919
  if (result.dryRun) return;
505
920
  try {
506
- await runUpdater("manual", extDeps(pi, ctx), { force: true });
921
+ const runResult = await runUpdater("manual", extDeps(pi, ctx), { force: true });
922
+ reportDefect(ctx, "remove-agent", runResult.defect);
507
923
  } catch (err) {
508
- ctx.ui.notify(`llm-role: ${err instanceof Error ? err.message : err}`, "warning");
924
+ reportCommandError(ctx, "remove-agent", err);
509
925
  }
510
926
  },
511
927
  });