omegon 0.6.3 → 0.6.4

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 (69) hide show
  1. package/README.md +12 -10
  2. package/bin/omegon.mjs +40 -0
  3. package/bin/pi.mjs +5 -26
  4. package/extensions/00-secrets/index.ts +146 -39
  5. package/extensions/01-auth/auth.ts +1 -1
  6. package/extensions/01-auth/index.ts +3 -3
  7. package/extensions/auto-compact.ts +1 -1
  8. package/extensions/bootstrap/deps.ts +42 -0
  9. package/extensions/bootstrap/index.ts +326 -110
  10. package/extensions/chronos/index.ts +1 -1
  11. package/extensions/cleave/dispatcher.ts +6 -6
  12. package/extensions/cleave/index.ts +6 -6
  13. package/extensions/cleave/planner.ts +1 -1
  14. package/extensions/cleave/worktree.ts +1 -1
  15. package/extensions/core-renderers.ts +24 -84
  16. package/extensions/dashboard/footer.ts +184 -40
  17. package/extensions/dashboard/git.ts +2 -2
  18. package/extensions/dashboard/index.ts +4 -4
  19. package/extensions/dashboard/overlay-data.ts +5 -5
  20. package/extensions/dashboard/overlay.ts +5 -5
  21. package/extensions/dashboard/render-utils.ts +1 -1
  22. package/extensions/dashboard/types.ts +15 -0
  23. package/extensions/defaults.ts +4 -12
  24. package/extensions/design-tree/dashboard-state.ts +6 -6
  25. package/extensions/design-tree/design-card.ts +3 -3
  26. package/extensions/design-tree/index.ts +64 -44
  27. package/extensions/design-tree/types.ts +4 -2
  28. package/extensions/distill.ts +1 -1
  29. package/extensions/effort/index.ts +137 -10
  30. package/extensions/lib/model-routing.ts +304 -32
  31. package/extensions/lib/operator-fallback.ts +1 -1
  32. package/extensions/lib/operator-profile.ts +1 -1
  33. package/extensions/lib/provider-env.ts +163 -0
  34. package/extensions/{sci-ui.ts → lib/sci-ui.ts} +119 -2
  35. package/extensions/{shared-state.ts → lib/shared-state.ts} +13 -9
  36. package/extensions/lib/slash-command-bridge.ts +1 -1
  37. package/extensions/{types.d.ts → lib/types.d.ts} +3 -3
  38. package/extensions/local-inference/index.ts +1 -1
  39. package/extensions/mcp-bridge/index.ts +1 -1
  40. package/extensions/model-budget.ts +10 -10
  41. package/extensions/offline-driver.ts +11 -4
  42. package/extensions/openspec/archive-gate.ts +1 -1
  43. package/extensions/openspec/branch-cleanup.ts +1 -1
  44. package/extensions/openspec/dashboard-state.ts +3 -3
  45. package/extensions/openspec/index.ts +5 -5
  46. package/extensions/project-memory/factstore.ts +5 -11
  47. package/extensions/project-memory/index.ts +48 -34
  48. package/extensions/project-memory/package.json +1 -1
  49. package/extensions/project-memory/sci-renderers.ts +1 -1
  50. package/extensions/render/index.ts +1 -1
  51. package/extensions/session-log.ts +1 -1
  52. package/extensions/spinner-verbs.ts +1 -1
  53. package/extensions/style.ts +1 -1
  54. package/extensions/terminal-title.ts +3 -3
  55. package/extensions/tool-profile/index.ts +1 -1
  56. package/extensions/vault/index.ts +1 -1
  57. package/extensions/version-check.ts +13 -9
  58. package/extensions/view/index.ts +4 -4
  59. package/extensions/web-search/index.ts +5 -2
  60. package/extensions/web-ui/index.ts +1 -1
  61. package/extensions/web-ui/state.ts +1 -1
  62. package/package.json +8 -7
  63. package/scripts/preinstall.sh +19 -3
  64. package/scripts/publish-pi-mono.sh +92 -0
  65. package/skills/pi-extensions/SKILL.md +2 -2
  66. package/skills/pi-tui/SKILL.md +17 -17
  67. package/skills/typescript/SKILL.md +1 -1
  68. package/themes/alpharius.json +7 -6
  69. /package/extensions/{debug.ts → lib/debug.ts} +0 -0
