@fro.bot/systematic 2.10.0 → 2.11.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/README.md CHANGED
@@ -338,6 +338,8 @@ 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-opus-4.7', 'openai/gpt-5.5']` — 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-opus-4.7`. 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.
342
+
341
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.
342
344
 
343
345
  The source defaults are:
package/dist/index.js CHANGED
@@ -78,14 +78,15 @@ ${toolMapping}
78
78
 
79
79
  // src/lib/agent-overlays.ts
80
80
  import fs2 from "fs";
81
+ import os2 from "os";
81
82
  import path2 from "path";
82
83
  var SOURCE_CATEGORY_MODEL_DEFAULTS = {
83
- design: "openai/gpt-5.5",
84
- docs: "openai/gpt-5.4-mini",
85
- "document-review": "anthropic/claude-opus-4.7",
86
- research: "openai/gpt-5.5",
87
- review: "anthropic/claude-opus-4.7",
88
- workflow: "openai/gpt-5.4-mini"
84
+ design: ["openai/gpt-5.5", "anthropic/claude-opus-4.7"],
85
+ docs: ["openai/gpt-5.4-mini", "anthropic/claude-haiku-4-5"],
86
+ "document-review": ["anthropic/claude-opus-4.7", "openai/gpt-5.5"],
87
+ research: ["openai/gpt-5.5", "anthropic/claude-opus-4.7"],
88
+ review: ["anthropic/claude-opus-4.7", "openai/gpt-5.5"],
89
+ workflow: ["openai/gpt-5.4-mini", "anthropic/claude-haiku-4-5"]
89
90
  };
