@kodrunhq/opencode-autopilot 0.1.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -12,13 +12,13 @@ import {
12
12
  import { fallbackDefaults } from "./orchestrator/fallback/fallback-config";
13
13
  import { resolveChain } from "./orchestrator/fallback/resolve-chain";
14
14
  import { ocConfidence } from "./tools/confidence";
15
+ import { ocConfigure, setAvailableProviders, setOpenCodeConfig } from "./tools/configure";
15
16
  import { ocCreateAgent } from "./tools/create-agent";
16
17
  import { ocCreateCommand } from "./tools/create-command";
17
18
  import { ocCreateSkill } from "./tools/create-skill";
18
19
  import { ocForensics } from "./tools/forensics";
19
20
  import { ocOrchestrate } from "./tools/orchestrate";
20
21
  import { ocPhase } from "./tools/phase";
21
- import { ocPlaceholder } from "./tools/placeholder";
22
22
  import { ocPlan } from "./tools/plan";
23
23
  import { ocReview } from "./tools/review";
24
24
  import { ocState } from "./tools/state";
@@ -34,6 +34,19 @@ const plugin: Plugin = async (input) => {
34
34
  console.error("[opencode-autopilot] Asset installation errors:", installResult.errors);
35
35
  }
36
36
 
37
+ // Discover available providers/models from OpenCode SDK
38
+ try {
39
+ const providerResponse = await client.provider.list({
40
+ query: { directory: process.cwd() },
41
+ });
42
+ const providerData = providerResponse.data;
43
+ if (providerData?.all) {
44
+ setAvailableProviders(providerData.all);
45
+ }
46
+ } catch {
47
+ // Provider discovery is best-effort; configure will show empty models
48
+ }
49
+
37
50
  // Load config for first-load detection and fallback settings
38
51
  const config = await loadConfig();
39
52
  const fallbackConfig = config?.fallback ?? fallbackDefaults;
@@ -77,7 +90,9 @@ const plugin: Plugin = async (input) => {
77
90
  const agentConfigs = openCodeConfig?.agent as
78
91
  | Record<string, Record<string, unknown>>
79
92
  | undefined;
80
- return resolveChain(agentName ?? "", agentConfigs, config?.fallback_models);
93
+ // Per-agent fallback_models are populated by configHook from group/override config.
94
+ // resolveChain reads config.agent[agentName].fallback_models (tier 1).
95
+ return resolveChain(agentName ?? "", agentConfigs, undefined);
81
96
  },
82
97
  });
83
98
 