@@ -17,18 +17,18 @@
17
17
  * ## Overview | ## Research | ## Decisions | ## Open Questions | ## Implementation Notes
18
18
  */
19
19
 
20
- import type { ExtensionAPI, ExtensionContext } from "@cwilson613/pi-coding-agent";
20
+ import type { ExtensionAPI, ExtensionContext } from "@styrene-lab/pi-coding-agent";
21
21
  import { Type } from "@sinclair/typebox";
22
22
  import { StringEnum } from "../lib/typebox-helpers.ts";
23
- import { Text } from "@cwilson613/pi-tui";
23
+ import { Text } from "@styrene-lab/pi-tui";
24
24
  import * as fs from "node:fs";
25
25
  import * as path from "node:path";
26
26
  import { execFileSync } from "node:child_process";
27
27
  import { shouldRefreshDesignTreeForPath } from "../dashboard/file-watch.ts";
28
- import { sharedState } from "../shared-state.ts";
28
+ import { sharedState } from "../lib/shared-state.ts";
29
29
 
30
30
  import { emitDesignTreeState } from "./dashboard-state.ts";
31
- import { sciCall, sciLoading, sciOk, sciErr, sciExpanded, sciBanner } from "../sci-ui.ts";
31
+ import { sciCall, sciLoading, sciOk, sciErr, sciExpanded, sciBanner } from "../lib/sci-ui.ts";
32
32
  import { SciDesignCard, buildCardDetails } from "./design-card.ts";
33
33
  import type { DesignCardDetails } from "./design-card.ts";
34
34
  import { emitConstraintCandidates, emitDecisionCandidates } from "./lifecycle-emitter.ts";
@@ -338,7 +338,7 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
338
338
  boundToOpenSpec: binding.bound,
339
339
  // Normalized binding status from canonical resolver
340
340
  bindingStatus: lifecycleSummary?.bindingStatus ?? (binding.bound ? "bound" : "unbound"),
341
- canImplement: node.status === "decided",
341
+ canImplement: node.status === "decided" || node.status === "resolved",
342
342
  isImplementationPhase: node.status === "implementing" || node.status === "implemented",
343
343
  reopenSignalTarget: binding.changeName ?? node.openspec_change ?? node.id,
344
344
  // Canonical lifecycle fields from resolveLifecycleSummary when available
@@ -435,7 +435,7 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
435
435
  // AND design-phase OpenSpec change is archived.
436
436
  const readyNodes = Array.from(tree.nodes.values())
