@fro.bot/systematic 2.8.1 → 2.9.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
@@ -297,6 +297,20 @@ Configuration is loaded from multiple locations and merged (later sources overri
297
297
  {
298
298
  "disabled_skills": ["git-worktree"],
299
299
  "disabled_agents": [],
300
+ "categories": {
301
+ "review": {
302
+ "temperature": 0.1,
303
+ "steps": 12
304
+ }
305
+ },
306
+ "agents": {
307
+ "security-sentinel": {
308
+ "model": "anthropic/claude-sonnet-4-5"
309
+ },
310
+ "workflow/systematic-implementer": {
311
+ "steps": 20
312
+ }
313
+ },
300
314
  "bootstrap": {
301
315
  "enabled": true
302
316
  }
@@ -307,9 +321,21 @@ Configuration is loaded from multiple locations and merged (later sources overri
307
321
  |--------|------|---------|-------------|
308
322
  | `disabled_skills` | `string[]` | `[]` | Skills to exclude from registration |
309
323
  | `disabled_agents` | `string[]` | `[]` | Agents to exclude from registration |
324
+ | `categories` | `object` | `{}` | Overlay bundled agents by category (`design`, `docs`, `document-review`, `research`, `review`, `workflow`) |
325
+ | `agents` | `object` | `{}` | Overlay exact bundled agents by unique stem or `<category>/<stem>` key |
310
326
  | `bootstrap.enabled` | `boolean` | `true` | Inject the `using-systematic` guide into system prompts |
311
327
  | `bootstrap.file` | `string` | — | Custom bootstrap file path (overrides default) |
312
328
 
329
+ Agent overlays support `model`, `variant`, `temperature`, `top_p`, `permission`, `mode`, `color`, `steps`, `hidden`, exact-agent-only `disable`, and managed `skills`. `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 `permission` and `skills` control tool access, they are only accepted from user config or `$OPENCODE_CONFIG_DIR/systematic.json`; project config may tune behavior but cannot loosen a user's permission policy.
330
+
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 security fields: same-key project overlays preserve user-level `permission` and `skills` fields instead of erasing them. After the effective config is built, exact `agents` overlays beat category overlays, which beat built-in policy defaults, bundled markdown defaults, and OpenCode inherited defaults.
332
+
333
+ Bundled agents omit `model` by default so OpenCode model inheritance keeps working. Systematic emits a `model` only when you configure one explicitly; provider-specific zero-config model defaults are intentionally deferred.
334
+
335
+ Native OpenCode agents with the same emitted key are full replacements. An exact Systematic overlay for that key conflicts, while category overlays skip native replacements and continue applying to other bundled agents. Use one canonical agent key form across config sources (`security-sentinel` or `review/security-sentinel`) because alias collisions fail duplicate-target validation.
336
+
337
+ Category IDs are V1 public API because broad policy overlays are a core use case; future agent reorganizations must preserve aliases or provide migration warnings. Category overlays also apply to future bundled agents added to that category. V1 does not include an MCP allowlist shortcut.
338
+
313
339
  ### Project-Specific Content
314
340
 
315
341
  Add your own skills and agents alongside bundled ones:
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  findCommandsInDir,
7
7
  findSkillsInDir,
8
8
  getConfigPaths
9
- } from "./index-k9tdxh0p.js";
9
+ } from "./index-488txzkn.js";
10
10
 
11
11
  // src/cli.ts
12
12
  import fs from "fs";
@@ -807,6 +807,43 @@ var ParseErrorCode;
807
807
  ParseErrorCode2[ParseErrorCode2["InvalidEscapeCharacter"] = 15] = "InvalidEscapeCharacter";
808
808
  ParseErrorCode2[ParseErrorCode2["InvalidCharacter"] = 16] = "InvalidCharacter";
809
809
  })(ParseErrorCode || (ParseErrorCode = {}));
810
+ function printParseErrorCode(code) {
811
+ switch (code) {
812
+ case 1:
813
+ return "InvalidSymbol";
814
+ case 2:
815
+ return "InvalidNumberFormat";
816
+ case 3:
817
+ return "PropertyNameExpected";
818
+ case 4:
819
+ return "ValueExpected";
820
+ case 5:
821
+ return "ColonExpected";
822
+ case 6:
823
+ return "CommaExpected";
824
+ case 7:
825
+ return "CloseBraceExpected";
826
+ case 8:
827
+ return "CloseBracketExpected";
828
+ case 9:
829
+ return "EndOfFileExpected";
830
+ case 10:
831
+ return "InvalidCommentToken";
832
+ case 11:
833
+ return "UnexpectedEndOfComment";
834
+ case 12:
835
+ return "UnexpectedEndOfString";
836
+ case 13:
837
+ return "UnexpectedEndOfNumber";
838
+ case 14:
839
+ return "InvalidUnicode";
840
+ case 15:
841
+ return "InvalidEscapeCharacter";
842
+ case 16:
843
+ return "InvalidCharacter";
844
+ }
845
+ return "<unknown ParseErrorCode>";
846
+ }
810
847
 
811
848
  // src/lib/config.ts
812
849
  var DEFAULT_CONFIG = {
@@ -815,17 +852,43 @@ var DEFAULT_CONFIG = {
815
852
  disabled_commands: [],
816
853
  bootstrap: {
817
854
  enabled: true
818
- }
855
+ },
856
+ agents: {},
857
+ categories: {}
819
858
  };
859
+ var SECURITY_OVERLAY_FIELDS = new Set(["permission", "skills"]);
860
+ function isErrorWithCode(error) {
861
+ return error instanceof Error && "code" in error;
862
+ }
820
863
  function loadJsoncFile(filePath) {
864
+ let content;
821
865
  try {
822
- if (!fs.existsSync(filePath))
866
+ content = fs.readFileSync(filePath, "utf-8");
867
+ } catch (error) {
868
+ if (isErrorWithCode(error) && error.code === "ENOENT")
823
869
  return null;
824
- const content = fs.readFileSync(filePath, "utf-8");
825
- return parse2(content);
826
- } catch {
827
- return null;
870
+ throw new Error(`Invalid Systematic config in ${filePath}: unable to read file`, { cause: error });
871
+ }
872
+ const errors = [];
873
+ const parsed = parse2(content, errors);
874
+ if (errors.length > 0) {
875
+ const error = errors[0];
876
+ const message = error ? `${printParseErrorCode(error.error)} at offset ${error.offset}` : "unknown parse error";
877
+ throw new Error(`Invalid Systematic config in ${filePath}: JSONC parse error: ${message}`);
828
878
  }
879
+ if (!isRecord(parsed)) {
880
+ throw new Error(`Invalid Systematic config in ${filePath}: root must be an object`);
881
+ }
882
+ return parsed;
883
+ }
884
+ function loadConfigSource(filePath, trust) {
885
+ const config = loadJsoncFile(filePath);
886
+ if (!config)
887
+ return null;
888
+ return { path: filePath, config, trust };
889
+ }
890
+ function isRecord(value) {
891
+ return typeof value === "object" && value !== null && !Array.isArray(value);
829
892
  }
830
893
  function mergeArraysUnique(arr1, arr2) {
831
894
  const set = new Set;
@@ -838,10 +901,18 @@ function mergeArraysUnique(arr1, arr2) {
838
901
  return Array.from(set);
839
902
  }
840
903
  function loadConfig(projectDir) {
904
+ return loadConfigWithSources(projectDir).config;
905
+ }
906
+ function loadConfigWithSources(projectDir) {
841
907
  const paths = getConfigPaths(projectDir);
842
- const userConfig = loadJsoncFile(paths.userConfig);
843
- const projectConfig = loadJsoncFile(paths.projectConfig);
844
- const customConfig = paths.customConfig ? loadJsoncFile(paths.customConfig) : null;
908
+ const userSource = loadConfigSource(paths.userConfig, "user");
909
+ const projectSource = loadConfigSource(paths.projectConfig, "project");
910
+ const customSource = paths.customConfig ? loadConfigSource(paths.customConfig, "custom") : null;
911
+ const sources = [userSource, projectSource, customSource].filter((source) => source !== null);
912
+ const overlays = mergeOverlaySources(sources);
913
+ const userConfig = userSource?.config;
914
+ const projectConfig = projectSource?.config;
915
+ const customConfig = customSource?.config;
845
916
  const result = {
846
917
  disabled_skills: mergeArraysUnique(mergeArraysUnique(mergeArraysUnique(DEFAULT_CONFIG.disabled_skills, userConfig?.disabled_skills), projectConfig?.disabled_skills), customConfig?.disabled_skills),
847
918
  disabled_agents: mergeArraysUnique(mergeArraysUnique(mergeArraysUnique(DEFAULT_CONFIG.disabled_agents, userConfig?.disabled_agents), projectConfig?.disabled_agents), customConfig?.disabled_agents),
@@ -851,10 +922,73 @@ function loadConfig(projectDir) {
851
922
  ...userConfig?.bootstrap,
852
923
  ...projectConfig?.bootstrap,
853
924
  ...customConfig?.bootstrap
854
- }
925
+ },
926
+ agents: overlayValues(overlays.agents),
927
+ categories: overlayValues(overlays.categories)
928
+ };
929
+ return { config: result, overlays };
930
+ }
931
+ function mergeOverlaySources(sources) {
932
+ const result = {
933
+ agents: {},
934
+ categories: {}
855
935
  };
936
+ for (const source of sources) {
937
+ mergeOverlayMap(result.agents, source, "agents");
938
+ mergeOverlayMap(result.categories, source, "categories");
939
+ }
940
+ return result;
941
+ }
942
+ function mergeOverlayMap(target, source, mapKey) {
943
+ const overlayMap = source.config[mapKey];
944
+ if (overlayMap === undefined)
945
+ return;
946
+ if (!isRecord(overlayMap)) {
947
+ throwInvalidOverlay(source.path, mapKey);
948
+ }
949
+ for (const [key, value] of Object.entries(overlayMap)) {
950
+ const keyPath = `${mapKey}.${key}`;
951
+ if (!isRecord(value)) {
952
+ throwInvalidOverlay(source.path, keyPath);
953
+ }
954
+ if (source.trust === "project") {
955
+ rejectProjectSecurityOverlay(source.path, keyPath, value);
956
+ }
957
+ const previous = target[key];
958
+ const nextValue = source.trust === "project" && previous ? preserveSecurityFields(previous.value, value) : value;
959
+ target[key] = {
960
+ value: nextValue,
961
+ sourcePath: source.path,
962
+ keyPath
963
+ };
964
+ }
965
+ }
966
+ function rejectProjectSecurityOverlay(sourcePath, keyPath, value) {
967
+ for (const field of SECURITY_OVERLAY_FIELDS) {
968
+ if (Object.hasOwn(value, field)) {
969
+ throw new Error(`Invalid Systematic config in ${sourcePath}: ${keyPath}.${field} is only valid in user config or OPENCODE_CONFIG_DIR config`);
970
+ }
971
+ }
972
+ }
973
+ function preserveSecurityFields(previous, next) {
974
+ const result = { ...next };
975
+ for (const field of SECURITY_OVERLAY_FIELDS) {
976
+ if (Object.hasOwn(previous, field)) {
977
+ result[field] = previous[field];
978
+ }
979
+ }
856
980
  return result;
857
981
  }
982
+ function overlayValues(overlays) {
983
+ const result = {};
984
+ for (const [key, overlay] of Object.entries(overlays)) {
985
+ result[key] = overlay.value;
986
+ }
987
+ return result;
988
+ }
989
+ function throwInvalidOverlay(sourcePath, keyPath) {
990
+ throw new Error(`Invalid Systematic config in ${sourcePath}: ${keyPath} must be an object`);
991
+ }
858
992
  function getConfigPaths(projectDir) {
859
993
  const homeDir = os.homedir();
860
994
  const customConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim();
@@ -909,14 +1043,14 @@ function formatFrontmatter(data) {
909
1043
  }
910
1044
 
911
1045
  // src/lib/validation.ts
912
- function isRecord(value) {
1046
+ function isRecord2(value) {
913
1047
  return typeof value === "object" && value !== null && !Array.isArray(value);
914
1048
  }
915
1049
  function isPermissionSetting(value) {
916
1050
  return value === "ask" || value === "allow" || value === "deny";
917
1051
  }
918
1052
  function isToolsMap(value) {
919
- if (!isRecord(value))
1053
+ if (!isRecord2(value))
920
1054
  return false;
921
1055
  return Object.values(value).every((entry) => typeof entry === "boolean");
922
1056
  }
@@ -935,7 +1069,7 @@ function extractBashPermission(data) {
935
1069
  const bash = data.bash;
936
1070
  if (isPermissionSetting(bash))
937
1071
  return bash;
938
- if (isRecord(bash)) {
1072
+ if (isRecord2(bash)) {
939
1073
  const entries = Object.entries(bash);
940
1074
  if (entries.every(([, setting]) => isPermissionSetting(setting))) {
941
1075
  return Object.fromEntries(entries);
@@ -962,7 +1096,7 @@ function buildPermissionObject(edit, bash, webfetch, doom_loop, external_directo
962
1096
  return Object.keys(permission).length > 0 ? permission : undefined;
963
1097
  }
964
1098
  function normalizePermission(value) {
965
- if (!isRecord(value))
1099
+ if (!isRecord2(value))
966
1100
  return;
967
1101
  const bash = extractBashPermission(value);
968
1102
  if (bash === null)
@@ -1071,6 +1205,7 @@ function extractAgentFrontmatter(content) {
1071
1205
  description: extractString(data, "description"),
1072
1206
  prompt: body.trim(),
1073
1207
  model: extractNonEmptyString(data, "model"),
1208
+ variant: extractNonEmptyString(data, "variant"),
1074
1209
  temperature: extractNumber(data, "temperature"),
1075
1210
  top_p: extractNumber(data, "top_p"),
1076
1211
  tools: isToolsMap(data.tools) ? data.tools : undefined,
@@ -1420,7 +1555,7 @@ function extractFrontmatter(filePath) {
1420
1555
  }
1421
1556
  const metadataRaw = data.metadata;
1422
1557
  let metadata;
1423
- if (isRecord(metadataRaw)) {
1558
+ if (isRecord2(metadataRaw)) {
1424
1559
  const entries = Object.entries(metadataRaw);
1425
1560
  if (entries.every(([, v]) => typeof v === "string")) {
1426
1561
  metadata = Object.fromEntries(entries);
@@ -1477,4 +1612,4 @@ function findSkillsInDir(dir, maxDepth = 3) {
1477
1612
  return skills;
1478
1613
  }
1479
1614
 
1480
- export { parseFrontmatter, loadConfig, getConfigPaths, findAgentsInDir, extractAgentFrontmatter, findCommandsInDir, extractCommandFrontmatter, convertContent, convertFileWithCache, findSkillsInDir };
1615
+ export { parseFrontmatter, loadConfig, loadConfigWithSources, getConfigPaths, isRecord2 as isRecord, isPermissionSetting, isAgentMode, findAgentsInDir, extractAgentFrontmatter, findCommandsInDir, extractCommandFrontmatter, convertContent, convertFileWithCache, findSkillsInDir };
package/dist/index.js CHANGED
@@ -6,13 +6,17 @@ import {
6
6
  findAgentsInDir,
7
7
  findCommandsInDir,
8
8
  findSkillsInDir,
9
+ isAgentMode,
10
+ isPermissionSetting,
11
+ isRecord,
9
12
  loadConfig,
13
+ loadConfigWithSources,
10
14
  parseFrontmatter
11
- } from "./index-k9tdxh0p.js";
15
+ } from "./index-488txzkn.js";
12
16
 
13
17
  // src/index.ts
14
- import fs3 from "fs";
15
- import path4 from "path";
18
+ import fs4 from "fs";
19
+ import path5 from "path";
16
20
  import { fileURLToPath } from "url";
17
21
 
18
22
  // src/lib/bootstrap.ts
@@ -72,8 +76,304 @@ ${toolMapping}
72
76
  </SYSTEMATIC_WORKFLOWS>`;
73
77
  }
74
78
 
75
- // src/lib/skill-loader.ts
79
+ // src/lib/agent-overlays.ts
80
+ import fs2 from "fs";
76
81
  import path2 from "path";
82
+ var ALLOWED_OVERLAY_FIELDS = new Set([
83
+ "model",
84
+ "variant",
85
+ "temperature",
86
+ "top_p",
87
+ "permission",
88
+ "mode",
89
+ "color",
90
+ "steps",
91
+ "hidden",
92
+ "disable",
93
+ "skills"
94
+ ]);
95
+ function buildBundledAgentInventory(agentsDir, disabledAgents) {
96
+ const categories = readCategoryDirs(agentsDir);
97
+ const agentsByQualifiedId = {};
98
+ const stemCategories = new Map;
99
+ const disabledSet = new Set(disabledAgents);
100
+ for (const category of categories) {
101
+ for (const fileName of readMarkdownFiles(path2.join(agentsDir, category))) {
102
+ const key = fileName.replace(/\.md$/, "");
103
+ const id = `${category}/${key}`;
104
+ agentsByQualifiedId[id] = {
105
+ id,
106
+ key,
107
+ category,
108
+ file: path2.join(agentsDir, category, fileName),
109
+ disabled: disabledSet.has(key) || disabledSet.has(id)
110
+ };
111
+ const existing = stemCategories.get(key) ?? [];
112
+ existing.push(category);
113
+ stemCategories.set(key, existing);
114
+ }
115
+ }
116
+ const duplicateStems = Array.from(stemCategories.entries()).filter(([, seenCategories]) => seenCategories.length > 1);
117
+ if (duplicateStems.length > 0) {
118
+ const details = duplicateStems.map(([stem, seenCategories]) => `Duplicate bundled agent stem "${stem}" in categories: ${seenCategories.join(", ")}`).join("; ");
119
+ throw new Error(details);
120
+ }
121
+ const aliases = {};
122
+ for (const entry of Object.values(agentsByQualifiedId)) {
123
+ aliases[entry.id] = entry.id;
124
+ aliases[entry.key] = entry.id;
125
+ }
126
+ return { agentsByQualifiedId, aliases, categories };
127
+ }
128
+ function validateAgentOverlays({
129
+ inventory,
130
+ overlays,
131
+ nativeAgents = {},
132
+ enabledSkills
133
+ }) {
134
+ const skillSet = enabledSkills ? new Set(enabledSkills) : undefined;
135
+ const agents = validateExactAgentOverlays(inventory, overlays, nativeAgents, skillSet);
136
+ const categories = validateCategoryOverlays(inventory, overlays, skillSet);
137
+ return { agents, categories };
138
+ }
139
+ function resolveAgentOverlaySet(overlays) {
140
+ return {
141
+ agentsByTargetId: new Map(overlays.agents.map((overlay) => [overlay.target.id, overlay])),
142
+ categoriesByKey: new Map(overlays.categories.map((overlay) => [overlay.key, overlay]))
143
+ };
144
+ }
145
+ function inferBuiltInTemperature(name, description) {
146
+ const sample = `${name} ${description ?? ""}`.toLowerCase();
147
+ if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) {
148
+ return 0.1;
149
+ }
150
+ if (/(plan|planning|architecture|strategist|analysis|research)/.test(sample)) {
151
+ return 0.2;
152
+ }
153
+ if (/(doc|readme|changelog|editor|writer)/.test(sample)) {
154
+ return 0.3;
155
+ }
156
+ if (/(brainstorm|creative|ideate|design|concept)/.test(sample)) {
157
+ return 0.6;
158
+ }
159
+ return 0.3;
160
+ }
161
+ function validateExactAgentOverlays(inventory, overlays, nativeAgents, enabledSkills) {
162
+ const result = [];
163
+ const seenTargets = new Map;
164
+ for (const [key, overlay] of Object.entries(overlays.agents)) {
165
+ const targetId = inventory.aliases[key];
166
+ if (!targetId) {
167
+ throwConfigError(overlay.sourcePath, overlay.keyPath, `unknown bundled agent. Valid agents: ${validAgentKeys(inventory).join(", ")}`);
168
+ }
169
+ const previousKeyPath = seenTargets.get(targetId);
170
+ if (previousKeyPath) {
171
+ throwConfigError(overlay.sourcePath, overlay.keyPath, `Duplicate Systematic agent overlay target "${targetId}" from ${previousKeyPath} and ${overlay.keyPath}`);
172
+ }
173
+ seenTargets.set(targetId, overlay.keyPath);
174
+ const target = inventory.agentsByQualifiedId[targetId];
175
+ if (!target) {
176
+ throwConfigError(overlay.sourcePath, overlay.keyPath, `unknown bundled agent target "${targetId}"`);
177
+ }
178
+ if (Object.hasOwn(nativeAgents, target.key)) {
179
+ throwConfigError(overlay.sourcePath, overlay.keyPath, `conflicts with native OpenCode agent.${target.key}; native agents are replacements for bundled agents`);
180
+ }
181
+ validateOverlayFields(overlay, "agent", enabledSkills);
182
+ result.push({
183
+ key,
184
+ target,
185
+ value: overlay.value,
186
+ sourcePath: overlay.sourcePath,
187
+ keyPath: overlay.keyPath
188
+ });
189
+ }
190
+ return result;
191
+ }
192
+ function validateCategoryOverlays(inventory, overlays, enabledSkills) {
193
+ const categories = new Set(inventory.categories);
194
+ const result = [];
195
+ for (const [key, overlay] of Object.entries(overlays.categories)) {
196
+ if (!categories.has(key)) {
197
+ throwConfigError(overlay.sourcePath, overlay.keyPath, `unknown bundled agent category. Valid categories: ${inventory.categories.join(", ")}`);
198
+ }
199
+ validateOverlayFields(overlay, "category", enabledSkills);
200
+ result.push({
201
+ key,
202
+ value: overlay.value,
203
+ sourcePath: overlay.sourcePath,
204
+ keyPath: overlay.keyPath
205
+ });
206
+ }
207
+ return result;
208
+ }
209
+ function validateOverlayFields(overlay, targetType, enabledSkills) {
210
+ if (Object.hasOwn(overlay.value, "skills") && hasPermissionSkill(overlay.value.permission)) {
211
+ throwConfigError(overlay.sourcePath, overlay.keyPath, "cannot set both skills and permission.skill in the same overlay object");
212
+ }
213
+ for (const [field, value] of Object.entries(overlay.value)) {
214
+ const keyPath = `${overlay.keyPath}.${field}`;
215
+ if (!ALLOWED_OVERLAY_FIELDS.has(field)) {
216
+ throwConfigError(overlay.sourcePath, keyPath, `unsupported agent overlay field "${field}"`);
217
+ }
218
+ if (targetType === "category" && field === "disable") {
219
+ throwConfigError(overlay.sourcePath, keyPath, "disable is only valid for exact agent overlays");
220
+ }
221
+ validateOverlayFieldValue(overlay.sourcePath, keyPath, field, value, enabledSkills);
222
+ }
223
+ }
224
+ function hasPermissionSkill(permission) {
225
+ return isRecord(permission) && Object.hasOwn(permission, "skill");
226
+ }
227
+ function validateOverlayFieldValue(sourcePath, keyPath, field, value, enabledSkills) {
228
+ switch (field) {
229
+ case "model":
230
+ validateModel(sourcePath, keyPath, value);
231
+ return;
232
+ case "variant":
233
+ validateNonEmptyString(sourcePath, keyPath, value);
234
+ return;
235
+ case "temperature":
236
+ validateTemperature(sourcePath, keyPath, value);
237
+ return;
238
+ case "top_p":
239
+ validateTopP(sourcePath, keyPath, value);
240
+ return;
241
+ case "permission":
242
+ validatePermission(sourcePath, keyPath, value);
243
+ return;
244
+ case "mode":
245
+ validateMode(sourcePath, keyPath, value);
246
+ return;
247
+ case "color":
248
+ validateColor(sourcePath, keyPath, value);
249
+ return;
250
+ case "steps":
251
+ validatePositiveInteger(sourcePath, keyPath, value);
252
+ return;
253
+ case "hidden":
254
+ case "disable":
255
+ validateBoolean(sourcePath, keyPath, value);
256
+ return;
257
+ case "skills":
258
+ validateSkills(sourcePath, keyPath, value, enabledSkills);
259
+ return;
260
+ }
261
+ }
262
+ function validateModel(sourcePath, keyPath, value) {
263
+ if (typeof value !== "string") {
264
+ throwConfigError(sourcePath, keyPath, "must be a provider/model string");
265
+ }
266
+ if (value !== value.trim() || /\s/.test(value)) {
267
+ throwConfigError(sourcePath, keyPath, "must be a provider/model string");
268
+ }
269
+ const slashIndex = value.indexOf("/");
270
+ if (value === "" || slashIndex <= 0 || slashIndex === value.length - 1) {
271
+ throwConfigError(sourcePath, keyPath, "must be a provider/model string");
272
+ }
273
+ }
274
+ function validateNonEmptyString(sourcePath, keyPath, value) {
275
+ if (typeof value !== "string" || value === "" || value !== value.trim()) {
276
+ throwConfigError(sourcePath, keyPath, "must be a non-empty string");
277
+ }
278
+ }
279
+ function validateTemperature(sourcePath, keyPath, value) {
280
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
281
+ throwConfigError(sourcePath, keyPath, "must be a non-negative finite number");
282
+ }
283
+ }
284
+ function validateTopP(sourcePath, keyPath, value) {
285
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
286
+ throwConfigError(sourcePath, keyPath, "must be a number from 0 to 1");
287
+ }
288
+ }
289
+ function validatePositiveInteger(sourcePath, keyPath, value) {
290
+ if (!Number.isInteger(value) || value < 1) {
291
+ throwConfigError(sourcePath, keyPath, "must be a positive integer");
292
+ }
293
+ }
294
+ function validateBoolean(sourcePath, keyPath, value) {
295
+ if (typeof value !== "boolean") {
296
+ throwConfigError(sourcePath, keyPath, "must be a boolean");
297
+ }
298
+ }
299
+ function validateMode(sourcePath, keyPath, value) {
300
+ if (!isAgentMode(value)) {
301
+ throwConfigError(sourcePath, keyPath, "must be one of: subagent, primary, all");
302
+ }
303
+ }
304
+ function validateColor(sourcePath, keyPath, value) {
305
+ if (typeof value !== "string" || value !== value.trim() || !isOpenCodeColor(value)) {
306
+ throwConfigError(sourcePath, keyPath, "must be an OpenCode-compatible color string");
307
+ }
308
+ }
309
+ function isOpenCodeColor(value) {
310
+ if (value === "")
311
+ return false;
312
+ if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value))
313
+ return true;
314
+ return /^[a-zA-Z][a-zA-Z0-9-]*$/.test(value);
315
+ }
316
+ function validateSkills(sourcePath, keyPath, value, enabledSkills) {
317
+ if (!Array.isArray(value) || !value.every((skill) => typeof skill === "string" && skill !== "" && skill === skill.trim())) {
318
+ throwConfigError(sourcePath, keyPath, "must be an array of non-empty strings");
319
+ }
320
+ if (!enabledSkills)
321
+ return;
322
+ for (const skill of value) {
323
+ if (!enabledSkills.has(skill)) {
324
+ throwConfigError(sourcePath, keyPath, `unknown or disabled skill "${skill}"`);
325
+ }
326
+ }
327
+ }
328
+ function validatePermission(sourcePath, keyPath, value) {
329
+ if (!isRecord(value)) {
330
+ throwConfigError(sourcePath, keyPath, "must be an object");
331
+ }
332
+ for (const [toolKey, rule] of Object.entries(value)) {
333
+ if (toolKey.trim() === "") {
334
+ throwConfigError(sourcePath, `${keyPath}.${toolKey}`, "must use a non-empty tool key");
335
+ }
336
+ validatePermissionRule(sourcePath, `${keyPath}.${toolKey}`, rule);
337
+ }
338
+ }
339
+ function validatePermissionRule(sourcePath, keyPath, value) {
340
+ if (isPermissionSetting(value))
341
+ return;
342
+ if (!isRecord(value)) {
343
+ throwConfigError(sourcePath, keyPath, "must be ask, allow, deny, or an object of pattern rules");
344
+ }
345
+ for (const [pattern, setting] of Object.entries(value)) {
346
+ if (pattern.trim() === "") {
347
+ throwConfigError(sourcePath, `${keyPath}.${pattern}`, "must use a non-empty permission pattern");
348
+ }
349
+ if (!isPermissionSetting(setting)) {
350
+ throwConfigError(sourcePath, `${keyPath}.${pattern}`, "must be ask, allow, or deny");
351
+ }
352
+ }
353
+ }
354
+ function readCategoryDirs(agentsDir) {
355
+ try {
356
+ return fs2.readdirSync(agentsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
357
+ } catch {
358
+ return [];
359
+ }
360
+ }
361
+ function readMarkdownFiles(categoryDir) {
362
+ try {
363
+ return fs2.readdirSync(categoryDir, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => entry.name).sort();
364
+ } catch {
365
+ return [];
366
+ }
367
+ }
368
+ function validAgentKeys(inventory) {
369
+ return Object.keys(inventory.aliases).sort();
370
+ }
371
+ function throwConfigError(sourcePath, keyPath, message) {
372
+ throw new Error(`Invalid Systematic config in ${sourcePath}: ${keyPath} ${message}`);
373
+ }
374
+
375
+ // src/lib/skill-loader.ts
376
+ import path3 from "path";
77
377
  var SKILL_PREFIX = "systematic:";
78
378
  var SKILL_DESCRIPTION_PREFIX = "(Systematic - Skill) ";
79
379
  function formatSkillCommandName(name) {
@@ -90,7 +390,7 @@ function formatSkillDescription(description, fallbackName) {
90
390
  return `${SKILL_DESCRIPTION_PREFIX}${desc}`;
91
391
  }
92
392
  function wrapSkillTemplate(skillPath, body) {
93
- const skillDir = path2.dirname(skillPath);
393
+ const skillDir = path3.dirname(skillPath);
94
394
  return `<skill-instruction>
95
395
  Base directory for this skill: ${skillDir}/
96
396
  File references (@path) in this skill are relative to this directory.
@@ -154,6 +454,7 @@ function loadAgentAsConfig(agentInfo) {
154
454
  description,
155
455
  prompt,
156
456
  model,
457
+ variant,
157
458
  temperature,
158
459
  top_p,
159
460
  tools,
@@ -170,6 +471,8 @@ function loadAgentAsConfig(agentInfo) {
170
471
  };
171
472
  if (model !== undefined)
172
473
  config.model = model;
474
+ if (variant !== undefined)
475
+ config.variant = variant;
173
476
  if (temperature !== undefined)
174
477
  config.temperature = temperature;
175
478
  if (top_p !== undefined)
@@ -237,19 +540,122 @@ function loadSkillAsCommand(loaded) {
237
540
  config.subtask = loaded.subtask;
238
541
  return config;
239
542
  }
240
- function collectAgents(dir, disabledAgents) {
543
+ function collectAgents(dir, disabledAgents, nativeAgents, overlays) {
241
544
  const agents = {};
242
545
  const agentList = findAgentsInDir(dir);
546
+ const disabledSet = new Set(disabledAgents);
243
547
  for (const agentInfo of agentList) {
244
- if (disabledAgents.includes(agentInfo.name))
548
+ const id = agentInfo.category ? `${agentInfo.category}/${agentInfo.name}` : agentInfo.name;
549
+ if (disabledSet.has(agentInfo.name) || disabledSet.has(id))
550
+ continue;
551
+ if (Object.hasOwn(nativeAgents, agentInfo.name))
552
+ continue;
553
+ const exactOverlay = overlays.agentsByTargetId.get(id);
554
+ if (exactOverlay?.value.disable === true)
245
555
  continue;
246
556
  const config = loadAgentAsConfig(agentInfo);
247
557
  if (config) {
248
- agents[agentInfo.name] = config;
558
+ agents[agentInfo.name] = applyAgentOverlays(config, agentInfo, overlays);
249
559
  }
250
560
  }
251
561
  return agents;
252
562
  }
563
+ function applyAgentOverlays(config, agentInfo, overlays) {
564
+ const id = agentInfo.category ? `${agentInfo.category}/${agentInfo.name}` : agentInfo.name;
565
+ const categoryOverlay = agentInfo.category ? overlays.categoriesByKey.get(agentInfo.category) : undefined;
566
+ const exactOverlay = overlays.agentsByTargetId.get(id);
567
+ const result = { ...config };
568
+ const permissionRules = createPermissionRuleAccumulator();
569
+ const hasPermissionOverlay = overlayControlsPermission(categoryOverlay?.value) || overlayControlsPermission(exactOverlay?.value);
570
+ if (hasPermissionOverlay && isRecord(config.permission)) {
571
+ addPermissionRules(permissionRules, config.permission);
572
+ }
573
+ result.temperature = inferBuiltInTemperature(agentInfo.name, result.description);
574
+ if (categoryOverlay) {
575
+ applyOverlayObject(result, categoryOverlay.value, permissionRules);
576
+ }
577
+ if (exactOverlay) {
578
+ applyOverlayObject(result, exactOverlay.value, permissionRules);
579
+ }
580
+ if (hasPermissionOverlay) {
581
+ const permission = permissionFromRules(permissionRules);
582
+ if (permission) {
583
+ result.permission = permission;
584
+ } else {
585
+ delete result.permission;
586
+ }
587
+ }
588
+ return result;
589
+ }
590
+ function overlayControlsPermission(overlay) {
591
+ return overlay !== undefined && (Object.hasOwn(overlay, "permission") || Object.hasOwn(overlay, "skills"));
592
+ }
593
+ var OVERLAY_ASSIGN_FIELDS = [
594
+ "model",
595
+ "variant",
596
+ "temperature",
597
+ "top_p",
598
+ "mode",
599
+ "color",
600
+ "steps",
601
+ "hidden"
602
+ ];
603
+ function applyOverlayObject(target, overlay, permissionRules) {
604
+ for (const field of OVERLAY_ASSIGN_FIELDS) {
605
+ if (Object.hasOwn(overlay, field)) {
606
+ target[field] = overlay[field];
607
+ }
608
+ }
609
+ if (isRecord(overlay.permission)) {
610
+ addPermissionRules(permissionRules, overlay.permission);
611
+ }
612
+ if (Array.isArray(overlay.skills)) {
613
+ addManagedSkillRules(permissionRules, overlay.skills);
614
+ }
615
+ }
616
+ function createPermissionRuleAccumulator() {
617
+ return new Map;
618
+ }
619
+ function addPermissionRules(accumulator, permission) {
620
+ for (const [tool, rule] of Object.entries(permission)) {
621
+ if (isPermissionSettingValue(rule)) {
622
+ setPermissionRule(accumulator, tool, "*", rule);
623
+ continue;
624
+ }
625
+ if (!isRecord(rule))
626
+ continue;
627
+ for (const [pattern, setting] of Object.entries(rule)) {
628
+ if (isPermissionSettingValue(setting)) {
629
+ setPermissionRule(accumulator, tool, pattern, setting);
630
+ }
631
+ }
632
+ }
633
+ }
634
+ function addManagedSkillRules(accumulator, skills) {
635
+ setPermissionRule(accumulator, "skill", "*", "deny");
636
+ for (const skill of skills) {
637
+ if (typeof skill === "string") {
638
+ setPermissionRule(accumulator, "skill", skill, "allow");
639
+ }
640
+ }
641
+ }
642
+ function permissionFromRules(accumulator) {
643
+ const permission = {};
644
+ for (const [tool, rules] of accumulator) {
645
+ permission[tool] = Object.fromEntries(rules);
646
+ }
647
+ return Object.keys(permission).length > 0 ? permission : undefined;
648
+ }
649
+ function setPermissionRule(accumulator, tool, pattern, setting) {
650
+ const rules = accumulator.get(tool) ?? new Map;
651
+ if (rules.has(pattern))
652
+ rules.delete(pattern);
653
+ rules.set(pattern, setting);
654
+ accumulator.set(tool, rules);
655
+ }
656
+ function isPermissionSettingValue(value) {
657
+ return value === "ask" || value === "allow" || value === "deny";
658
+ }
253
659
  function collectCommands(dir, disabledCommands) {
254
660
  const commands = {};
255
661
  const commandList = findCommandsInDir(dir);
@@ -280,19 +686,32 @@ function collectSkillsAsCommands(dir, disabledSkills) {
280
686
  }
281
687
  return commands;
282
688
  }
689
+ function collectEnabledSkillNames(dir, disabledSkills) {
690
+ const disabledSet = new Set(disabledSkills);
691
+ return findSkillsInDir(dir).filter((skillInfo) => !disabledSet.has(skillInfo.name)).map((skillInfo) => skillInfo.name);
692
+ }
283
693
  function createConfigHandler(deps) {
284
694
  const { directory, bundledSkillsDir, bundledAgentsDir, bundledCommandsDir } = deps;
285
695
  return async (config) => {
286
- const systematicConfig = loadConfig(directory);
287
- const bundledAgents = collectAgents(bundledAgentsDir, systematicConfig.disabled_agents);
288
- const bundledCommands = collectCommands(bundledCommandsDir, systematicConfig.disabled_commands);
696
+ const { config: systematicConfig, overlays } = loadConfigWithSources(directory);
697
+ const existingAgents = { ...config.agent ?? {} };
698
+ const existingCommands = { ...config.command ?? {} };
289
699
  const bundledSkills = collectSkillsAsCommands(bundledSkillsDir, systematicConfig.disabled_skills);
290
- const existingAgents = config.agent ?? {};
700
+ const enabledSkillNames = collectEnabledSkillNames(bundledSkillsDir, systematicConfig.disabled_skills);
701
+ const inventory = buildBundledAgentInventory(bundledAgentsDir, systematicConfig.disabled_agents);
702
+ const validatedOverlays = validateAgentOverlays({
703
+ inventory,
704
+ overlays,
705
+ nativeAgents: existingAgents,
706
+ enabledSkills: enabledSkillNames
707
+ });
708
+ const resolvedOverlays = resolveAgentOverlaySet(validatedOverlays);
709
+ const bundledAgents = collectAgents(bundledAgentsDir, systematicConfig.disabled_agents, existingAgents, resolvedOverlays);
710
+ const bundledCommands = collectCommands(bundledCommandsDir, systematicConfig.disabled_commands);
291
711
  config.agent = {
292
712
  ...bundledAgents,
293
713
  ...existingAgents
294
714
  };
295
- const existingCommands = config.command ?? {};
296
715
  config.command = {
297
716
  ...bundledCommands,
298
717
  ...bundledSkills,
@@ -303,11 +722,12 @@ function createConfigHandler(deps) {
303
722
  }
304
723
  function registerSkillsPaths(config, skillsDir) {
305
724
  const extended = config;
306
- extended.skills ??= {};
307
- extended.skills.paths ??= [];
308
- if (!extended.skills.paths.includes(skillsDir)) {
309
- extended.skills.paths.push(skillsDir);
310
- }
725
+ const paths = extended.skills?.paths ?? [];
726
+ const nextPaths = paths.includes(skillsDir) ? [...paths] : [...paths, skillsDir];
727
+ extended.skills = {
728
+ ...extended.skills,
729
+ paths: nextPaths
730
+ };
311
731
  }
312
732
 
313
733
  // src/lib/plugin-singleton.ts
@@ -342,8 +762,8 @@ async function plugInOnce({
342
762
  }
343
763
 
344
764
  // src/lib/skill-tool.ts
345
- import fs2 from "fs";
346
- import path3 from "path";
765
+ import fs3 from "fs";
766
+ import path4 from "path";
347
767
  import { pathToFileURL } from "url";
348
768
  import { tool } from "@opencode-ai/plugin/tool";
349
769
  function formatSkillsXml(skills) {
@@ -369,17 +789,17 @@ function discoverSkillFiles(dir, limit = 10) {
369
789
  function handleEntry(entry, currentDir) {
370
790
  if (entry.isDirectory()) {
371
791
  if (!shouldSkipDirectory(entry.name)) {
372
- recurse(path3.resolve(currentDir, entry.name));
792
+ recurse(path4.resolve(currentDir, entry.name));
373
793
  }
374
794
  } else if (shouldIncludeFile(entry.name)) {
375
- files.push(path3.resolve(currentDir, entry.name));
795
+ files.push(path4.resolve(currentDir, entry.name));
376
796
  }
377
797
  }
378
798
  function recurse(currentDir) {
379
799
  if (files.length >= limit)
380
800
  return;
381
801
  try {
382
- const entries = fs2.readdirSync(currentDir, { withFileTypes: true });
802
+ const entries = fs3.readdirSync(currentDir, { withFileTypes: true });
383
803
  for (const entry of entries) {
384
804
  if (files.length >= limit)
385
805
  break;
@@ -460,7 +880,7 @@ function createSkillTool(options) {
460
880
  throw new Error(`Skill "${requestedName}" not found. Available systematic skills: ${availableSystematic.join(", ")}`);
461
881
  }
462
882
  const body = extractSkillBody(matchedSkill.wrappedTemplate);
463
- const dir = path3.dirname(matchedSkill.skillFile);
883
+ const dir = path4.dirname(matchedSkill.skillFile);
464
884
  const base = pathToFileURL(dir).href;
465
885
  const files = discoverSkillFiles(dir);
466
886
  await context.ask({
@@ -497,12 +917,12 @@ function createSkillTool(options) {
497
917
  }
498
918
 
499
919
  // src/index.ts
500
- var __dirname2 = path4.dirname(fileURLToPath(import.meta.url));
501
- var packageRoot = path4.resolve(__dirname2, "..");
502
- var bundledSkillsDir = path4.join(packageRoot, "skills");
503
- var bundledAgentsDir = path4.join(packageRoot, "agents");
504
- var bundledCommandsDir = path4.join(packageRoot, "commands");
505
- var packageJsonPath = path4.join(packageRoot, "package.json");
920
+ var __dirname2 = path5.dirname(fileURLToPath(import.meta.url));
921
+ var packageRoot = path5.resolve(__dirname2, "..");
922
+ var bundledSkillsDir = path5.join(packageRoot, "skills");
923
+ var bundledAgentsDir = path5.join(packageRoot, "agents");
924
+ var bundledCommandsDir = path5.join(packageRoot, "commands");
925
+ var packageJsonPath = path5.join(packageRoot, "package.json");
506
926
  var hasLoggedInit = false;
507
927
  var applyBootstrapContent = (output, content) => {
508
928
  if (output.system.length > 0) {
@@ -515,9 +935,9 @@ ${content}`;
515
935
  };
516
936
  var getPackageVersion = () => {
517
937
  try {
518
- if (!fs3.existsSync(packageJsonPath))
938
+ if (!fs4.existsSync(packageJsonPath))
519
939
  return "unknown";
520
- const content = fs3.readFileSync(packageJsonPath, "utf8");
940
+ const content = fs4.readFileSync(packageJsonPath, "utf8");
521
941
  const parsed = JSON.parse(content);
522
942
  return parsed.version ?? "unknown";
523
943
  } catch {
@@ -0,0 +1,44 @@
1
+ import type { SourcedOverlayConfigMap } from './config.js';
2
+ export interface BundledAgentInventoryEntry {
3
+ id: string;
4
+ key: string;
5
+ category: string;
6
+ file: string;
7
+ disabled: boolean;
8
+ }
9
+ export interface BundledAgentInventory {
10
+ agentsByQualifiedId: Record<string, BundledAgentInventoryEntry>;
11
+ aliases: Record<string, string>;
12
+ categories: string[];
13
+ }
14
+ export interface ValidatedAgentOverlay {
15
+ key: string;
16
+ target: BundledAgentInventoryEntry;
17
+ value: Record<string, unknown>;
18
+ sourcePath: string;
19
+ keyPath: string;
20
+ }
21
+ export interface ValidatedCategoryOverlay {
22
+ key: string;
23
+ value: Record<string, unknown>;
24
+ sourcePath: string;
25
+ keyPath: string;
26
+ }
27
+ export interface ValidatedAgentOverlays {
28
+ agents: ValidatedAgentOverlay[];
29
+ categories: ValidatedCategoryOverlay[];
30
+ }
31
+ export interface ValidateAgentOverlaysOptions {
32
+ inventory: BundledAgentInventory;
33
+ overlays: SourcedOverlayConfigMap;
34
+ nativeAgents?: Record<string, unknown>;
35
+ enabledSkills?: string[];
36
+ }
37
+ export interface ResolvedAgentOverlaySet {
38
+ agentsByTargetId: Map<string, ValidatedAgentOverlay>;
39
+ categoriesByKey: Map<string, ValidatedCategoryOverlay>;
40
+ }
41
+ export declare function buildBundledAgentInventory(agentsDir: string, disabledAgents: string[]): BundledAgentInventory;
42
+ export declare function validateAgentOverlays({ inventory, overlays, nativeAgents, enabledSkills, }: ValidateAgentOverlaysOptions): ValidatedAgentOverlays;
43
+ export declare function resolveAgentOverlaySet(overlays: ValidatedAgentOverlays): ResolvedAgentOverlaySet;
44
+ export declare function inferBuiltInTemperature(name: string, description?: string): number;
@@ -8,6 +8,8 @@ export interface AgentFrontmatter {
8
8
  prompt: string;
9
9
  /** Model to use (provider/model format) */
10
10
  model?: string;
11
+ /** Model variant to use */
12
+ variant?: string;
11
13
  /** Temperature for generation */
12
14
  temperature?: number;
13
15
  /** Top-p sampling */
@@ -13,8 +13,9 @@ export declare function formatAgentDescription(name: string, description: string
13
13
  * This follows the pattern used by oh-my-opencode to inject bundled agents,
14
14
  * skills (as commands), and commands into OpenCode's configuration.
15
15
  *
16
- * Only bundled content is loaded. User/project overrides are not supported.
17
- * Existing OpenCode config is preserved and takes precedence.
16
+ * Bundled content is loaded and then tuned with Systematic agent overlays.
17
+ * Existing native OpenCode agents with the same emitted key are preserved as
18
+ * replacements for bundled agents.
18
19
  */
19
20
  export declare function createConfigHandler(deps: ConfigHandlerDeps): (config: Config) => Promise<void>;
20
21
  /** Register a directory for OpenCode's native skill discovery (`skill` tool). */
@@ -2,14 +2,32 @@ export interface BootstrapConfig {
2
2
  enabled: boolean;
3
3
  file?: string;
4
4
  }
5
+ export type OverlayConfig = Record<string, unknown>;
6
+ export type OverlayConfigMap = Record<string, OverlayConfig>;
7
+ export interface SourcedOverlayConfig {
8
+ value: OverlayConfig;
9
+ sourcePath: string;
10
+ keyPath: string;
11
+ }
12
+ export interface SourcedOverlayConfigMap {
13
+ agents: Record<string, SourcedOverlayConfig>;
14
+ categories: Record<string, SourcedOverlayConfig>;
15
+ }
16
+ export interface SourceAwareConfigResult {
17
+ config: SystematicConfig;
18
+ overlays: SourcedOverlayConfigMap;
19
+ }
5
20
  export interface SystematicConfig {
6
21
  disabled_skills: string[];
7
22
  disabled_agents: string[];
8
23
  disabled_commands: string[];
9
24
  bootstrap: BootstrapConfig;
25
+ agents?: OverlayConfigMap;
26
+ categories?: OverlayConfigMap;
10
27
  }
11
28
  export declare const DEFAULT_CONFIG: SystematicConfig;
12
29
  export declare function loadConfig(projectDir: string): SystematicConfig;
30
+ export declare function loadConfigWithSources(projectDir: string): SourceAwareConfigResult;
13
31
  export declare function getConfigPaths(projectDir: string): {
14
32
  customConfig?: string | undefined;
15
33
  customDir?: string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fro.bot/systematic",
3
- "version": "2.8.1",
3
+ "version": "2.9.0",
4
4
  "description": "Structured engineering workflows for OpenCode",
5
5
  "type": "module",
6
6
  "homepage": "https://fro.bot/systematic",