@fro.bot/systematic 2.12.3 → 2.13.1

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/README.md CHANGED
@@ -326,7 +326,7 @@ Configuration is loaded from multiple locations and merged (later sources overri
326
326
  | `bootstrap.enabled` | `boolean` | `true` | Inject the `using-systematic` guide into system prompts |
327
327
  | `bootstrap.file` | `string` | — | Custom bootstrap file path (overrides default) |
328
328
 
329
- Agent overlays support `model`, `variant`, `temperature`, `top_p`, `permission`, `mode`, `color`, `steps`, `hidden`, exact-agent-only `disable`, and managed `skills`. `color` accepts `#RGB`, `#RRGGBB`, or OpenCode named color tokens matching `[a-zA-Z][a-zA-Z0-9-]*`; whitespace/freeform numeric strings are rejected. `skills` uses bundled skill frontmatter names like `ce:review`; it is a shortcut that writes OpenCode `permission.skill` rules, not a native OpenCode agent field. Because `model` and `variant` control provider routing/cost/privacy and `permission`/`skills` control tool access, those fields are only accepted from user config or `$OPENCODE_CONFIG_DIR/systematic.json`. Project config may tune non-sensitive presentation and runtime fields such as `temperature`, `top_p`, `mode`, `color`, `steps`, `hidden`, or exact-agent `disable`, but it cannot choose model/provider routing, tune `variant`, or loosen permission/capability policy.
329
+ Agent overlays support `model`, `variant`, `temperature`, `top_p`, `permission`, `mode`, `color`, `steps`, `hidden`, exact-agent-only `disable`, and managed `skills`. `color` accepts `#RRGGBB` hex colors or OpenCode theme tokens: `primary`, `secondary`, `accent`, `success`, `warning`, `error`, and `info`. `skills` uses bundled skill frontmatter names like `ce:review`; it is a shortcut that writes OpenCode `permission.skill` rules, not a native OpenCode agent field. Because `model` and `variant` control provider routing/cost/privacy and `permission`/`skills` control tool access, those fields are only accepted from user config or `$OPENCODE_CONFIG_DIR/systematic.json`. Project config may tune non-sensitive presentation and runtime fields such as `temperature`, `top_p`, `mode`, `color`, `steps`, `hidden`, or exact-agent `disable`, but it cannot choose model/provider routing, tune `variant`, or loosen permission/capability policy.
330
330
 
331
331
  Systematic separates config-source precedence from overlay precedence. Config files merge in this order: user config, project config, then `$OPENCODE_CONFIG_DIR/systematic.json` if set. Higher-priority `agents.<key>` and `categories.<id>` entries replace lower-priority entries wholesale, while unrelated keys survive. Project overlays are the exception for trust-sensitive fields: same-key project overlays preserve user-level `model`, `variant`, `permission`, and `skills` fields instead of erasing them. After the effective config is built, Systematic applies agent overlay precedence for bundled agents:
332
332
 
@@ -338,20 +338,20 @@ Systematic separates config-source precedence from overlay precedence. Config fi
338
338
 
339
339
  Source category model defaults are primary model choices only — they are not fallback chains. Systematic does not support `fallback_models`, inherited retry semantics, runtime fallback behavior, or fallback to the parent model when a source model is unavailable. Explicit and source model IDs are structurally validated and may still fail at OpenCode runtime if the provider or model is unavailable.
340
340
 
341
- Source category model defaults are now ordered preference arrays per category rather than single strings. At plugin load, Systematic reads OpenCode's authentication state from `auth.json` and selects the first array entry whose provider is authenticated. For example, the `review` category defaults to `['anthropic/claude-sonnet-4-6', 'openai/gpt-5.3-codex']` a user authenticated only to OpenAI receives `openai/gpt-5.5` (first match), while a user authenticated to Anthropic (or both) receives the more preferred `anthropic/claude-sonnet-4-6`. If no array entry's provider is authenticated, the first entry is used as the default. The arrays are an ordered preference list, not a runtime fallback chain — `fallback_models` is still not supported.
341
+ Source category model defaults are ordered provider/model preference chains per category. At plugin load, Systematic asks OpenCode for connected providers through `client.config.providers()` and selects the first provider/model entry OpenCode reports as available. If the live provider query fails, Systematic falls back to OpenCode's `models.json` cache; if both sources fail, it skips source-default emission so bundled agents inherit the parent OpenCode model instead of pinning an unavailable model. The chains are ordered preference lists, not runtime fallback chains — `fallback_models` is still not supported.
342
342
 
343
343
  If you want to restore OpenCode parent-model inheritance for a bundled agent or category (opting out of the source default), set `"model": null` in high-trust user or `$OPENCODE_CONFIG_DIR/systematic.json` config. Project config cannot use `model: null` — project config cannot set, erase, or shadow `model` at any value.
344
344
 
345
345
  The source defaults are:
346
346
 
347
- | Category | Default `model` | Rationale |
348
- |----------|-----------------|-----------|
349
- | `design` | `github-copilot/gemini-3.1-pro-preview` | High-judgment UX/product/design work benefits from a strong general reasoning model. |
350
- | `docs` | `github-copilot/gemini-3.1-pro-preview` | Documentation and summarization should start cheaper/faster. |
351
- | `document-review` | `anthropic/claude-opus-4-7` | Requirements and plan critique benefit from strongest nuanced reasoning. |
352
- | `research` | `openai/gpt-5.4-mini` | Tool-heavy synthesis and source evaluation benefit from a strong general reasoning model. |
353
- | `review` | `anthropic/claude-sonnet-4-6` | Code/security/adversarial review benefits from strongest reasoning. |
354
- | `workflow` | `openai/gpt-5.4-mini` | Orchestration and bounded implementation should default cheaper/faster. |
347
+ | Category | Chain | Rationale | When to Override |
348
+ | --- | --- | --- | --- |
349
+ | `design` | `github-copilot/gemini-3.1-pro-preview`, `openai/gpt-5.5` (`high`), `anthropic/claude-opus-4-7` (`max`), … | High-judgment UX, product, and design work benefits from a strong general reasoning model with broad creative capability. | Override to a faster/cheaper model when design tasks are primarily templating or low-stakes layout work. |
350
+ | `docs` | `github-copilot/gemini-3.1-pro-preview`, `openai/gpt-5.4-mini`, `anthropic/claude-haiku-4-5`, … | Documentation and summarization tasks should start cheaper and faster; quality is sufficient at mid-tier models. | — |
351
+ | `document-review` | `anthropic/claude-opus-4-7` (`max`), `openai/gpt-5.5` (`high`), `github-copilot/gemini-3.1-pro-preview`, … | Requirements and plan critique benefit from the strongest nuanced reasoning to surface non-obvious gaps and contradictions. | — |
352
+ | `research` | `openai/gpt-5.4-mini`, `anthropic/claude-sonnet-4-6`, `github-copilot/gemini-3.1-pro-preview`, … | Tool-heavy synthesis and source evaluation benefit from a strong general reasoning model with broad knowledge. | — |
353
+ | `review` | `anthropic/claude-sonnet-4-6`, `openai/gpt-5.3-codex`, `github-copilot/gemini-3.1-pro-preview`, … | Code, security, and adversarial review benefits from the strongest reasoning to catch subtle bugs and security issues. | Override to a faster model when review tasks are primarily style or formatting checks rather than correctness or security analysis. |
354
+ | `workflow` | `openai/gpt-5.4-mini`, `anthropic/claude-sonnet-4-6`, `opencode/claude-haiku-4-5`, … | Orchestration and bounded implementation tasks should default cheaper and faster; strong reasoning is rarely needed for routing decisions. | — |
355
355
 
356
356
  These defaults are owned by Systematic code and emitted for bundled agents in each category when no stronger high-trust exact or category `model` override exists. Uncategorized bundled agents receive no source default and continue inheriting the parent OpenCode model. Native OpenCode agents with the same emitted key are full replacements and receive no Systematic source model default.
357
357
 
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  findCommandsInDir,
7
7
  findSkillsInDir,
8
8
  getConfigPaths
9
- } from "./index-gb1v2n3m.js";
9
+ } from "./index-4qhjf4tb.js";
10
10
 