437
437
  .filter((n) => {
438
- if (n.status !== "decided") return false;
438
+ if (n.status !== "decided" && n.status !== "resolved") return false;
439
439
  // Hard gate: design spec must be archived
440
440
  const specBinding = resolveDesignSpecBinding(ctx.cwd, n.id);
441
441
  if (!specBinding.archived) return false;
@@ -668,7 +668,7 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
668
668
  "set focus, or bridge to OpenSpec for implementation.\n\n" +
669
669
  "Actions:\n" +
670
670
  "- create: Create a new design node (id, title required; parent, status, tags, overview optional)\n" +
671
- "- set_status: Change node status (seed/exploring/decided/blocked/deferred)\n" +
671
+ "- set_status: Change node status (seed/exploring/resolved/decided/blocked/deferred)\n" +
672
672
  "- add_question: Add an open question to a node\n" +
673
673
  "- remove_question: Remove an open question by text\n" +
674
674
  "- add_research: Add a research entry (heading + content)\n" +
@@ -686,7 +686,7 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
686
686
  "Mutate the design tree — create nodes, set status, add research/decisions/questions, branch, implement",
687
687
  promptGuidelines: [
688
688
  "Use 'create' to start a new design exploration. Status defaults to 'seed'.",
689
- "Use 'set_status' to transition nodes: seed → exploring → decided. Use 'blocked' or 'deferred' as needed.",
689
+ "Use 'set_status' to transition nodes: seed → exploring → resolved → decided. Use 'resolved' when design questions are answered but the formal lifecycle gate hasn't cleared. Use 'blocked' or 'deferred' as needed.",
690
690
  "Use 'add_question' when discussion reveals unknowns. Use 'remove_question' when questions are answered.",
691
691
  "Use 'add_research' to record findings with a heading and content.",
692
692
  "Use 'add_decision' to crystallize choices with title, status (exploring/decided/rejected), and rationale.",
@@ -799,21 +799,31 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
799
799
  const oldStatus = node.status;
800
800
 
801
801
  // Hard gate: set_status(decided) requires archived design spec.
802
+ // Exception: bug/chore/task nodes with no open questions can skip
803
+ // the design-phase ceremony — the decision IS the diagnosis.
802
804
  if (newStatus === "decided") {
803
- const designSpec = resolveDesignSpecBinding(ctx.cwd, node.id);
804
- if (designSpec.missing) {
805
- return {
806
- content: [{ type: "text", text: `Cannot mark '${node.title}' decided: scaffold design spec first via set_status(exploring).` }],
807
- details: { id: node.id, blockedBy: "design-openspec-missing" },
808
- isError: true,
809
- };
810
- }
811
- if (designSpec.active && !designSpec.archived) {
812
- return {
813
- content: [{ type: "text", text: `Cannot mark '${node.title}' decided: run /assess design then archive the design change before marking decided.` }],
814
- details: { id: node.id, blockedBy: "design-openspec-not-archived" },
815
- isError: true,
816
- };
805
+ const lightweightTypes = new Set(["bug", "chore", "task"]);
806
+ const isLightweight = node.issue_type && lightweightTypes.has(node.issue_type);
807
+ const hasOpenQuestions = (node.open_questions?.length ?? 0) > 0;
808
+
809
+ // Lightweight nodes: allow decided if no open questions remain,
810
+ // regardless of design-phase spec state.
811
+ if (!(isLightweight && !hasOpenQuestions)) {
812
+ const designSpec = resolveDesignSpecBinding(ctx.cwd, node.id);
813
+ if (designSpec.missing) {
814
+ return {
815
+ content: [{ type: "text", text: `Cannot mark '${node.title}' decided: scaffold design spec first via set_status(exploring).` }],
816
+ details: { id: node.id, blockedBy: "design-openspec-missing" },
817
+ isError: true,
818
+ };
819
+ }
820
+ if (designSpec.active && !designSpec.archived) {
821
+ return {
822
+ content: [{ type: "text", text: `Cannot mark '${node.title}' decided: run \`/assess design ${node.id}\` then archive the design change before marking decided.` }],
823
+ details: { id: node.id, blockedBy: "design-openspec-not-archived" },
824
+ isError: true,
825
+ };
826
+ }
817
827
  }
818
828
  }
819
829
 
@@ -1125,12 +1135,12 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
1125
1135
  if (!node) {
1126
1136
  return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
1127
1137
  }
1128
- if (node.status !== "decided") {
1138
+ if (node.status !== "decided" && node.status !== "resolved") {
1129
1139
  return {
1130
1140
  content: [{
1131
1141
  type: "text",
1132
- text: `Node '${node.title}' is '${node.status}', not 'decided'. ` +
1133
- `Resolve open questions and set status to 'decided' before implementing.`,
1142
+ text: `Node '${node.title}' is '${node.status}', not 'decided' or 'resolved'. ` +
1143
+ `Resolve open questions and set status to 'decided' (or 'resolved') before implementing.`,
1134
1144
  }],
1135
1145
  details: {},
1136
1146
  isError: true,
@@ -1138,21 +1148,31 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
1138
1148
  }
1139
1149
 
1140
1150
  // Hard gate: design-phase spec must be archived before implementation.
1151
+ // Exception: bug/chore/task nodes that are already decided with no
1152
+ // open questions can proceed directly — they don't need a design-phase
1153
+ // spec because the decision is the bug diagnosis itself.
1141
1154
  {
1142
- const designSpec = resolveDesignSpecBinding(ctx.cwd, node.id);
1143
- if (designSpec.missing) {
1144
- return {
1145
- content: [{ type: "text", text: "Scaffold design spec first via set_status(exploring)" }],
1146
- details: { id: node.id, blockedBy: "design-openspec-missing" },
1147
- isError: true,
1148
- };
1149
- }
1150
- if (designSpec.active && !designSpec.archived) {
1151
- return {
1152
- content: [{ type: "text", text: `Cannot implement '${node.title}': archive the design change first (/opsx:archive on the design change).` }],
1153
- details: { id: node.id, blockedBy: "design-openspec-not-archived" },
1154
- isError: true,
1155
- };
1155
+ const lightweightTypes = new Set(["bug", "chore", "task"]);
1156
+ const isLightweight = node.issue_type && lightweightTypes.has(node.issue_type);
1157
+ const hasOpenQuestions = (node.open_questions?.length ?? 0) > 0;
1158
+ const skipDesignGate = isLightweight && !hasOpenQuestions && (node.status === "decided" || node.status === "resolved");
1159
+
1160
+ if (!skipDesignGate) {
1161
+ const designSpec = resolveDesignSpecBinding(ctx.cwd, node.id);
1162
+ if (designSpec.missing) {
1163
+ return {
1164
+ content: [{ type: "text", text: "Scaffold design spec first via set_status(exploring)" }],
1165
+ details: { id: node.id, blockedBy: "design-openspec-missing" },
1166
+ isError: true,
1167
+ };
1168
+ }
1169
+ if (designSpec.active && !designSpec.archived) {
1170
+ return {
1171
+ content: [{ type: "text", text: `Cannot implement '${node.title}': archive the design change first (\`/opsx:archive\` on the design change).` }],
1172
+ details: { id: node.id, blockedBy: "design-openspec-not-archived" },
1173
+ isError: true,
1174
+ };
1175
+ }
1156
1176
  }
1157
1177
  }
1158
1178
 
@@ -1367,7 +1387,7 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
1367
1387
  return;
1368
1388
  }
1369
1389
  const total = tree.nodes.size;
1370
- const decided = Array.from(tree.nodes.values()).filter((n) => n.status === "decided").length;
1390
+ const decided = Array.from(tree.nodes.values()).filter((n) => n.status === "decided" || n.status === "resolved").length;
1371
1391
  const exploring = Array.from(tree.nodes.values()).filter(
1372
1392
  (n) => n.status === "exploring" || n.status === "seed",
1373
1393
  ).length;
@@ -1658,9 +1678,9 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
1658
1678
  ctx.ui.notify(`Node '${id}' not found`, "error");
1659
1679
  return;
1660
1680
  }
1661
- if (node.status !== "decided") {
1681
+ if (node.status !== "decided" && node.status !== "resolved") {
1662
1682
  ctx.ui.notify(
1663
- `'${node.title}' is '${node.status}', not 'decided'. Resolve questions first.`,
1683
+ `'${node.title}' is '${node.status}', not 'decided'/'resolved'. Resolve questions first.`,
1664
1684
  "warning",
1665
1685
  );
1666
1686
  return;
@@ -1765,7 +1785,7 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
1765
1785
 
1766
1786
  const implemented = Array.from(tree.nodes.values()).filter((n) => n.status === "implemented").length;
1767
1787
  const implementing = Array.from(tree.nodes.values()).filter((n) => n.status === "implementing").length;
1768
- const decided = Array.from(tree.nodes.values()).filter((n) => n.status === "decided").length;
1788
+ const decided = Array.from(tree.nodes.values()).filter((n) => n.status === "decided" || n.status === "resolved").length;
1769
1789
  const exploring = Array.from(tree.nodes.values()).filter(
1770
1790
  (n) => n.status === "exploring" || n.status === "seed",
1771
1791
  ).length;
@@ -1956,7 +1976,7 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
1956
1976
  if (status === "implemented" || status === "deferred") {
1957
1977
  toMigrate.push(entry);
1958
1978
  } else {
1959
- // seed, exploring, decided, blocked — leave in docs/
1979
+ // seed, exploring, resolved, decided, blocked — leave in docs/
1960
1980
  activeExplorations.push(entry);
1961
1981
  }
1962
1982
  }
@@ -4,13 +4,14 @@
4
4
 
5
5
  // ─── Node Status ─────────────────────────────────────────────────────────────
6
6
 
7
- export type NodeStatus = "seed" | "exploring" | "decided" | "implementing" | "implemented" | "blocked" | "deferred";
7
+ export type NodeStatus = "seed" | "exploring" | "resolved" | "decided" | "implementing" | "implemented" | "blocked" | "deferred";
8
8
 
9
- export const VALID_STATUSES: NodeStatus[] = ["seed", "exploring", "decided", "implementing", "implemented", "blocked", "deferred"];
9
+ export const VALID_STATUSES: NodeStatus[] = ["seed", "exploring", "resolved", "decided", "implementing", "implemented", "blocked", "deferred"];
10
10
 
11
11
  export const STATUS_ICONS: Record<NodeStatus, string> = {
12
12
  seed: "◌",
13
13
  exploring: "◐",
14
+ resolved: "◉",
14
15
  decided: "●",
15
16
  implementing: "⚙",
16
17
  implemented: "✓",
@@ -21,6 +22,7 @@ export const STATUS_ICONS: Record<NodeStatus, string> = {
21
22
  export const STATUS_COLORS: Record<NodeStatus, string> = {
22
23
  seed: "muted",
23
24
  exploring: "accent",
25
+ resolved: "success",
24
26
  decided: "success",
25
27
  implementing: "accent",
26
28
  implemented: "success",
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { existsSync, mkdirSync } from "node:fs";
13
13
  import { join, basename } from "node:path";
14
- import type { ExtensionAPI } from "@cwilson613/pi-coding-agent";
14
+ import type { ExtensionAPI } from "@styrene-lab/pi-coding-agent";
15
15
 
16
16
  export default function distillExtension(pi: ExtensionAPI) {
17
17
 
@@ -16,24 +16,28 @@
16
16
  * /effort uncap — Remove ceiling lock
17
17
  */
18
18
 
19
- import type { ExtensionAPI, ExtensionContext } from "@cwilson613/pi-coding-agent";
19
+ import type { ExtensionAPI, ExtensionContext } from "@styrene-lab/pi-coding-agent";
20
20
  import { readFileSync, existsSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
 
23
23
  import type { EffortLevel, EffortState, EffortModelTier, ThinkingLevel } from "./types.ts";
24
24
  import { EFFORT_NAMES } from "./types.ts";
25
25
  import { tierConfig, parseTierName, DEFAULT_EFFORT_LEVEL, TIER_NAMES } from "./tiers.ts";
26
- import { sharedState, DASHBOARD_UPDATE_EVENT } from "../shared-state.ts";
26
+ import { sharedState, DASHBOARD_UPDATE_EVENT } from "../lib/shared-state.ts";
27
27
  import {
28
28
  resolveTier,
29
29
  getTierDisplayLabel,
30
30
  getDefaultPolicy,
31
+ getViableModels,
32
+ buildProviderSummary,
33
+ matchTierUniversal as matchTierUniversalExport,
31
34
  clampThinkingLevel,
32
35
  type ModelTier,
33
36
  type RegistryModel,
34
37
  } from "../lib/model-routing.ts";
35
38
  import { readLastUsedModel, writeLastUsedModel } from "../lib/model-preferences.ts";
36
39
  import { readOperatorProfile, loadOperatorRuntimeState, toCapabilityProfile, toCapabilityRuntimeState } from "../lib/operator-profile.ts";
40
+ import { PROVIDER_ENV_VARS, getProviderRemediationHint } from "../lib/provider-env.ts";
37
41
 
38
42
  // ─── Constants ───────────────────────────────────────────────
39
43
 
@@ -72,7 +76,7 @@ async function switchDriverModel(
72
76
  driver: EffortModelTier,
73
77
  ): Promise<{ model: RegistryModel; maxThinking?: ThinkingLevel } | null> {
74
78
  // Snapshot the registry once; both resolveTier and the model lookup use it
75
- const all = ctx.modelRegistry.getAll() as unknown as RegistryModel[];
79
+ const all = getViableModels(ctx.modelRegistry);
76
80
  // Build O(1) index over the same snapshot — no second linear scan (C3)
77
81
  const byKey = new Map(all.map((m) => [`${m.provider}/${m.id}`, m]));
78
82
  const { policy, profile, runtimeState } = getResolverInputs(ctx);
@@ -113,7 +117,7 @@ function resolveExtractionTier(
113
117
  ctx: ExtensionContext,
114
118
  ): { displayTier: string; resolvedModelId?: string } {
115
119
  const { policy, profile, runtimeState } = getResolverInputs(ctx);
116
- const all = ctx.modelRegistry.getAll() as unknown as RegistryModel[];
120
+ const all = getViableModels(ctx.modelRegistry);
117
121
 
118
122
  // Determine effective tier: upgrade local→retribution when policy prefers cheap cloud
119
123
  const effectiveTier: ModelTier =
@@ -247,7 +251,25 @@ export default function (pi: ExtensionAPI) {
247
251
  // current startup model rather than warning about an unusable session when
248
252
  // a working driver is already present.
249
253
  const restoredModel = await restoreLastUsedModel(pi, ctx);
250
- const switchedDriver = restoredModel ? null : await switchDriverModel(pi, ctx, state.driver);
254
+ let switchedDriver = restoredModel ? null : await switchDriverModel(pi, ctx, state.driver);
255
+
256
+ // Degradation cascade: if the preferred tier failed, try adjacent tiers
257
+ // before settling on whatever pi defaulted to (which may be deprecated).
258
+ // Order: victory→gloriana→retribution, gloriana→victory, retribution→victory.
259
+ if (!restoredModel && !switchedDriver) {
260
+ const DEGRADATION_CASCADE: Record<string, ModelTier[]> = {
261
+ victory: ["gloriana", "retribution"],
262
+ gloriana: ["victory"],
263
+ retribution: ["victory"],
264
+ local: [],
265
+ };
266
+ const fallbacks = DEGRADATION_CASCADE[state.driver] ?? [];
267
+ for (const fallbackTier of fallbacks) {
268
+ switchedDriver = await switchDriverModel(pi, ctx, fallbackTier);
269
+ if (switchedDriver) break;
270
+ }
271
+ }
272
+
251
273
  const retainedModel = !restoredModel && !switchedDriver && ctx.model ? ctx.model : null;
252
274
 
253
275
  // Set thinking level, respecting candidate ceilings when the effort-driven
@@ -259,8 +281,11 @@ export default function (pi: ExtensionAPI) {
259
281
  : state.thinking;
260
282
  pi.setThinkingLevel(effectiveThinking as any);
261
283
 
262
- // Notify operator
284
+ // Notify operator — suppress the "no model" warning during first-run
285
+ // (bootstrap handles consolidated guidance), but always show when a model
286
+ // is resolved so the operator knows what's driving their session.
263
287
  const icon = TIER_ICONS[state.level];
288
+ const hasModel = !!(restoredModel || switchedDriver || retainedModel);
264
289
  const modelNote = restoredModel
265
290
  ? ` → restored ${restoredModel.provider}/${restoredModel.id}`
266
291
  : switchedDriver
@@ -268,10 +293,112 @@ export default function (pi: ExtensionAPI) {
268
293
  : retainedModel
269
294
  ? ` → kept ${retainedModel.provider}/${retainedModel.id} (preferred ${state.driver} unavailable)`
270
295
  : " (driver model unavailable)";
271
- ctx.ui.notify(
272
- `${icon} Effort: ${state.name} (${state.driver}/${effectiveThinking})${modelNote}`,
273
- restoredModel || switchedDriver || retainedModel ? "info" : "warning",
274
- );
296
+ if (hasModel || !sharedState.bootstrapPending) {
297
+ ctx.ui.notify(
298
+ `${icon} Effort: ${state.name} (${state.driver}/${effectiveThinking})${modelNote}`,
299
+ hasModel ? "info" : "warning",
300
+ );
301
+ }
302
+
303
+ // Provider summary — show what tiers are available
304
+ try {
305
+ const allModels = ctx.modelRegistry.getAll() as unknown as RegistryModel[];
306
+ const viable = getViableModels(ctx.modelRegistry);
307
+ const policy = sharedState.routingPolicy ?? getDefaultPolicy();
308
+ const summary = buildProviderSummary(allModels, viable, policy);
309
+
310
+ if (summary.level === 0 && !sharedState.bootstrapPending) {
311
+ ctx.ui.notify("⚠ No providers configured. Run /bootstrap or /providers for setup hints.", "warning");
312
+ } else if (summary.level < 3) {
313
+ const parts: string[] = [];
314
+ for (const t of summary.tiers) {
315
+ const icon = t.status === "operational" ? "●" : t.status === "degraded" ? "◐" : "○";
316
+ const detail = t.topCandidate ? ` ${t.topCandidate.provider}/${t.topCandidate.modelId}` : "";
317
+ parts.push(`${icon} ${getTierDisplayLabel(t.tier)}${detail}`);
318
+ }
319
+ ctx.ui.notify(`Routing: ${parts.join(" ")}`, "info");
320
+ }
321
+ // level 3 (all operational) = silent — no need to clutter startup
322
+ } catch {
323
+ // Non-critical — don't break startup
324
+ }
325
+ });
326
+
327
+ // ── /providers command ──
328
+
329
+ pi.registerCommand("providers", {
330
+ description: "Show provider auth status and tier routing summary",
331
+ handler: async (_args, ctx) => {
332
+ const allModels = ctx.modelRegistry.getAll() as unknown as RegistryModel[];
333
+ const viable = getViableModels(ctx.modelRegistry);
334
+ const policy = sharedState.routingPolicy ?? getDefaultPolicy();
335
+ const summary = buildProviderSummary(allModels, viable, policy);
336
+
337
+ const lines: string[] = [];
338
+
339
+ // Auth status
340
+ if (summary.authProviders.length > 0) {
341
+ lines.push(`**Auth configured:** ${summary.authProviders.join(", ")}`);
342
+ }
343
+ if (summary.unauthProviders.length > 0) {
344
+ const top = summary.unauthProviders.slice(0, 8);
345
+ const more = summary.unauthProviders.length > 8 ? ` (+${summary.unauthProviders.length - 8} more)` : "";
346
+ lines.push(`**No auth:** ${top.join(", ")}${more}`);
347
+ }
348
+ lines.push("");
349
+
350
+ // Tier table
351
+ lines.push("| Tier | Status | Provider | Model |");
352
+ lines.push("|------|--------|----------|-------|");
353
+ for (const t of summary.tiers) {
354
+ const icon = t.status === "operational" ? "●" : t.status === "degraded" ? "◐" : "○";
355
+ const status = `${icon} ${t.status}`;
356
+ const provider = t.topCandidate?.provider ?? "—";
357
+ const model = t.topCandidate?.modelId ?? "—";
358
+ lines.push(`| ${getTierDisplayLabel(t.tier)} | ${status} | ${provider} | ${model} |`);
359
+ }
360
+ lines.push("");
361
+
362
+ // Candidate detail
363
+ for (const t of summary.tiers) {
364
+ if (t.candidateCount > 1) {
365
+ const matches = matchTierUniversalExport(viable, t.tier);
366
+ const candidateList = matches.slice(0, 5).map((m) => `${m.model.provider}/${m.model.id}`).join(", ");
367
+ const more = matches.length > 5 ? ` (+${matches.length - 5} more)` : "";
368
+ lines.push(`**${getTierDisplayLabel(t.tier)} candidates:** ${candidateList}${more}`);
369
+ }
370
+ }
371
+
372
+ // Remediation hints for unconfigured providers that could improve coverage
373
+ const impairedTiers = summary.tiers.filter(t => t.status === "unavailable" || t.status === "degraded");
374
+ if (impairedTiers.length > 0 && summary.unauthProviders.length > 0) {
375
+ const hintLines: string[] = [];
376
+ const shown = new Set<string>();
377
+ for (const provider of summary.unauthProviders) {
378
+ if (shown.size >= 5) break;
379
+ const hint = getProviderRemediationHint(provider);
380
+ if (hint && !shown.has(provider)) {
381
+ shown.add(provider);
382
+ const entry = PROVIDER_ENV_VARS[provider];
383
+ const desc = entry?.description ?? provider;
384
+ hintLines.push(` ${provider} (${desc}): ${hint}`);
385
+ }
386
+ }
387
+ if (hintLines.length > 0) {
388
+ lines.push("**To configure providers:**");
389
+ lines.push(...hintLines);
390
+ lines.push("");
391
+ }
392
+ }
393
+
394
+ lines.push(`**Headline:** ${summary.headline}`);
395
+
396
+ pi.sendMessage({
397
+ customType: "provider-summary",
398
+ content: lines.join("\n"),
399
+ display: true,
400
+ });
401
+ },
275
402
  });
276
403
 
277
404
  // ── /effort command ──