90
91
  var ALLOWED_OVERLAY_FIELDS = new Set([
91
92
  "model",
@@ -166,10 +167,51 @@ function inferBuiltInTemperature(name, description) {
166
167
  }
167
168
  return 0.3;
168
169
  }
169
- function getSourceCategoryModel(category) {
170
+ function getAuthenticatedProviders(rootDirOverride) {
171
+ const xdgDataHome = process.env.XDG_DATA_HOME?.trim();
172
+ const rootDir = rootDirOverride || (xdgDataHome && path2.isAbsolute(xdgDataHome) ? xdgDataHome : path2.join(os2.homedir(), ".local/share"));
173
+ const authPath = path2.join(rootDir, "opencode", "auth.json");
174
+ let raw;
175
+ try {
176
+ raw = fs2.readFileSync(authPath, "utf8");
177
+ } catch (err) {
178
+ if (isSystemError(err) && err.code === "ENOENT") {
179
+ return new Set;
180
+ }
181
+ console.warn(`[systematic] auth.json unreadable at ${authPath}; ignoring`);
182
+ return new Set;
183
+ }
184
+ let parsed;
185
+ try {
186
+ parsed = JSON.parse(raw);
187
+ } catch {
188
+ console.warn(`[systematic] auth.json malformed at ${authPath}; ignoring`);
189
+ return new Set;
190
+ }
191
+ if (!isRecord(parsed)) {
192
+ console.warn(`[systematic] auth.json malformed at ${authPath}; ignoring`);
193
+ return new Set;
194
+ }
195
+ return new Set(Object.keys(parsed));
196
+ }
197
+ function getSourceCategoryModel(category, authedProviders) {
170
198
  if (!category)
171
199
  return;
172
- return SOURCE_CATEGORY_MODEL_DEFAULTS[category];
200
+ const candidates = SOURCE_CATEGORY_MODEL_DEFAULTS[category];
201
+ if (!candidates || candidates.length === 0)
202
+ return;
203
+ if (!authedProviders || authedProviders.size === 0)
204
+ return candidates[0];
205
+ for (const entry of candidates) {
206
+ const slashIndex = entry.indexOf("/");
207
+ if (slashIndex <= 0)
208
+ continue;
209
+ const providerId = entry.slice(0, slashIndex);
210
+ if (authedProviders.has(providerId)) {
211
+ return entry;
212
+ }
213
+ }
214
+ return candidates[0];
173
215
  }
174
216
  function assertSourceCategoryModelCoverage(categories) {
175
217
  validateSourceCategoryModelDefaults();
@@ -179,8 +221,16 @@ function assertSourceCategoryModelCoverage(categories) {
179
221
  }
180
222
  }
181
223
  function validateSourceCategoryModelDefaults(defaults = SOURCE_CATEGORY_MODEL_DEFAULTS) {
182
- for (const [category, model] of Object.entries(defaults)) {
183
- validateModel("source category model defaults", `source category model defaults.${category}`, model);
224
+ for (const [category, value] of Object.entries(defaults)) {
225
+ if (!Array.isArray(value)) {
226
+ throw new Error(`Source category model defaults: ${category} must be a non-empty array of provider/model strings`);
227
+ }
228
+ if (value.length === 0) {
229
+ throw new Error(`Source category model defaults: ${category} must be a non-empty array of provider/model strings`);
230
+ }
231
+ for (const [index, model] of value.entries()) {
232
+ validateModel("source category model defaults", `source category model defaults.${category}[${index}]`, model);
233
+ }
184
234
  }
185
235
  }
186
236
  function validateExactAgentOverlays(inventory, overlays, nativeAgents, enabledSkills) {
@@ -398,6 +448,9 @@ function validAgentKeys(inventory) {
398
448
  function throwConfigError(sourcePath, keyPath, message) {
399
449
  throw new Error(`Invalid Systematic config in ${sourcePath}: ${keyPath} ${message}`);
400
450
  }
451
+ function isSystemError(err) {
452
+ return typeof err === "object" && err !== null && "code" in err && typeof err.code === "string";
453
+ }
401
454
 
402
455
  // src/lib/skill-loader.ts
403
456
  import path3 from "path";
@@ -567,7 +620,7 @@ function loadSkillAsCommand(loaded) {
567
620
  config.subtask = loaded.subtask;
568
621
  return config;
569
622
  }
570
- function collectAgents(dir, disabledAgents, nativeAgents, overlays) {
623
+ function collectAgents(dir, disabledAgents, nativeAgents, overlays, authedProviders) {
571
624
  const agents = {};
572
625
  const agentList = findAgentsInDir(dir);
573
626
  const disabledSet = new Set(disabledAgents);
@@ -582,12 +635,12 @@ function collectAgents(dir, disabledAgents, nativeAgents, overlays) {
582
635
  continue;
583
636
  const config = loadAgentAsConfig(agentInfo);
584
637
  if (config) {
585
- agents[agentInfo.name] = applyAgentOverlays(config, agentInfo, overlays);
638
+ agents[agentInfo.name] = applyAgentOverlays(config, agentInfo, overlays, authedProviders);
586
639
  }
587
640
  }
588
641
  return agents;
589
642
  }
590
- function applyAgentOverlays(config, agentInfo, overlays) {
643
+ function applyAgentOverlays(config, agentInfo, overlays, authedProviders) {
591
644
  const id = agentInfo.category ? `${agentInfo.category}/${agentInfo.name}` : agentInfo.name;
592
645
  const categoryOverlay = agentInfo.category ? overlays.categoriesByKey.get(agentInfo.category) : undefined;
593
646
  const exactOverlay = overlays.agentsByTargetId.get(id);
@@ -599,7 +652,7 @@ function applyAgentOverlays(config, agentInfo, overlays) {
599
652
  }
600
653
  result.temperature = inferBuiltInTemperature(agentInfo.name, result.description);
601
654
  if (agentInfo.category) {
602
- const sourceModel = getSourceCategoryModel(agentInfo.category);
655
+ const sourceModel = getSourceCategoryModel(agentInfo.category, authedProviders);
603
656
  if (sourceModel) {
604
657
  result.model = sourceModel;
605
658
  }
@@ -729,6 +782,7 @@ function collectEnabledSkillNames(dir, disabledSkills) {
729
782
  }
730
783
  function createConfigHandler(deps) {
731
784
  const { directory, bundledSkillsDir, bundledAgentsDir, bundledCommandsDir } = deps;
785
+ const readAuthProviders = deps.getAuthenticatedProviders ?? getAuthenticatedProviders;
732
786
  return async (config) => {
733
787
  const { config: systematicConfig, overlays } = loadConfigWithSources(directory);
734
788
  const existingAgents = { ...config.agent ?? {} };
@@ -744,7 +798,8 @@ function createConfigHandler(deps) {
744
798
  enabledSkills: enabledSkillNames
745
799
  });
746
800
  const resolvedOverlays = resolveAgentOverlaySet(validatedOverlays);
747
- const bundledAgents = collectAgents(bundledAgentsDir, systematicConfig.disabled_agents, existingAgents, resolvedOverlays);
801
+ const authedProviders = readAuthProviders();
802
+ const bundledAgents = collectAgents(bundledAgentsDir, systematicConfig.disabled_agents, existingAgents, resolvedOverlays, authedProviders);
748
803
  const bundledCommands = collectCommands(bundledCommandsDir, systematicConfig.disabled_commands);
749
804
  config.agent = {
750
805
  ...bundledAgents,
@@ -42,6 +42,27 @@ 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
- export declare function getSourceCategoryModel(category: string | undefined): string | undefined;
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;
46
67
  export declare function assertSourceCategoryModelCoverage(categories: string[]): void;
47
68
  export declare function validateSourceCategoryModelDefaults(defaults?: Record<string, unknown>): void;
@@ -4,6 +4,8 @@ export interface ConfigHandlerDeps {
4
4
  bundledSkillsDir: string;
5
5
  bundledAgentsDir: string;
6
6
  bundledCommandsDir: string;
7
+ /** Override for authenticated provider reader; for testing. */
8
+ getAuthenticatedProviders?: (rootDirOverride?: string) => ReadonlySet<string>;
7
9
  }
8
10
  export declare function toTitleCase(name: string): string;
9
11
  export declare function formatAgentDescription(name: string, description: string | undefined): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fro.bot/systematic",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "Structured engineering workflows for OpenCode",
5
5
  "type": "module",
6
6
  "homepage": "https://fro.bot/systematic",