11
11
  // src/cli.ts
12
12
  import fs from "fs";
@@ -12324,7 +12324,7 @@ function finalize(ctx, schema) {
12324
12324
  result.$schema = "http://json-schema.org/draft-07/schema#";
12325
12325
  } else if (ctx.target === "draft-04") {
12326
12326
  result.$schema = "http://json-schema.org/draft-04/schema#";
12327
- } else if (ctx.target === "openapi-3.0") {} else {}
12327
+ } else if (ctx.target === "openapi-3.0") {}
12328
12328
  if (ctx.external?.uri) {
12329
12329
  const id = ctx.external.registry.get(schema)?.id;
12330
12330
  if (!id)
@@ -12568,7 +12568,7 @@ var literalProcessor = (schema, ctx, json, _params) => {
12568
12568
  if (val === undefined) {
12569
12569
  if (ctx.unrepresentable === "throw") {
12570
12570
  throw new Error("Literal `undefined` cannot be represented in JSON Schema");
12571
- } else {}
12571
+ }
12572
12572
  } else if (typeof val === "bigint") {
12573
12573
  if (ctx.unrepresentable === "throw") {
12574
12574
  throw new Error("BigInt literals cannot be represented in JSON Schema");
@@ -15163,7 +15163,7 @@ var modelSchema = exports_external.string().min(1).regex(MODEL_FORMAT_REGEX, MOD
15163
15163
  description: "Model identifier in provider/model format, or null to inherit parent model",
15164
15164
  examples: ["anthropic/claude-sonnet-4", null]
15165
15165
  });
15166
- var variantSchema = exports_external.string().min(1).regex(/^\S+$/, "must be a non-empty string without whitespace").meta({
15166
+ var variantSchema = exports_external.string().min(1).max(128, "variant must be at most 128 characters").regex(/^\S+$/, "must be a non-empty string without whitespace").meta({
15167
15167
  description: "Model variant identifier",
15168
15168
  examples: ["v2", "extended"]
15169
15169
  });
@@ -15296,13 +15296,6 @@ var SystematicConfigSchema = exports_external.object({
15296
15296
  description: "Systematic user configuration file (systematic.json / systematic.jsonc)",
15297
15297
  examples: [{ disabled_skills: ["ce:plan"], bootstrap: { enabled: false } }]
15298
15298
  });
15299
- var SourceCategoryModelDefaultsSchema = exports_external.record(exports_external.string(), exports_external.array(exports_external.string().min(1).regex(MODEL_FORMAT_REGEX, MODEL_FORMAT_MESSAGE)).min(1)).meta({
15300
- description: "Validates source category model defaults shape",
15301
- examples: [{ design: ["openai/gpt-5.5", "anthropic/claude-opus-4-7"] }]
15302
- });
15303
- function assertSourceCategoryModelDefaults(defaults) {
15304
- SourceCategoryModelDefaultsSchema.parse(defaults);
15305
- }
15306
15299
  var SECURITY_OVERLAY_FIELDS = [
15307
15300
  "model",
15308
15301
  "variant",
@@ -16101,4 +16094,4 @@ function findSkillsInDir(dir, maxDepth = 3) {
16101
16094
  return skills;
16102
16095
  }
16103
16096
 
16104
- export { parseFrontmatter, AgentOverlaySchema, CategoryOverlaySchema, assertSourceCategoryModelDefaults, loadConfig, loadConfigWithSources, getConfigPaths, isRecord2 as isRecord, findAgentsInDir, extractAgentFrontmatter, findCommandsInDir, extractCommandFrontmatter, convertContent, convertFileWithCache, findSkillsInDir };
16097
+ export { parseFrontmatter, exports_external, AgentOverlaySchema, CategoryOverlaySchema, loadConfig, loadConfigWithSources, getConfigPaths, isRecord2 as isRecord, findAgentsInDir, extractAgentFrontmatter, findCommandsInDir, extractCommandFrontmatter, convertContent, convertFileWithCache, findSkillsInDir };
package/dist/index.js CHANGED
@@ -2,8 +2,8 @@
2
2
  import {
3
3
  AgentOverlaySchema,
4
4
  CategoryOverlaySchema,
5
- assertSourceCategoryModelDefaults,
6
5
  convertFileWithCache,
6
+ exports_external,
7
7
  extractAgentFrontmatter,
8
8
  extractCommandFrontmatter,
9
9
  findAgentsInDir,
@@ -13,12 +13,12 @@ import {
13
13
  loadConfig,
14
14
  loadConfigWithSources,
15
15
  parseFrontmatter
16
- } from "./index-gb1v2n3m.js";
16
+ } from "./index-4qhjf4tb.js";
17
17
 
18
18
  // src/index.ts
19
- import fs4 from "fs";
20
- import path5 from "path";
21
- import { fileURLToPath } from "url";
19
+ import fs5 from "fs";
20
+ import path7 from "path";
21
+ import { fileURLToPath as fileURLToPath2 } from "url";
22
22
 
23
23
  // src/lib/bootstrap.ts
24
24
  import fs from "fs";
@@ -107,38 +107,232 @@ ${toolMapping}
107
107
 
108
108
  // src/lib/agent-overlays.ts
109
109
  import fs2 from "fs";
110
- import os2 from "os";
111
- import path2 from "path";
110
+ import path3 from "path";
111
+
112
+ // src/lib/source-model-defaults.ts
113
+ import * as path2 from "path";
114
+ import { fileURLToPath } from "url";
115
+ var __filename2 = fileURLToPath(import.meta.url);
116
+ var __dirname2 = path2.dirname(__filename2);
117
+ var packageRoot = path2.resolve(__dirname2, "..", "..");
118
+ var bundledAgentsDir = path2.join(packageRoot, "agents");
119
+ var ProviderID = exports_external.union([
120
+ exports_external.literal("vercel"),
121
+ exports_external.literal("opencode"),
122
+ exports_external.literal("github-copilot"),
123
+ exports_external.literal("opencode-go"),
124
+ exports_external.literal("openai"),
125
+ exports_external.literal("anthropic"),
126
+ exports_external.literal("google")
127
+ ]);
128
+ var variantSchema = exports_external.string().min(1, "variant must be a non-empty string").max(128, "variant must be at most 128 characters").regex(/^\S+$/, "variant must not contain whitespace");
129
+ var tableCellSchema = exports_external.string().min(1, "must be a non-empty string").regex(/^[^|\r\n]+$/, "must not contain pipe (|) or newline characters \u2014 these break Markdown table generation");
130
+ var ModelEntrySchema = exports_external.object({
131
+ model: exports_external.string().min(1, "model must be a non-empty string"),
132
+ variant: variantSchema.optional()
133
+ }).strict();
134
+ var ProviderEntrySchema = exports_external.object({
135
+ provider: ProviderID,
136
+ models: exports_external.array(ModelEntrySchema).min(1, "models must be non-empty \u2014 every provider entry must list at least one model")
137
+ }).strict().refine((entry) => {
138
+ const seen = new Set;
139
+ for (const m of entry.models) {
140
+ const key = `${m.model}::${m.variant ?? ""}`;
141
+ if (seen.has(key))
142
+ return false;
143
+ seen.add(key);
144
+ }
145
+ return true;
146
+ }, {
147
+ message: "duplicate (model, variant) pair within a provider entry \u2014 each model+variant combination must be unique"
148
+ });
149
+ var CategoryDefaultSchema = exports_external.object({
150
+ rationale: tableCellSchema,
151
+ whenToOverride: tableCellSchema.optional(),
152
+ providers: exports_external.array(ProviderEntrySchema).min(1, "providers must be non-empty \u2014 every category must list at least one provider")
153
+ }).strict().refine((cat) => {
154
+ const seen = new Set;
155
+ for (const p of cat.providers) {
156
+ if (seen.has(p.provider))
157
+ return false;
158
+ seen.add(p.provider);
159
+ }
160
+ return true;
161
+ }, {
162
+ message: "duplicate provider ID within a category \u2014 each provider must appear at most once per category"
163
+ });
164
+ var SourceCategoryDefaultsSchema = exports_external.record(exports_external.string(), CategoryDefaultSchema);
112
165
  var SOURCE_CATEGORY_MODEL_DEFAULTS = {
113
- design: [
114
- "github-copilot/gemini-3.1-pro-preview",
115
- "openai/gpt-5.5",
116
- "anthropic/claude-opus-4-7"
117
- ],
118
- docs: [
119
- "github-copilot/gemini-3.1-pro-preview",
120
- "openai/gpt-5.4-mini",
121
- "anthropic/claude-haiku-4-5"
122
- ],
123
- "document-review": ["anthropic/claude-sonnet-4-6", "openai/gpt-5.4-mini"],
124
- research: ["openai/gpt-5.4-mini", "anthropic/claude-sonnet-4-6"],
125
- review: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.3-codex"],
126
- workflow: ["openai/gpt-5.4-mini", "anthropic/claude-sonnet-4-6"]
166
+ design: {
167
+ rationale: "High-judgment UX, product, and design work benefits from a strong general reasoning model with broad creative capability.",
168
+ whenToOverride: "Override to a faster/cheaper model when design tasks are primarily templating or low-stakes layout work.",
169
+ providers: [
170
+ {
171
+ provider: "github-copilot",
172
+ models: [{ model: "gemini-3.1-pro-preview" }]
173
+ },
174
+ {
175
+ provider: "openai",
176
+ models: [{ model: "gpt-5.5", variant: "high" }]
177
+ },
178
+ {
179
+ provider: "anthropic",
180
+ models: [{ model: "claude-opus-4-7", variant: "max" }]
181
+ },
182
+ {
183
+ provider: "vercel",
184
+ models: [{ model: "v0-1.5-md" }]
185
+ }
186
+ ]
187
+ },
188
+ docs: {
189
+ rationale: "Documentation and summarization tasks should start cheaper and faster; quality is sufficient at mid-tier models.",
190
+ providers: [
191
+ {
192
+ provider: "github-copilot",
193
+ models: [{ model: "gemini-3.1-pro-preview" }]
194
+ },
195
+ {
196
+ provider: "openai",
197
+ models: [{ model: "gpt-5.4-mini" }]
198
+ },
199
+ {
200
+ provider: "anthropic",
201
+ models: [{ model: "claude-haiku-4-5" }]
202
+ },
203
+ {
204
+ provider: "opencode",
205
+ models: [{ model: "claude-haiku-4-5" }]
206
+ }
207
+ ]
208
+ },
209
+ "document-review": {
210
+ rationale: "Requirements and plan critique benefit from the strongest nuanced reasoning to surface non-obvious gaps and contradictions.",
211
+ providers: [
212
+ {
213
+ provider: "anthropic",
214
+ models: [{ model: "claude-opus-4-7", variant: "max" }]
215
+ },
216
+ {
217
+ provider: "openai",
218
+ models: [{ model: "gpt-5.5", variant: "high" }]
219
+ },
220
+ {
221
+ provider: "github-copilot",
222
+ models: [{ model: "gemini-3.1-pro-preview" }]
223
+ },
224
+ {
225
+ provider: "opencode",
226
+ models: [{ model: "claude-sonnet-4-6" }]
227
+ }
228
+ ]
229
+ },
230
+ research: {
231
+ rationale: "Tool-heavy synthesis and source evaluation benefit from a strong general reasoning model with broad knowledge.",
232
+ providers: [
233
+ {
234
+ provider: "openai",
235
+ models: [{ model: "gpt-5.4-mini" }]
236
+ },
237
+ {
238
+ provider: "anthropic",
239
+ models: [{ model: "claude-sonnet-4-6" }]
240
+ },
241
+ {
242
+ provider: "github-copilot",
243
+ models: [{ model: "gemini-3.1-pro-preview" }]
244
+ },
245
+ {
246
+ provider: "opencode",
247
+ models: [{ model: "claude-sonnet-4-6" }]
248
+ }
249
+ ]
250
+ },
251
+ review: {
252
+ rationale: "Code, security, and adversarial review benefits from the strongest reasoning to catch subtle bugs and security issues.",
253
+ whenToOverride: "Override to a faster model when review tasks are primarily style or formatting checks rather than correctness or security analysis.",
254
+ providers: [
255
+ {
256
+ provider: "anthropic",
257
+ models: [{ model: "claude-sonnet-4-6" }]
258
+ },
259
+ {
260
+ provider: "openai",
261
+ models: [{ model: "gpt-5.3-codex" }]
262
+ },
263
+ {
264
+ provider: "github-copilot",
265
+ models: [{ model: "gemini-3.1-pro-preview" }]
266
+ },
267
+ {
268
+ provider: "opencode",
269
+ models: [{ model: "claude-sonnet-4-6" }]
270
+ }
271
+ ]
272
+ },
273
+ workflow: {
274
+ rationale: "Orchestration and bounded implementation tasks should default cheaper and faster; strong reasoning is rarely needed for routing decisions.",
275
+ providers: [
276
+ {
277
+ provider: "openai",
278
+ models: [{ model: "gpt-5.4-mini" }]
279
+ },
280
+ {
281
+ provider: "anthropic",
282
+ models: [{ model: "claude-sonnet-4-6" }]
283
+ },
284
+ {
285
+ provider: "opencode",
286
+ models: [{ model: "claude-haiku-4-5" }]
287
+ },
288
+ {
289
+ provider: "opencode-go",
290
+ models: [{ model: "claude-haiku-4-5" }]
291
+ }
292
+ ]
293
+ }
127
294
  };
295
+ function resolveSourceModel(category, availabilitySet) {
296
+ const categoryDefault = SOURCE_CATEGORY_MODEL_DEFAULTS[category];
297
+ if (!categoryDefault) {
298
+ throw new Error(`resolveSourceModel: unknown category "${category}". Valid categories: ${Object.keys(SOURCE_CATEGORY_MODEL_DEFAULTS).join(", ")}`);
299
+ }
300
+ for (const providerEntry of categoryDefault.providers) {
301
+ for (const modelEntry of providerEntry.models) {
302
+ const key = `${providerEntry.provider}/${modelEntry.model}`;
303
+ if (availabilitySet.has(key)) {
304
+ return {
305
+ provider: providerEntry.provider,
306
+ model: modelEntry.model,
307
+ variant: modelEntry.variant
308
+ };
309
+ }
310
+ }
311
+ }
312
+ const firstProvider = categoryDefault.providers[0];
313
+ const firstModel = firstProvider.models[0];
314
+ return {
315
+ provider: firstProvider.provider,
316
+ model: firstModel.model,
317
+ variant: firstModel.variant
318
+ };
319
+ }
320
+
321
+ // src/lib/agent-overlays.ts
128
322
  function buildBundledAgentInventory(agentsDir, disabledAgents) {
129
323
  const categories = readCategoryDirs(agentsDir);
130
324
  const agentsByQualifiedId = {};
131
325
  const stemCategories = new Map;
132
326
  const disabledSet = new Set(disabledAgents);
133
327
  for (const category of categories) {
134
- for (const fileName of readMarkdownFiles(path2.join(agentsDir, category))) {
328
+ for (const fileName of readMarkdownFiles(path3.join(agentsDir, category))) {
135
329
  const key = fileName.replace(/\.md$/, "");
136
330
  const id = `${category}/${key}`;
137
331
  agentsByQualifiedId[id] = {
138
332
  id,
139
333
  key,
140
334
  category,
141
- file: path2.join(agentsDir, category, fileName),
335
+ file: path3.join(agentsDir, category, fileName),
142
336
  disabled: disabledSet.has(key) || disabledSet.has(id)
143
337
  };
144
338
  const existing = stemCategories.get(key) ?? [];
@@ -191,62 +385,12 @@ function inferBuiltInTemperature(name, description) {
191
385
  }
192
386
  return 0.3;
193
387
  }
194
- function getAuthenticatedProviders(rootDirOverride) {
195
- const xdgDataHome = process.env.XDG_DATA_HOME?.trim();
196
- const rootDir = rootDirOverride || (xdgDataHome && path2.isAbsolute(xdgDataHome) ? xdgDataHome : path2.join(os2.homedir(), ".local/share"));
197
- const authPath = path2.join(rootDir, "opencode", "auth.json");
198
- let raw;
199
- try {
200
- raw = fs2.readFileSync(authPath, "utf8");
201
- } catch (err) {
202
- if (isSystemError(err) && err.code === "ENOENT") {
203
- return new Set;
204
- }
205
- console.warn(`[systematic] auth.json unreadable at ${authPath}; ignoring`);
206
- return new Set;
207
- }
208
- let parsed;
209
- try {
210
- parsed = JSON.parse(raw);
211
- } catch {
212
- console.warn(`[systematic] auth.json malformed at ${authPath}; ignoring`);
213
- return new Set;
214
- }
215
- if (!isRecord(parsed)) {
216
- console.warn(`[systematic] auth.json malformed at ${authPath}; ignoring`);
217
- return new Set;
218
- }
219
- return new Set(Object.keys(parsed));
220
- }
221
- function getSourceCategoryModel(category, authedProviders) {
222
- if (!category)
223
- return;
224
- const candidates = SOURCE_CATEGORY_MODEL_DEFAULTS[category];
225
- if (!candidates || candidates.length === 0)
226
- return;
227
- if (!authedProviders || authedProviders.size === 0)
228
- return candidates[0];
229
- for (const entry of candidates) {
230
- const slashIndex = entry.indexOf("/");
231
- if (slashIndex <= 0)
232
- continue;
233
- const providerId = entry.slice(0, slashIndex);
234
- if (authedProviders.has(providerId)) {
235
- return entry;
236
- }
237
- }
238
- return candidates[0];
239
- }
240
388
  function assertSourceCategoryModelCoverage(categories) {
241
- validateSourceCategoryModelDefaults();
242
389
  const missingCategories = categories.filter((category) => !Object.hasOwn(SOURCE_CATEGORY_MODEL_DEFAULTS, category));
243
390
  if (missingCategories.length > 0) {
244
391
  throw new Error(`Source category model defaults missing intentional coverage for: ${missingCategories.join(", ")}`);
245
392
  }
246
393
  }
247
- function validateSourceCategoryModelDefaults(defaults = SOURCE_CATEGORY_MODEL_DEFAULTS) {
248
- assertSourceCategoryModelDefaults(defaults);
249
- }
250
394
  function validateExactAgentOverlays(inventory, overlays, nativeAgents, enabledSkills) {
251
395
  const result = [];
252
396
  const seenTargets = new Map;
@@ -347,12 +491,158 @@ function validAgentKeys(inventory) {
347
491
  function throwConfigError(sourcePath, keyPath, message) {
348
492
  throw new Error(`Invalid Systematic config in ${sourcePath}: ${keyPath} ${message}`);
349
493
  }
350
- function isSystemError(err) {
351
- return typeof err === "object" && err !== null && "code" in err && typeof err.code === "string";
494
+
495
+ // src/lib/model-availability.ts
496
+ import { createHash } from "crypto";
497
+ import fs3 from "fs";
498
+ import os2 from "os";
499
+ import path4 from "path";
500
+ function emptyAvailability() {
501
+ return { status: "unknown", models: new Set };
502
+ }
503
+ var MAX_CACHE_FILE_BYTES = 16 * 1024 * 1024;
504
+ var DEFAULT_API_TIMEOUT_MS = 1500;
505
+ var MODELS_JSON_FILENAME = "models.json";
506
+ function resolveCacheDir() {
507
+ const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim();
508
+ const cacheBase = xdgCacheHome && path4.isAbsolute(xdgCacheHome) ? xdgCacheHome : path4.join(os2.homedir(), ".cache");
509
+ return path4.join(cacheBase, "opencode");
510
+ }
511
+ function fastHash(input) {
512
+ return createHash("sha1").update(input).digest("hex");
513
+ }
514
+ function isProviderRecord(value) {
515
+ if (!isRecord(value))
516
+ return false;
517
+ for (const entry of Object.values(value)) {
518
+ if (!isRecord(entry))
519
+ return false;
520
+ if (!isRecord(entry.models))
521
+ return false;
522
+ }
523
+ return true;
524
+ }
525
+ function readModelsFromCache(filePath) {
526
+ let fd;
527
+ try {
528
+ fd = fs3.openSync(filePath, "r");
529
+ } catch {
530
+ return null;
531
+ }
532
+ let raw;
533
+ try {
534
+ let stat;
535
+ try {
536
+ stat = fs3.fstatSync(fd);
537
+ } catch {
538
+ return null;
539
+ }
540
+ if (!stat.isFile())
541
+ return null;
542
+ if (stat.size === 0)
543
+ return null;
544
+ if (stat.size > MAX_CACHE_FILE_BYTES) {
545
+ console.warn(`[systematic] models.json at ${filePath} is ${stat.size} bytes (>${MAX_CACHE_FILE_BYTES}); treating as cache miss.`);
546
+ return null;
547
+ }
548
+ const buffer = Buffer.alloc(stat.size);
549
+ let bytesRead;
550
+ try {
551
+ bytesRead = fs3.readSync(fd, buffer, 0, stat.size, 0);
552
+ } catch {
553
+ return null;
554
+ }
555
+ if (bytesRead !== stat.size)
556
+ return null;
557
+ raw = buffer.toString("utf8");
558
+ } finally {
559
+ try {
560
+ fs3.closeSync(fd);
561
+ } catch {}
562
+ }
563
+ if (raw.trim().length === 0)
564
+ return null;
565
+ let parsed;
566
+ try {
567
+ parsed = JSON.parse(raw);
568
+ } catch {
569
+ return null;
570
+ }
571
+ if (!isProviderRecord(parsed)) {
572
+ console.warn(`[systematic] models.json schema mismatch at ${filePath}; treating as cache miss. Upstream shape may have drifted.`);
573
+ return null;
574
+ }
575
+ const result = new Set;
576
+ for (const [providerId, providerData] of Object.entries(parsed)) {
577
+ for (const modelId of Object.keys(providerData.models)) {
578
+ result.add(`${providerId}/${modelId}`);
579
+ }
580
+ }
581
+ return result;
582
+ }
583
+ function readFallbackCache() {
584
+ const cacheDir = resolveCacheDir();
585
+ const openCodeModelsUrl = process.env.OPENCODE_MODELS_URL?.trim();
586
+ if (openCodeModelsUrl) {
587
+ const urlDerivedPath = path4.join(cacheDir, `models-${fastHash(openCodeModelsUrl)}.json`);
588
+ const urlResult = readModelsFromCache(urlDerivedPath);
589
+ if (urlResult !== null) {
590
+ return { status: "cache", models: urlResult };
591
+ }
592
+ }
593
+ const defaultPath = path4.join(cacheDir, MODELS_JSON_FILENAME);
594
+ const defaultResult = readModelsFromCache(defaultPath);
595
+ if (defaultResult !== null) {
596
+ return { status: "cache", models: defaultResult };
597
+ }
598
+ return emptyAvailability();
599
+ }
600
+ function buildSetFromProviders(providers) {
601
+ const result = new Set;
602
+ for (const provider of providers) {
603
+ for (const modelId of Object.keys(provider.models)) {
604
+ result.add(`${provider.id}/${modelId}`);
605
+ }
606
+ }
607
+ return result;
608
+ }
609
+ async function getAvailableModels(client, options = {}) {
610
+ const timeoutMs = options.apiTimeoutMs === undefined ? DEFAULT_API_TIMEOUT_MS : options.apiTimeoutMs;
611
+ if (typeof client.config?.providers !== "function") {
612
+ return readFallbackCache();
613
+ }
614
+ const apiCall = client.config.providers();
615
+ let response;
616
+ try {
617
+ if (timeoutMs === null) {
618
+ response = await apiCall;
619
+ } else {
620
+ const TIMEOUT_SENTINEL = Symbol("timeout");
621
+ const timeoutPromise = new Promise((resolve2) => {
622
+ const timer = setTimeout(() => resolve2(TIMEOUT_SENTINEL), timeoutMs);
623
+ timer.unref?.();
624
+ });
625
+ const raced = await Promise.race([apiCall, timeoutPromise]);
626
+ if (raced === TIMEOUT_SENTINEL) {
627
+ console.warn(`[systematic] client.config.providers() exceeded ${timeoutMs}ms; falling back to models.json cache.`);
628
+ return readFallbackCache();
629
+ }
630
+ response = raced;
631
+ }
632
+ } catch {
633
+ return readFallbackCache();
634
+ }
635
+ if (response.error !== undefined || response.data === undefined) {
636
+ return readFallbackCache();
637
+ }
638
+ return {
639
+ status: "api",
640
+ models: buildSetFromProviders(response.data.providers)
641
+ };
352
642
  }
353
643
 
354
644
  // src/lib/skill-loader.ts
355
- import path3 from "path";
645
+ import path5 from "path";
356
646
  var SKILL_PREFIX = "systematic:";
357
647
  var SKILL_DESCRIPTION_PREFIX = "(Systematic - Skill) ";
358
648
  function formatSkillCommandName(name) {
@@ -369,7 +659,7 @@ function formatSkillDescription(description, fallbackName) {
369
659
  return `${SKILL_DESCRIPTION_PREFIX}${desc}`;
370
660
  }
371
661
  function wrapSkillTemplate(skillPath, body) {
372
- const skillDir = path3.dirname(skillPath);
662
+ const skillDir = path5.dirname(skillPath);
373
663
  return `<skill-instruction>
374
664
  Base directory for this skill: ${skillDir}/
375
665
  File references (@path) in this skill are relative to this directory.
@@ -519,7 +809,7 @@ function loadSkillAsCommand(loaded) {
519
809
  config.subtask = loaded.subtask;
520
810
  return config;
521
811
  }
522
- function collectAgents(dir, disabledAgents, nativeAgents, overlays, authedProviders) {
812
+ function collectAgents(dir, disabledAgents, nativeAgents, overlays, availabilitySet) {
523
813
  const agents = {};
524
814
  const agentList = findAgentsInDir(dir);
525
815
  const disabledSet = new Set(disabledAgents);
@@ -534,12 +824,12 @@ function collectAgents(dir, disabledAgents, nativeAgents, overlays, authedProvid
534
824
  continue;
535
825
  const config = loadAgentAsConfig(agentInfo);
536
826
  if (config) {
537
- agents[agentInfo.name] = applyAgentOverlays(config, agentInfo, overlays, authedProviders);
827
+ agents[agentInfo.name] = applyAgentOverlays(config, agentInfo, overlays, availabilitySet);
538
828
  }
539
829
  }
540
830
  return agents;
541
831
  }
542
- function applyAgentOverlays(config, agentInfo, overlays, authedProviders) {
832
+ function applyAgentOverlays(config, agentInfo, overlays, availabilitySet) {
543
833
  const id = agentInfo.category ? `${agentInfo.category}/${agentInfo.name}` : agentInfo.name;
544
834
  const categoryOverlay = agentInfo.category ? overlays.categoriesByKey.get(agentInfo.category) : undefined;
545
835
  const exactOverlay = overlays.agentsByTargetId.get(id);
@@ -550,17 +840,20 @@ function applyAgentOverlays(config, agentInfo, overlays, authedProviders) {
550
840
  addPermissionRules(permissionRules, config.permission);
551
841
  }
552
842
  result.temperature = inferBuiltInTemperature(agentInfo.name, result.description);
553
- if (agentInfo.category) {
554
- const sourceModel = getSourceCategoryModel(agentInfo.category, authedProviders);
555
- if (sourceModel) {
556
- result.model = sourceModel;
843
+ if (agentInfo.category && availabilitySet !== undefined) {
844
+ const resolved = resolveSourceModel(agentInfo.category, availabilitySet);
845
+ result.model = `${resolved.provider}/${resolved.model}`;
846
+ if (resolved.variant !== undefined) {
847
+ result.variant = resolved.variant;
848
+ } else {
849
+ delete result.variant;
557
850
  }
558
851
  }
559
852
  if (categoryOverlay) {
560
- applyOverlayObject(result, categoryOverlay.value, permissionRules);
853
+ applyOverlayObjectWithVariantClearing(result, categoryOverlay.value, permissionRules);
561
854
  }
562
855
  if (exactOverlay) {
563
- applyOverlayObject(result, exactOverlay.value, permissionRules);
856
+ applyOverlayObjectWithVariantClearing(result, exactOverlay.value, permissionRules);
564
857
  }
565
858
  if (hasPermissionOverlay) {
566
859
  const permission = permissionFromRules(permissionRules);
@@ -585,7 +878,12 @@ var OVERLAY_ASSIGN_FIELDS = [
585
878
  "steps",
586
879
  "hidden"
587
880
  ];
588
- function applyOverlayObject(target, overlay, permissionRules) {
881
+ function applyOverlayObjectWithVariantClearing(target, overlay, permissionRules) {
882
+ const overlayHasModel = Object.hasOwn(overlay, "model");
883
+ const overlayHasVariant = Object.hasOwn(overlay, "variant");
884
+ if (overlayHasModel && !overlayHasVariant) {
885
+ delete target.variant;
886
+ }
589
887
  for (const field of OVERLAY_ASSIGN_FIELDS) {
590
888
  if (Object.hasOwn(overlay, field)) {
591
889
  if (field === "model" && overlay[field] === null) {
@@ -680,15 +978,14 @@ function collectEnabledSkillNames(dir, disabledSkills) {
680
978
  return findSkillsInDir(dir).filter((skillInfo) => !disabledSet.has(skillInfo.name)).map((skillInfo) => skillInfo.name);
681
979
  }
682
980
  function createConfigHandler(deps) {
683
- const { directory, bundledSkillsDir, bundledAgentsDir, bundledCommandsDir } = deps;
684
- const readAuthProviders = deps.getAuthenticatedProviders ?? getAuthenticatedProviders;
981
+ const { directory, bundledSkillsDir, bundledAgentsDir: bundledAgentsDir2, bundledCommandsDir } = deps;
685
982
  return async (config) => {
686
983
  const { config: systematicConfig, overlays } = loadConfigWithSources(directory);
687
984
  const existingAgents = { ...config.agent ?? {} };
688
985
  const existingCommands = { ...config.command ?? {} };
689
986
  const bundledSkills = collectSkillsAsCommands(bundledSkillsDir, systematicConfig.disabled_skills);
690
987
  const enabledSkillNames = collectEnabledSkillNames(bundledSkillsDir, systematicConfig.disabled_skills);
691
- const inventory = buildBundledAgentInventory(bundledAgentsDir, systematicConfig.disabled_agents);
988
+ const inventory = buildBundledAgentInventory(bundledAgentsDir2, systematicConfig.disabled_agents);
692
989
  assertSourceCategoryModelCoverage(inventory.categories);
693
990
  const validatedOverlays = validateAgentOverlays({
694
991
  inventory,
@@ -697,8 +994,9 @@ function createConfigHandler(deps) {
697
994
  enabledSkills: enabledSkillNames
698
995
  });
699
996
  const resolvedOverlays = resolveAgentOverlaySet(validatedOverlays);
700
- const authedProviders = readAuthProviders();
701
- const bundledAgents = collectAgents(bundledAgentsDir, systematicConfig.disabled_agents, existingAgents, resolvedOverlays, authedProviders);
997
+ const availability = deps.client ? await getAvailableModels(deps.client) : undefined;
998
+ const availabilitySet = availability && availability.status !== "unknown" ? availability.models : undefined;
999
+ const bundledAgents = collectAgents(bundledAgentsDir2, systematicConfig.disabled_agents, existingAgents, resolvedOverlays, availabilitySet);
702
1000
  const bundledCommands = collectCommands(bundledCommandsDir, systematicConfig.disabled_commands);
703
1001
  config.agent = {
704
1002
  ...bundledAgents,
@@ -723,8 +1021,8 @@ function registerSkillsPaths(config, skillsDir) {
723
1021
  }
724
1022
 
725
1023
  // src/lib/skill-tool.ts
726
- import fs3 from "fs";
727
- import path4 from "path";
1024
+ import fs4 from "fs";
1025
+ import path6 from "path";
728
1026
  import { pathToFileURL } from "url";
729
1027
  import { tool } from "@opencode-ai/plugin/tool";
730
1028
  function formatSkillsXml(skills) {
@@ -750,17 +1048,17 @@ function discoverSkillFiles(dir, limit = 10) {
750
1048
  function handleEntry(entry, currentDir) {
751
1049
  if (entry.isDirectory()) {
752
1050
  if (!shouldSkipDirectory(entry.name)) {
753
- recurse(path4.resolve(currentDir, entry.name));
1051
+ recurse(path6.resolve(currentDir, entry.name));
754
1052
  }
755
1053
  } else if (shouldIncludeFile(entry.name)) {
756
- files.push(path4.resolve(currentDir, entry.name));
1054
+ files.push(path6.resolve(currentDir, entry.name));
757
1055
  }
758
1056
  }
759
1057
  function recurse(currentDir) {
760
1058
  if (files.length >= limit)
761
1059
  return;
762
1060
  try {
763
- const entries = fs3.readdirSync(currentDir, { withFileTypes: true });
1061
+ const entries = fs4.readdirSync(currentDir, { withFileTypes: true });
764
1062
  for (const entry of entries) {
765
1063
  if (files.length >= limit)
766
1064
  break;
@@ -841,7 +1139,7 @@ function createSkillTool(options) {
841
1139
  throw new Error(`Skill "${requestedName}" not found. Available systematic skills: ${availableSystematic.join(", ")}`);
842
1140
  }
843
1141
  const body = extractSkillBody(matchedSkill.wrappedTemplate);
844
- const dir = path4.dirname(matchedSkill.skillFile);
1142
+ const dir = path6.dirname(matchedSkill.skillFile);
845
1143
  const base = pathToFileURL(dir).href;
846
1144
  const files = discoverSkillFiles(dir);
847
1145
  await context.ask({
@@ -878,17 +1176,17 @@ function createSkillTool(options) {
878
1176
  }
879
1177
 
880
1178
  // src/index.ts
881
- var __dirname2 = path5.dirname(fileURLToPath(import.meta.url));
882
- var packageRoot = path5.resolve(__dirname2, "..");
883
- var bundledSkillsDir = path5.join(packageRoot, "skills");
884
- var bundledAgentsDir = path5.join(packageRoot, "agents");
885
- var bundledCommandsDir = path5.join(packageRoot, "commands");
886
- var packageJsonPath = path5.join(packageRoot, "package.json");
1179
+ var __dirname3 = path7.dirname(fileURLToPath2(import.meta.url));
1180
+ var packageRoot2 = path7.resolve(__dirname3, "..");
1181
+ var bundledSkillsDir = path7.join(packageRoot2, "skills");
1182
+ var bundledAgentsDir2 = path7.join(packageRoot2, "agents");
1183
+ var bundledCommandsDir = path7.join(packageRoot2, "commands");
1184
+ var packageJsonPath = path7.join(packageRoot2, "package.json");
887
1185
  var getPackageVersion = () => {
888
1186
  try {
889
- if (!fs4.existsSync(packageJsonPath))
1187
+ if (!fs5.existsSync(packageJsonPath))
890
1188
  return "unknown";
891
- const content = fs4.readFileSync(packageJsonPath, "utf8");
1189
+ const content = fs5.readFileSync(packageJsonPath, "utf8");
892
1190
  const parsed = JSON.parse(content);
893
1191
  return parsed.version ?? "unknown";
894
1192
  } catch {
@@ -902,8 +1200,9 @@ var initializePlugin = async ({ client, directory }) => {
902
1200
  const configHandler = createConfigHandler({
903
1201
  directory,
904
1202
  bundledSkillsDir,
905
- bundledAgentsDir,
906
- bundledCommandsDir
1203
+ bundledAgentsDir: bundledAgentsDir2,
1204
+ bundledCommandsDir,
1205
+ client
907
1206
  });
908
1207
  return {
909
1208
  config: configHandler,
@@ -42,27 +42,4 @@ export declare function buildBundledAgentInventory(agentsDir: string, disabledAg
42
42
  export declare function validateAgentOverlays({ inventory, overlays, nativeAgents, enabledSkills, }: ValidateAgentOverlaysOptions): ValidatedAgentOverlays;
43
43
  export declare function resolveAgentOverlaySet(overlays: ValidatedAgentOverlays): ResolvedAgentOverlaySet;
44
44
  export declare function inferBuiltInTemperature(name: string, description?: string): number;
45
- /**
46
- * Read which providers are authenticated from OpenCode's auth.json.
47
- *
48
- * Reads only top-level keys (provider IDs). Nested values are NEVER
49
- * inspected, logged, persisted, or transmitted. This is a hard contract:
50
- * the auth file holds API keys and OAuth tokens, and Systematic must
51
- * never expose them via stderr, telemetry, or any other channel.
52
- *
53
- * Intended for one invocation per plugin config(cfg) cycle. Repeated
54
- * calls trigger repeated file reads and, on malformed input, repeated
55
- * stderr diagnostics.
56
- *
57
- * @param rootDirOverride - Optional path override for tests. When
58
- * non-empty, the auth file is resolved as
59
- * `path.join(rootDirOverride, 'opencode', 'auth.json')`. When
60
- * omitted, resolution follows XDG_DATA_HOME -> ~/.local/share
61
- * convention.
62
- * @returns A readonly set of authenticated provider IDs (empty set on
63
- * any failure).
64
- */
65
- export declare function getAuthenticatedProviders(rootDirOverride?: string): ReadonlySet<string>;
66
- export declare function getSourceCategoryModel(category: string | undefined, authedProviders?: ReadonlySet<string>): string | undefined;
67
45
  export declare function assertSourceCategoryModelCoverage(categories: string[]): void;
68
- export declare function validateSourceCategoryModelDefaults(defaults?: Record<string, unknown>): void;
@@ -1,11 +1,12 @@
1
1
  import type { Config } from '@opencode-ai/plugin';
2
+ import { type OpencodeClientLike } from './model-availability.js';
2
3
  export interface ConfigHandlerDeps {
3
4
  directory: string;
4
5
  bundledSkillsDir: string;
5
6
  bundledAgentsDir: string;
6
7
  bundledCommandsDir: string;
7
- /** Override for authenticated provider reader; for testing. */
8
- getAuthenticatedProviders?: (rootDirOverride?: string) => ReadonlySet<string>;
8
+ /** OpenCode client for availability lookup. When omitted, availability falls back to empty set (last-resort resolution). */
9
+ client?: OpencodeClientLike;
9
10
  }
10
11
  export declare function toTitleCase(name: string): string;
11
12
  export declare function formatAgentDescription(name: string, description: string | undefined): string;
@@ -166,7 +166,6 @@ export type ValidationResult = {
166
166
  errors: readonly z.ZodIssue[];
167
167
  };
168
168
  export declare function validateConfig(input: unknown): ValidationResult;
169
- export declare function assertSourceCategoryModelDefaults(defaults: Record<string, unknown>): void;
170
169
  /**
171
170
  * Overlay fields that require a project-or-higher trust source.
172
171
  *
@@ -0,0 +1,77 @@
1
+ interface ConnectedProvider {
2
+ id: string;
3
+ models: Record<string, unknown>;
4
+ }
5
+ interface ProvidersResponse {
6
+ providers: ConnectedProvider[];
7
+ default: Record<string, string>;
8
+ }
9
+ interface ClientConfigApi {
10
+ providers: () => Promise<{
11
+ data: ProvidersResponse;
12
+ error: undefined;
13
+ } | {
14
+ data: undefined;
15
+ error: unknown;
16
+ }>;
17
+ }
18
+ export interface OpencodeClientLike {
19
+ config: ClientConfigApi;
20
+ }
21
+ /**
22
+ * Outcome of model availability discovery.
23
+ *
24
+ * - `api`: The OpenCode server's `/config/providers` endpoint responded with
25
+ * a connected-providers payload. `models` may be empty if no providers
26
+ * are authenticated; that is authoritative.
27
+ * - `cache`: The API call failed (error envelope, thrown, or timed out) and
28
+ * the local `models.json` cache was readable. `models` reflects whatever
29
+ * OpenCode last wrote to disk.
30
+ * - `unknown`: Both the API call and the cache fallback failed (cache
31
+ * missing, unreadable, corrupt, or schema-mismatched). Resolution should
32
+ * degrade gracefully — callers should treat `unknown` as a signal to
33
+ * skip source-default model pinning so users do not get agents pinned
34
+ * to inaccessible models. `models` is the empty set.
35
+ */
36
+ export type DiscoveryStatus = 'api' | 'cache' | 'unknown';
37
+ export interface ModelAvailability {
38
+ status: DiscoveryStatus;
39
+ /**
40
+ * Set of `${providerId}/${modelId}` strings. Typed `ReadonlySet` because
41
+ * callers must not mutate the returned collection — mutation would corrupt
42
+ * future calls in the same process. Each `ModelAvailability` is a fresh
43
+ * instance (see `emptyAvailability()`), so mutation via cast cannot
44
+ * propagate, but the type makes intent explicit.
45
+ */
46
+ models: ReadonlySet<string>;
47
+ }
48
+ interface AvailabilityOptions {
49
+ /**
50
+ * Maximum time to wait for `client.config.providers()` before falling
51
+ * back to the local cache. Defaults to 1500ms — a startup-budget value
52
+ * that prevents a slow/half-open OpenCode server from holding the plugin
53
+ * indefinitely.
54
+ *
55
+ * Set to `null` to disable the timeout entirely (not recommended).
56
+ */
57
+ apiTimeoutMs?: number | null;
58
+ }
59
+ /**
60
+ * Discover the set of `provider/model` keys the OpenCode server considers
61
+ * connected (or, on API failure, the set last written to the on-disk
62
+ * `models.json` cache).
63
+ *
64
+ * The returned `status` lets callers distinguish three discovery outcomes:
65
+ * - `api`: live answer; safe to pin source-default models against it
66
+ * - `cache`: degraded but informed; the cached `provider/model` keys are
67
+ * plausibly still authoritative
68
+ * - `unknown`: both the API and the cache failed; callers should fall back
69
+ * to OpenCode's parent-model inheritance rather than pinning a source
70
+ * default the user may not have access to
71
+ *
72
+ * The API call is bounded by `apiTimeoutMs` (default 1500ms). On timeout,
73
+ * thrown error, error-envelope response, or undefined data, the cache
74
+ * fallback runs. The function never rejects.
75
+ */
76
+ export declare function getAvailableModels(client: OpencodeClientLike, options?: AvailabilityOptions): Promise<ModelAvailability>;
77
+ export {};
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Provider-grouped source category model defaults for Systematic bundled agents.
3
+ *
4
+ * This module owns the canonical shape, Zod schema, and constant for the
5
+ * per-category model resolution chain. The resolution algorithm walks
6
+ * the provider list in order and picks the first available provider/model pair.
7
+ *
8
+ * Provider catalog is constrained to the 7 IDs with empirical OMO usage-frequency
9
+ * justification: vercel=80, opencode=55, github-copilot=39, opencode-go=26,
10
+ * openai=20, anthropic=18, google=10.
11
+ */
12
+ import { z } from 'zod';
13
+ /**
14
+ * Zod literal union of the 7 supported provider IDs.
15
+ * Ordered by OMO empirical usage frequency (highest first).
16
+ */
17
+ export declare const ProviderID: z.ZodUnion<readonly [z.ZodLiteral<"vercel">, z.ZodLiteral<"opencode">, z.ZodLiteral<"github-copilot">, z.ZodLiteral<"opencode-go">, z.ZodLiteral<"openai">, z.ZodLiteral<"anthropic">, z.ZodLiteral<"google">]>;
18
+ export type ProviderID = z.infer<typeof ProviderID>;
19
+ /**
20
+ * Zod schema for the full source category model defaults map.
21
+ *
22
+ * Schema is **pure** — it validates only structural correctness:
23
+ * - Shape correctness (CategoryDefaultSchema per value)
24
+ * - Provider lists non-empty (enforced by CategoryDefaultSchema)
25
+ * - Model lists non-empty (enforced by ProviderEntrySchema)
26
+ * - (model, variant) pairs unique within a provider entry
27
+ * - Provider IDs unique within a category
28
+ * - variant is non-empty, whitespace-free, max 128 chars
29
+ * - `rationale` and `whenToOverride` reject pipe (`|`) and newline characters
30
+ * so the generator produces well-formed Markdown tables
31
+ *
32
+ * Filesystem coverage (every key resolves to a real `agents/<category>/`
33
+ * directory) is NOT checked here. Use `assertCategoryCoverageOnDisk` to
34
+ * enforce that invariant from tests where it matters; the production
35
+ * runtime path uses `assertSourceCategoryModelCoverage` from
36
+ * `agent-overlays.ts` against an in-memory inventory rather than reading
37
+ * disk again.
38
+ */
39
+ export declare const SourceCategoryDefaultsSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
40
+ rationale: z.ZodString;
41
+ whenToOverride: z.ZodOptional<z.ZodString>;
42
+ providers: z.ZodArray<z.ZodObject<{
43
+ provider: z.ZodUnion<readonly [z.ZodLiteral<"vercel">, z.ZodLiteral<"opencode">, z.ZodLiteral<"github-copilot">, z.ZodLiteral<"opencode-go">, z.ZodLiteral<"openai">, z.ZodLiteral<"anthropic">, z.ZodLiteral<"google">]>;
44
+ models: z.ZodArray<z.ZodObject<{
45
+ model: z.ZodString;
46
+ variant: z.ZodOptional<z.ZodString>;
47
+ }, z.core.$strict>>;
48
+ }, z.core.$strict>>;
49
+ }, z.core.$strict>>;
50
+ /**
51
+ * Verify that every category key in `categories` maps to a real
52
+ * `agents/<category>/` directory on disk under `agentsDir` (defaulting to
53
+ * the package's bundled-agents directory). Throws with a useful message if
54
+ * any keys are unrecognized.
55
+ *
56
+ * Use from tests to lock the SOURCE_CATEGORY_MODEL_DEFAULTS ↔ agents/
57
+ * directory layout contract. The production runtime path validates the
58
+ * inverse direction (every bundled-agent category has a source default)
59
+ * via `assertSourceCategoryModelCoverage` in `agent-overlays.ts`.
60
+ */
61
+ export declare function assertCategoryCoverageOnDisk(categories: readonly string[], agentsDir?: string): void;
62
+ export interface ModelEntry {
63
+ model: string;
64
+ variant?: string;
65
+ }
66
+ export interface ProviderEntry {
67
+ provider: ProviderID;
68
+ models: ModelEntry[];
69
+ }
70
+ export interface CategoryDefault {
71
+ rationale: string;
72
+ whenToOverride?: string;
73
+ providers: ProviderEntry[];
74
+ }
75
+ export type SourceCategoryDefaults = Record<string, CategoryDefault>;
76
+ /**
77
+ * Provider-grouped source model defaults for the 6 Systematic agent categories.
78
+ *
79
+ * Provider chains are ordered by OMO category-fit reasoning. The resolver
80
+ * walks providers in order and picks the first available provider/model pair.
81
+ * If no provider is available, the first entry of the first provider is used as
82
+ * the last-resort fallback.
83
+ *
84
+ * Model choices translate the existing flat-string-array constant in agent-overlays.ts
85
+ * to the new provider-grouped shape, with variant annotations where applicable.
86
+ */
87
+ export declare const SOURCE_CATEGORY_MODEL_DEFAULTS: SourceCategoryDefaults;
88
+ /**
89
+ * Format the SOURCE_CATEGORY_MODEL_DEFAULTS as a GitHub-flavored markdown table
90
+ * for injection into documentation.
91
+ *
92
+ * Columns: Category | Chain | Rationale | When to Override
93
+ *
94
+ * Chain format: comma-separated `provider/model[+variant]` for the first 2–3
95
+ * provider entries (first model per provider). Appends `, …` when there are
96
+ * more than 3 provider entries.
97
+ *
98
+ * Returns a string ending with `\n` for clean concatenation.
99
+ */
100
+ export declare function formatForDocs(): string;
101
+ /**
102
+ * Walk the provider-grouped shape for a category and return the first available
103
+ * provider/model pair from the availability set.
104
+ *
105
+ * Algorithm:
106
+ * 1. Look up the category. Unknown category is a programmer error — throw.
107
+ * 2. Walk providers in declared order. For each provider, walk its models in
108
+ * declared order and test `${provider}/${model}` membership in availabilitySet.
109
+ * 3. On first hit, return { provider, model, variant? }.
110
+ * 4. Last-resort fallback (no available model anywhere): return the first model
111
+ * entry of the first provider entry, including its variant if present.
112
+ */
113
+ export declare function resolveSourceModel(category: string, availabilitySet: ReadonlySet<string>): {
114
+ provider: ProviderID;
115
+ model: string;
116
+ variant?: string;
117
+ };
@@ -46,6 +46,7 @@
46
46
  "variant": {
47
47
  "type": "string",
48
48
  "minLength": 1,
49
+ "maxLength": 128,
49
50
  "pattern": "^\\S+$",
50
51
  "description": "Model variant identifier",
51
52
  "examples": ["v2", "extended"],
@@ -204,6 +205,7 @@
204
205
  "variant": {
205
206
  "type": "string",
206
207
  "minLength": 1,
208
+ "maxLength": 128,
207
209
  "pattern": "^\\S+$",
208
210
  "description": "Model variant identifier",
209
211
  "examples": ["v2", "extended"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fro.bot/systematic",
3
- "version": "2.12.3",
3
+ "version": "2.13.1",
4
4
  "description": "Structured engineering workflows for OpenCode",
5
5
  "type": "module",
6
6
  "homepage": "https://fro.bot/systematic",