@@ -91,7 +106,7 @@ const plugin: Plugin = async (input) => {
91
106
 
92
107
  return {
93
108
  tool: {
94
- oc_placeholder: ocPlaceholder,
109
+ oc_configure: ocConfigure,
95
110
  oc_create_agent: ocCreateAgent,
96
111
  oc_create_skill: ocCreateSkill,
97
112
  oc_create_command: ocCreateCommand,
@@ -105,8 +120,11 @@ const plugin: Plugin = async (input) => {
105
120
  },
106
121
  event: async ({ event }) => {
107
122
  if (event.type === "session.created" && isFirstLoad(config)) {
108
- // First load: config wizard will be triggered via /configure command
109
- // Phase 2 will add the oc_configure tool
123
+ await sdkOps.showToast(
124
+ "Welcome to OpenCode Autopilot!",
125
+ "Run /oc-configure to set up your model assignments for each agent group.",
126
+ "info",
127
+ );
110
128
  }
111
129
 
112
130
  // Fallback event handling (runs for all events)
@@ -116,6 +134,7 @@ const plugin: Plugin = async (input) => {
116
134
  },
117
135
  config: async (cfg: Config) => {
118
136
  openCodeConfig = cfg;
137
+ setOpenCodeConfig(cfg);
119
138
  await configHook(cfg);
120
139
  },
121
140
  "chat.message": async (
package/src/installer.ts CHANGED
@@ -1,8 +1,14 @@
1
- import { readdir } from "node:fs/promises";
1
+ import { readdir, unlink } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { copyIfMissing, isEnoentError } from "./utils/fs-helpers";
4
4
  import { getAssetsDir, getGlobalConfigDir } from "./utils/paths";
5
5
 
6
+ /**
7
+ * Assets that were previously shipped but have since been removed from the source repo.
8
+ * These are cleaned up from the target directory on every install to avoid stale files.
9
+ */
10
+ const DEPRECATED_ASSETS = ["agents/placeholder-agent.md", "commands/configure.md"] as const;
11
+
6
12
  export interface InstallResult {
7
13
  readonly copied: readonly string[];
8
14
  readonly skipped: readonly string[];
@@ -112,10 +118,33 @@ async function processSkills(sourceDir: string, targetDir: string): Promise<Inst
112
118
  return { copied, skipped, errors };
113
119
  }
114
120
 
121
+ async function cleanupDeprecatedAssets(
122
+ targetDir: string,
123
+ ): Promise<{ readonly removed: readonly string[]; readonly errors: readonly string[] }> {
124
+ const removed: string[] = [];
125
+ const errors: string[] = [];
126
+ for (const asset of DEPRECATED_ASSETS) {
127
+ try {
128
+ await unlink(join(targetDir, asset));
129
+ removed.push(asset);
130
+ } catch (error: unknown) {
131
+ if (!isEnoentError(error)) {
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ errors.push(`cleanup ${asset}: ${message}`);
134
+ }
135
+ // ENOENT is expected — asset already gone
136
+ }
137
+ }
138
+ return { removed, errors };
139
+ }
140
+
115
141
  export async function installAssets(
116
142
  assetsDir: string = getAssetsDir(),
117
143
  targetDir: string = getGlobalConfigDir(),
118
144
  ): Promise<InstallResult> {
145
+ // Remove deprecated assets before copying new ones
146
+ const cleanup = await cleanupDeprecatedAssets(targetDir);
147
+
119
148
  const [agents, commands, skills] = await Promise.all([
120
149
  processFiles(assetsDir, targetDir, "agents"),
121
150
  processFiles(assetsDir, targetDir, "commands"),
@@ -125,6 +154,6 @@ export async function installAssets(
125
154
  return {
126
155
  copied: [...agents.copied, ...commands.copied, ...skills.copied],
127
156
  skipped: [...agents.skipped, ...commands.skipped, ...skills.skipped],
128
- errors: [...agents.errors, ...commands.errors, ...skills.errors],
157
+ errors: [...cleanup.errors, ...agents.errors, ...commands.errors, ...skills.errors],
129
158
  };
130
159
  }
@@ -0,0 +1,78 @@
1
+ import { DIVERSITY_RULES } from "./model-groups";
2
+ import { extractFamily } from "./resolver";
3
+ import type { DiversityWarning, GroupId, GroupModelAssignment } from "./types";
4
+
5
+ /**
6
+ * Check all diversity rules against current group assignments.
7
+ * Returns warnings for adversarial pairs that share the same model family.
8
+ *
9
+ * Only checks groups that are assigned — unassigned groups are skipped
10
+ * (no warning for missing assignments; that's the config validator's job).
11
+ */
12
+ export function checkDiversity(
13
+ groups: Readonly<Record<string, GroupModelAssignment>>,
14
+ ): readonly DiversityWarning[] {
15
+ const warnings: DiversityWarning[] = [];
16
+
17
+ for (const rule of DIVERSITY_RULES) {
18
+ // Extract families for all assigned groups in this rule
19
+ const families = new Map<GroupId, string>();
20
+ for (const groupId of rule.groups) {
21
+ const assignment = groups[groupId];
22
+ if (assignment) {
23
+ families.set(groupId, extractFamily(assignment.primary));
24
+ }
25
+ }
26
+
27
+ // All groups in the rule must be assigned to check diversity
28
+ if (families.size < rule.groups.length) continue;
29
+
30
+ // For 2-group rules: warn if both use same family
31
+ // For 3-group rules (red-team + builders + reviewers):
32
+ // warn if red-team shares family with ANY of the others
33
+ if (rule.groups.length === 2) {
34
+ const [familyA, familyB] = [...families.values()];
35
+ if (familyA === familyB) {
36
+ warnings.push({
37
+ rule,
38
+ sharedFamily: familyA,
39
+ groups: [...families.keys()],
40
+ });
41
+ }
42
+ } else if (rule.groups.includes("red-team" as GroupId)) {
43
+ // 3-group rule with red-team: only compare red-team against the others
44
+ // (builders↔reviewers is already covered by its own 2-group strong rule)
45
+ const redTeamFamily = families.get("red-team" as GroupId);
46
+ if (redTeamFamily) {
47
+ const conflicting = rule.groups.filter(
48
+ (g) => g !== ("red-team" as GroupId) && families.get(g) === redTeamFamily,
49
+ );
50
+ if (conflicting.length > 0) {
51
+ warnings.push({
52
+ rule,
53
+ sharedFamily: redTeamFamily,
54
+ groups: ["red-team" as GroupId, ...conflicting],
55
+ });
56
+ }
57
+ }
58
+ } else {
59
+ // Generic multi-group rule: check if any pair shares a family
60
+ const entries = [...families.entries()];
61
+ const seen = new Set<string>();
62
+ for (const [, family] of entries) {
63
+ if (seen.has(family)) {
64
+ const sharing = entries.filter(([, f]) => f === family).map(([g]) => g);
65
+ warnings.push({
66
+ rule,
67
+ sharedFamily: family,
68
+ groups: sharing,
69
+ });
70
+ break;
71
+ }
72
+ seen.add(family);
73
+ }
74
+ }
75
+ }
76
+
77
+ return Object.freeze(warnings);
78
+ }
@@ -0,0 +1,84 @@
1
+ import { checkDiversity } from "./diversity";
2
+ import { ALL_GROUP_IDS } from "./model-groups";
3
+ import type { DiversityWarning, GroupModelAssignment } from "./types";
4
+
5
+ /**
6
+ * Plugin config shape expected by diagnose().
7
+ * Minimal subset to avoid coupling to the full PluginConfig Zod schema.
8
+ */
9
+ export interface DiagnosableConfig {
10
+ readonly configured: boolean;
11
+ readonly version: number;
12
+ readonly groups: Readonly<Record<string, GroupModelAssignment>>;
13
+ }
14
+
15
+ /**
16
+ * Structured result of a diagnostic check, suitable for both
17
+ * CLI terminal formatting and JSON serialization by the tool.
18
+ */
19
+ export interface DiagnosticResult {
20
+ readonly configExists: boolean;
21
+ readonly schemaValid: boolean;
22
+ readonly configured: boolean;
23
+ readonly groupsAssigned: Readonly<
24
+ Record<
25
+ string,
26
+ {
27
+ readonly assigned: boolean;
28
+ readonly primary: string | null;
29
+ readonly fallbacks: readonly string[];
30
+ }
31
+ >
32
+ >;
33
+ readonly diversityWarnings: readonly DiversityWarning[];
34
+ readonly allPassed: boolean;
35
+ }
36
+
37
+ /**
38
+ * Pure diagnostic function — inspects a config (or null) and returns
39
+ * a structured result. No side effects, no I/O.
40
+ *
41
+ * Both `bin/cli.ts:runDoctor()` and `src/tools/configure.ts:handleDoctor()`
42
+ * call this and format the result differently (terminal vs JSON).
43
+ */
44
+ export function diagnose(config: DiagnosableConfig | null): DiagnosticResult {
45
+ const configExists = config !== null;
46
+ // Always true when configExists — loadConfig validates through Zod on load
47
+ const schemaValid = configExists;
48
+ const configured = config?.configured ?? false;
49
+
50
+ const groupsAssigned: Record<
51
+ string,
52
+ { assigned: boolean; primary: string | null; fallbacks: readonly string[] }
53
+ > = {};
54
+
55
+ for (const groupId of ALL_GROUP_IDS) {
56
+ const assignment = config?.groups[groupId];
57
+ groupsAssigned[groupId] = assignment
58
+ ? { assigned: true, primary: assignment.primary, fallbacks: assignment.fallbacks }
59
+ : { assigned: false, primary: null, fallbacks: [] };
60
+ }
61
+
62
+ // Diversity check on assigned groups
63
+ const assignedGroups: Record<string, GroupModelAssignment> = {};
64
+ if (config) {
65
+ for (const [key, val] of Object.entries(config.groups)) {
66
+ assignedGroups[key] = val;
67
+ }
68
+ }
69
+
70
+ const diversityWarnings = checkDiversity(assignedGroups);
71
+
72
+ const allGroupsAssigned = ALL_GROUP_IDS.every((id) => groupsAssigned[id]?.assigned);
73
+ // diversityWarnings are advisory — they don't affect allPassed
74
+ const allPassed = configExists && schemaValid && configured && allGroupsAssigned;
75
+
76
+ return Object.freeze({
77
+ configExists,
78
+ schemaValid,
79
+ configured,
80
+ groupsAssigned: Object.freeze(groupsAssigned),
81
+ diversityWarnings,
82
+ allPassed,
83
+ });
84
+ }
@@ -0,0 +1,163 @@
1
+ import type { AgentEntry, DiversityRule, GroupDefinition, GroupId } from "./types";
2
+
3
+ function deepFreeze<T extends object>(obj: T): Readonly<T> {
4
+ for (const value of Object.values(obj)) {
5
+ if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
6
+ deepFreeze(value as object);
7
+ }
8
+ }
9
+ return Object.freeze(obj);
10
+ }
11
+
12
+ export const AGENT_REGISTRY: Readonly<Record<string, AgentEntry>> = deepFreeze({
13
+ // ── Architects ─────────────────────────────────────────────
14
+ // Deep reasoning: system design, task decomposition, orchestration
15
+ "oc-architect": { group: "architects" },
16
+ "oc-planner": { group: "architects" },
17
+ autopilot: { group: "architects" },
18
+
19
+ // ── Challengers ────────────────────────────────────────────
20
+ // Adversarial to Architects: critique proposals, enhance ideas
21
+ "oc-critic": { group: "challengers" },
22
+ "oc-challenger": { group: "challengers" },
23
+
24
+ // ── Builders ───────────────────────────────────────────────
25
+ // Code generation
26
+ "oc-implementer": { group: "builders" },
27
+
28
+ // ── Reviewers ──────────────────────────────────────────────
29
+ // Code analysis, adversarial to Builders
30
+ // NOTE: The 21 internal ReviewAgent objects (logic-auditor, security-auditor,
31
+ // etc.) are NOT in this registry. They use the ReviewAgent type from
32
+ // src/review/types.ts, not AgentConfig. The review pipeline resolves their
33
+ // model via resolveModelForGroup("reviewers") directly.
34
+ "oc-reviewer": { group: "reviewers" },
35
+
36
+ // ── Red Team ───────────────────────────────────────────────
37
+ // Final adversarial pass
38
+ // NOTE: red-team and product-thinker are ALSO internal ReviewAgent objects
39
+ // in STAGE3_AGENTS (src/review/agents/index.ts). They appear here so the
40
+ // review pipeline can resolve their model via resolveModelForGroup("red-team")
41
+ // separately from the "reviewers" group.
42
+ "red-team": { group: "red-team" },
43
+ "product-thinker": { group: "red-team" },
44
+
45
+ // ── Researchers ────────────────────────────────────────────
46
+ // Domain research, feasibility analysis
47
+ "oc-researcher": { group: "researchers" },
48
+ researcher: { group: "researchers" },
49
+
50
+ // ── Communicators ──────────────────────────────────────────
51
+ // Docs, changelogs, lesson extraction
52
+ "oc-shipper": { group: "communicators" },
53
+ documenter: { group: "communicators" },
54
+ "oc-retrospector": { group: "communicators" },
55
+
56
+ // ── Utilities ──────────────────────────────────────────────
57
+ // Fast lookups, prompt tuning, PR scanning
58
+ "oc-explorer": { group: "utilities" },
59
+ metaprompter: { group: "utilities" },
60
+ "pr-reviewer": { group: "utilities" },
61
+ });
62
+
63
+ export const GROUP_DEFINITIONS: Readonly<Record<GroupId, GroupDefinition>> = deepFreeze({
64
+ architects: {
65
+ id: "architects",
66
+ label: "Architects",
67
+ purpose: "System design, task decomposition, pipeline orchestration",
68
+ recommendation:
69
+ "Most powerful model available. Bad architecture cascades into everything downstream.",
70
+ tier: "heavy",
71
+ order: 1,
72
+ },
73
+ challengers: {
74
+ id: "challengers",
75
+ label: "Challengers",
76
+ purpose: "Challenge architecture proposals, enhance ideas, find design flaws",
77
+ recommendation:
78
+ "Strong model, different family from Architects for genuine adversarial review.",
79
+ tier: "heavy",
80
+ order: 2,
81
+ },
82
+ builders: {
83
+ id: "builders",
84
+ label: "Builders",
85
+ purpose: "Write production code",
86
+ recommendation: "Strong coding model. This is where most tokens are spent.",
87
+ tier: "heavy",
88
+ order: 3,
89
+ },
90
+ reviewers: {
91
+ id: "reviewers",
92
+ label: "Reviewers",
93
+ purpose: "Find bugs, security issues, logic errors in code",
94
+ recommendation:
95
+ "Strong model, different family from Builders to catch different classes of bugs.",
96
+ tier: "heavy",
97
+ order: 4,
98
+ },
99
+ "red-team": {
100
+ id: "red-team",
101
+ label: "Red Team",
102
+ purpose: "Final adversarial pass — hunt exploits, find UX gaps",
103
+ recommendation: "Different family from both Builders and Reviewers for a third perspective.",
104
+ tier: "heavy",
105
+ order: 5,
106
+ },
107
+ researchers: {
108
+ id: "researchers",
109
+ label: "Researchers",
110
+ purpose: "Domain research, feasibility analysis, information gathering",
111
+ recommendation: "Good context window and comprehension. Any model family works.",
112
+ tier: "medium",
113
+ order: 6,
114
+ },
115
+ communicators: {
116
+ id: "communicators",
117
+ label: "Communicators",
118
+ purpose: "Write docs, changelogs, extract lessons",
119
+ recommendation: "Mid-tier model. Clear writing matters more than deep reasoning.",
120
+ tier: "light",
121
+ order: 7,
122
+ },
123
+ utilities: {
124
+ id: "utilities",
125
+ label: "Utilities",
126
+ purpose: "Fast lookups, prompt tuning, PR scanning",
127
+ recommendation:
128
+ "Fastest available model. Speed over intelligence — don't waste expensive tokens on grep.",
129
+ tier: "light",
130
+ order: 8,
131
+ },
132
+ });
133
+
134
+ /**
135
+ * All valid group IDs, derived from GROUP_DEFINITIONS and sorted by order.
136
+ * Adding a new group to GROUP_DEFINITIONS auto-includes it here.
137
+ */
138
+ export const ALL_GROUP_IDS: readonly GroupId[] = Object.freeze(
139
+ [...(Object.values(GROUP_DEFINITIONS) as GroupDefinition[])]
140
+ .sort((a: GroupDefinition, b: GroupDefinition) => a.order - b.order)
141
+ .map((d: GroupDefinition) => d.id),
142
+ );
143
+
144
+ export const DIVERSITY_RULES: readonly DiversityRule[] = Object.freeze([
145
+ Object.freeze({
146
+ groups: Object.freeze(["architects", "challengers"] as const),
147
+ severity: "strong" as const,
148
+ reason:
149
+ "Challengers critique architect output. Same-model review creates confirmation bias — the model agrees with its own reasoning patterns.",
150
+ }),
151
+ Object.freeze({
152
+ groups: Object.freeze(["builders", "reviewers"] as const),
153
+ severity: "strong" as const,
154
+ reason:
155
+ "Reviewers find bugs in builder code. Same model shares the same blind spots — it won't catch errors it would also make.",
156
+ }),
157
+ Object.freeze({
158
+ groups: Object.freeze(["red-team", "builders", "reviewers"] as const),
159
+ severity: "soft" as const,
160
+ reason:
161
+ "Red Team is most effective as a third perspective. If you only have 2 model families, use whichever isn't assigned to Reviewers.",
162
+ }),
163
+ ]);
@@ -0,0 +1,74 @@
1
+ import { AGENT_REGISTRY } from "./model-groups";
2
+ import type { AgentOverride, GroupId, GroupModelAssignment, ResolvedModel } from "./types";
3
+
4
+ /**
5
+ * Extract model family (provider) from a model string.
6
+ * "anthropic/claude-opus-4-6" → "anthropic"
7
+ * "openai/gpt-5.4" → "openai"
8
+ * Returns the full string if no "/" is found.
9
+ */
10
+ export function extractFamily(model: string): string {
11
+ const idx = model.indexOf("/");
12
+ return idx === -1 ? model : model.slice(0, idx);
13
+ }
14
+
15
+ /**
16
+ * Resolve model for a named agent.
17
+ *
18
+ * Resolution order:
19
+ * 1. Per-agent override in overrides[agentName]
20
+ * 2. Agent's group in AGENT_REGISTRY → groups[groupId]
21
+ * 3. null (agent uses OpenCode's default model)
22
+ */
23
+ export function resolveModelForAgent(
24
+ agentName: string,
25
+ groups: Readonly<Record<string, GroupModelAssignment>>,
26
+ overrides: Readonly<Record<string, AgentOverride>>,
27
+ ): ResolvedModel | null {
28
+ // Tier 1: per-agent override
29
+ const override = overrides[agentName];
30
+ if (override) {
31
+ return {
32
+ primary: override.primary,
33
+ fallbacks: override.fallbacks ?? [],
34
+ source: "override",
35
+ };
36
+ }
37
+
38
+ // Tier 2: group assignment
39
+ const entry = AGENT_REGISTRY[agentName];
40
+ if (entry) {
41
+ const group = groups[entry.group];
42
+ if (group) {
43
+ return {
44
+ primary: group.primary,
45
+ fallbacks: group.fallbacks,
46
+ source: "group",
47
+ };
48
+ }
49
+ }
50
+
51
+ // Tier 3: no assignment
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Resolve model for a group directly (used by review pipeline for
57
+ * internal ReviewAgent objects that are not in AGENT_REGISTRY).
58
+ *
59
+ * Used by:
60
+ * - Review pipeline for the 19 universal+specialized agents → "reviewers"
61
+ * - Review pipeline for red-team + product-thinker → "red-team"
62
+ */
63
+ export function resolveModelForGroup(
64
+ groupId: GroupId,
65
+ groups: Readonly<Record<string, GroupModelAssignment>>,
66
+ ): ResolvedModel | null {
67
+ const group = groups[groupId];
68
+ if (!group) return null;
69
+ return {
70
+ primary: group.primary,
71
+ fallbacks: group.fallbacks,
72
+ source: "group",
73
+ };
74
+ }
@@ -0,0 +1,91 @@
1
+ // src/registry/types.ts
2
+
3
+ /**
4
+ * The 8 model groups. Used as keys in config.groups and GROUP_DEFINITIONS.
5
+ */
6
+ export type GroupId =
7
+ | "architects"
8
+ | "challengers"
9
+ | "builders"
10
+ | "reviewers"
11
+ | "red-team"
12
+ | "researchers"
13
+ | "communicators"
14
+ | "utilities";
15
+
16
+ /**
17
+ * Model tier hint — used for display ordering and recommendations.
18
+ * Does not affect resolution logic.
19
+ */
20
+ export type ModelTier = "heavy" | "medium" | "light";
21
+
22
+ /**
23
+ * Diversity warning severity.
24
+ * "strong" — displayed with warning icon, explicitly recommended to change.
25
+ * "soft" — displayed as suggestion, not a strong recommendation.
26
+ */
27
+ export type DiversitySeverity = "strong" | "soft";
28
+
29
+ /**
30
+ * Entry in the agent registry. Maps an agent name to its group.
31
+ */
32
+ export interface AgentEntry {
33
+ readonly group: GroupId;
34
+ }
35
+
36
+ /**
37
+ * Metadata for a model group. Pure display/recommendation data.
38
+ */
39
+ export interface GroupDefinition {
40
+ readonly id: GroupId;
41
+ readonly label: string;
42
+ readonly purpose: string;
43
+ readonly recommendation: string;
44
+ readonly tier: ModelTier;
45
+ readonly order: number;
46
+ }
47
+
48
+ /**
49
+ * Adversarial diversity rule. Declares which groups should use
50
+ * different model families for quality benefits.
51
+ */
52
+ export interface DiversityRule {
53
+ readonly groups: readonly GroupId[];
54
+ readonly severity: DiversitySeverity;
55
+ readonly reason: string;
56
+ }
57
+
58
+ /**
59
+ * A model assignment for a group (stored in config).
60
+ */
61
+ export interface GroupModelAssignment {
62
+ readonly primary: string;
63
+ readonly fallbacks: readonly string[];
64
+ }
65
+
66
+ /**
67
+ * A per-agent override (stored in config.overrides).
68
+ */
69
+ export interface AgentOverride {
70
+ readonly primary: string;
71
+ readonly fallbacks?: readonly string[];
72
+ }
73
+
74
+ /**
75
+ * Resolved model for an agent — returned by the resolver.
76
+ * `null` means no assignment found; agent uses OpenCode's default.
77
+ */
78
+ export interface ResolvedModel {
79
+ readonly primary: string;
80
+ readonly fallbacks: readonly string[];
81
+ readonly source: "override" | "group" | "default";
82
+ }
83
+
84
+ /**
85
+ * Diversity warning emitted by checkDiversity().
86
+ */
87
+ export interface DiversityWarning {
88
+ readonly rule: DiversityRule;
89
+ readonly sharedFamily: string;
90
+ readonly groups: readonly GroupId[];
91
+ }