@heretek-ai/epistemic-swarm 0.7.5 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/config/opencode-snippet.json +102 -149
  3. package/install.sh +81 -58
  4. package/package.json +1 -1
  5. package/plugins/opencode/index.js +283 -0
  6. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  7. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  8. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  9. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  20. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  21. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  22. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  23. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  24. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  25. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  26. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  27. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  28. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  29. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  30. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  31. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  32. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  33. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  34. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  35. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  36. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  37. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  38. package/runner/tests/test_factory.py +15 -7
  39. package/runner/tests/test_opencode_ux.py +37 -27
  40. package/runner/tests/test_opencode_v2_registrars.py +260 -0
  41. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  42. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  43. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  44. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  45. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  46. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  47. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  48. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
@@ -587,6 +587,7 @@ export const OPENCODE_COMMANDS = [
587
587
  '2. Per gate run iumbtems_brainstorm and iumbtems_darkharvest (mock_mode only for dry runs), then synthesize .roadmap/<phase>/ GOAL.md + dossier.json (goal/evidence/acceptance/brief/verdict/hashes; every claim needs a VERIFIED hash).',
588
588
  '3. Spawn the programmer subagent per phase with the phase dossier (cite phase hashes); run qa-a and qa-b (diverged prompts) per phase; track retries with the iumbtems_factory tool (command: phase-add / qa-record; 3 failures escalate to manager). Never invoke factory helper scripts by relative path.',
589
589
  '4. Manager tiebreaks QA disagreements; explicit user sign-off closes each phase.',
590
+ 'Swarm agents use the host-native backend automatically (claude -p on Claude Code, opencode run on OpenCode). Do not debug backend selection, config pins, or binary availability as part of a gate — if a swarm call fails, report the error and continue.',
590
591
  ].join('\n'),
591
592
  },
592
593
  {
@@ -907,9 +908,20 @@ async function registerHostCommands(host) {
907
908
  execute: async (input) => {
908
909
  const args = input?.prompt?.text || '';
909
910
  const prompt = (typeof input?.prompt === 'object' && input?.prompt !== null) ? input.prompt : {};
911
+ // Pin the declared agent for this run: the template says "as the
912
+ // manager agent", so make that true rather than aspirational.
913
+ let switched = false;
914
+ if (cmd.agent) {
915
+ switched = await switchSessionAgent(host, input?.sessionID, cmd.agent);
916
+ log(host, 'debug', 'command agent switch', {
917
+ command: cmd.name, agent: cmd.agent, switched,
918
+ });
919
+ }
910
920
  await host.session.prompt({
911
921
  ...prompt,
912
922
  sessionID: input?.sessionID,
923
+ // No `agent` field here: V2's SessionPromptInput has none (agent
924
+ // mentions only). Pinning happens via switchAgent above.
913
925
  text: cmd.template.split('$ARGUMENTS').join(String(args).trim()),
914
926
  delivery: input?.delivery,
915
927
  });
@@ -1010,6 +1022,237 @@ async function registerCompactionHook(host, context) {
1010
1022
  }
1011
1023
  }
1012
1024
 
1025
+ async function registerHostAgents(host) {
1026
+ // V2 Context exposes `agent.transform` (AgentDomain). Registering the IUMBTEMS
1027
+ // role profiles here removes the config-snippet install dependency: without
1028
+ // them the `/factory` command ran as the generic `build` agent (observed live:
1029
+ // every step of a factory session executed as `build`, not `manager`).
1030
+ if (typeof host?.agent?.transform !== 'function') return [];
1031
+ const snippet = readPkgJson(path.join(PKG_ROOT, 'config', 'opencode-snippet.json'));
1032
+ // V2 config key is `agents` (plural); accept the legacy `agent` key for one
1033
+ // release so older snippet copies keep working.
1034
+ const agentDefs = (snippet && (snippet.agents || snippet.agent)) || {};
1035
+ if (Object.keys(agentDefs).length === 0) return [];
1036
+
1037
+ let existing = new Set();
1038
+ try {
1039
+ const listed = await host.agent.list();
1040
+ existing = new Set((listed?.data || listed || []).map((a) => a?.id || a?.name));
1041
+ } catch (err) {
1042
+ log(host, 'warn', 'agent.list() failed; assuming an empty registry', errDetail(err));
1043
+ }
1044
+
1045
+ const registration = await host.agent.transform((draft) => {
1046
+ const claimed = new Set(existing);
1047
+ const added = [];
1048
+ for (const [name, def] of Object.entries(agentDefs)) {
1049
+ if (claimed.has(name)) continue;
1050
+ claimed.add(name);
1051
+ added.push(name);
1052
+ registerAgent(draft, name, def);
1053
+ }
1054
+ log(host, 'debug', 'agent.transform pass', { added });
1055
+ });
1056
+ try {
1057
+ if (typeof host.agent.reload === 'function') await host.agent.reload();
1058
+ log(host, 'info', 'agent profiles registered', { count: Object.keys(agentDefs).length });
1059
+ } catch (err) {
1060
+ log(host, 'warn', 'agent.reload() failed', errDetail(err));
1061
+ }
1062
+ return registration ? [registration] : [];
1063
+ }
1064
+
1065
+ /** Short role system prompts for the factory seats (skills carry the long form). */
1066
+ const FACTORY_SYSTEM = {
1067
+ manager:
1068
+ 'You are the IUMBTEMS Factory Manager. Own gate discipline: grill until the frontier is settled, run the brainstorm and darkharvest swarms, synthesize .roadmap phase dossiers, spawn the programmer per phase, run qa-a and qa-b, and tiebreak their disagreements. Never write implementation code. Use the iumbtems_factory tool for run state. Explicit user approval advances each gate.',
1069
+ programmer:
1070
+ 'You are the IUMBTEMS Factory Programmer. Implement exactly one phase brief per spawn. Cite phase evidence hashes. Never invoke swarms or other programmers. If the brief is ambiguous or untestable, stop and ask the manager.',
1071
+ 'qa-a':
1072
+ 'You are the IUMBTEMS Factory functional QA. Verify each phase acceptance criterion on the real surface with tests and inspection. Read-only plus test execution; never edit code. Return pass|fail(reason)|conditional(note).',
1073
+ 'qa-b':
1074
+ 'You are the IUMBTEMS Factory adversarial QA. Attack the phase: edge cases, regressions, vacuous acceptance criteria, error paths, resource limits. Read-only plus test execution; never edit code. Return pass|fail(reason)|conditional(note) with reproductions.',
1075
+ };
1076
+
1077
+ /** V1 permission/tool action names -> V2 permission action names. */
1078
+ const ACTION_ALIASES = { bash: 'shell', task: 'subagent', patch: 'edit', write: 'edit' };
1079
+
1080
+ function normalizeAction(action) {
1081
+ const key = String(action || '').trim();
1082
+ return ACTION_ALIASES[key] || key;
1083
+ }
1084
+
1085
+ function normalizeEffect(effect) {
1086
+ return effect === 'deny' || effect === 'ask' ? effect : 'allow';
1087
+ }
1088
+
1089
+ /**
1090
+ * Map one snippet agent definition to an Agent.Info shape.
1091
+ *
1092
+ * Accepts the V2 config form (`permissions: [{action,resource,effect}]`) and
1093
+ * still understands the legacy V1 form (`tools` map + `permission` map) so an
1094
+ * older snippet copy is not silently dropped.
1095
+ */
1096
+ function toAgentInfo(name, def) {
1097
+ const permissions = [];
1098
+ for (const rule of def?.permissions || []) {
1099
+ if (rule && typeof rule === 'object' && typeof rule.action === 'string') {
1100
+ permissions.push({
1101
+ action: normalizeAction(rule.action),
1102
+ resource: typeof rule.resource === 'string' ? rule.resource : '*',
1103
+ effect: normalizeEffect(rule.effect),
1104
+ });
1105
+ }
1106
+ }
1107
+ for (const [tool, enabled] of Object.entries(def?.tools || {})) {
1108
+ permissions.push({
1109
+ action: normalizeAction(tool),
1110
+ resource: '*',
1111
+ effect: enabled ? 'allow' : 'deny',
1112
+ });
1113
+ }
1114
+ for (const [key, value] of Object.entries(def?.permission || {})) {
1115
+ if (typeof value === 'string') {
1116
+ permissions.push({ action: normalizeAction(key), resource: '*', effect: normalizeEffect(value) });
1117
+ } else if (value && typeof value === 'object') {
1118
+ for (const [resource, effect] of Object.entries(value)) {
1119
+ permissions.push({ action: normalizeAction(key), resource, effect: normalizeEffect(effect) });
1120
+ }
1121
+ }
1122
+ }
1123
+ const info = {
1124
+ id: name,
1125
+ name,
1126
+ description: def?.description || `IUMBTEMS ${name}`,
1127
+ mode: def?.mode || 'all',
1128
+ hidden: Boolean(def?.hidden),
1129
+ request: { settings: {}, headers: {}, body: {} },
1130
+ permissions,
1131
+ };
1132
+ if (def?.steps) info.steps = def.steps;
1133
+ if (def?.color) info.color = def.color;
1134
+ // The canonical short role prompt wins; fall back to a snippet-provided one.
1135
+ const system = FACTORY_SYSTEM[name] || def?.system;
1136
+ if (system) info.system = system;
1137
+ return info;
1138
+ }
1139
+
1140
+ /**
1141
+ * Register one agent profile through a V2 `AgentEditor`.
1142
+ *
1143
+ * V2's AgentEditor has NO `add()` — only `list/get/default/update/remove`
1144
+ * (verified against @opencode/plugin@2.0.18 dist/promise/agent.d.ts). Calling
1145
+ * `draft.add(...)` threw `draft.add is not a function` and the host disabled
1146
+ * the whole plugin ("disabled plugin after transform failure", state=agent).
1147
+ *
1148
+ * `update(id, mutate)` upserts: when the id is new the host seeds the draft
1149
+ * from `Agent.Info.default(id)` plus global permission rules, then hands it to
1150
+ * the callback (opencode v2.0.18 agent registry:
1151
+ * `c = agents.get(d) ?? {...default(d), permissions:[...default.permissions, ...I]}`).
1152
+ * The first-party `opencode.config.agent` plugin registers config agents the
1153
+ * same way, so this is the supported path for creating profile agents.
1154
+ *
1155
+ * Seeded permissions are kept and ours appended: rule matching is
1156
+ * last-match-wins, so an explicit `edit: deny` still beats the default allow.
1157
+ */
1158
+ function registerAgent(editor, name, def) {
1159
+ const info = toAgentInfo(name, def);
1160
+ const rules = info.permissions || [];
1161
+ const profile = { ...info };
1162
+ delete profile.permissions;
1163
+ editor.update(name, (agent) => {
1164
+ Object.assign(agent, profile);
1165
+ agent.permissions = [...(agent.permissions || []), ...rules];
1166
+ });
1167
+ }
1168
+
1169
+ /** Canonical skills the plugin registers so agents never hunt the filesystem. */
1170
+ const BUNDLED_SKILLS = [
1171
+ 'factory',
1172
+ 'darkharvest',
1173
+ 'brainstorming',
1174
+ 'grilling',
1175
+ 'swarm_config',
1176
+ 'code_audit',
1177
+ 'oss_scout',
1178
+ 'research_cache',
1179
+ 'epistemic_search',
1180
+ ];
1181
+
1182
+ function parseSkillFrontmatter(text) {
1183
+ const out = {};
1184
+ if (!text.startsWith('---')) return out;
1185
+ const end = text.indexOf('\n---', 3);
1186
+ if (end < 0) return out;
1187
+ for (const line of text.slice(3, end).split('\n')) {
1188
+ const idx = line.indexOf(':');
1189
+ if (idx > 0) out[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
1190
+ }
1191
+ return out;
1192
+ }
1193
+
1194
+ async function registerHostSkills(host) {
1195
+ // V2 Context exposes `skill.transform` (SkillDomain). Live evidence: with no
1196
+ // skills registered, a factory agent ran `find / -name factory.py` and read
1197
+ // the skill prose out of the CLAUDE plugin cache to learn its own mechanics.
1198
+ if (typeof host?.skill?.transform !== 'function') return [];
1199
+ const skills = [];
1200
+ for (const name of BUNDLED_SKILLS) {
1201
+ const skillPath = path.join(PKG_ROOT, 'skills', name, 'SKILL.md');
1202
+ let content;
1203
+ try {
1204
+ content = readFileSync(skillPath, 'utf-8');
1205
+ } catch {
1206
+ continue;
1207
+ }
1208
+ const fm = parseSkillFrontmatter(content);
1209
+ skills.push({
1210
+ id: name,
1211
+ name,
1212
+ description: fm.description || `IUMBTEMS ${name} skill`,
1213
+ path: skillPath,
1214
+ content,
1215
+ autoinvoke: true,
1216
+ });
1217
+ }
1218
+ if (skills.length === 0) return [];
1219
+
1220
+ let existing = new Set();
1221
+ try {
1222
+ const listed = await host.skill.list();
1223
+ existing = new Set((listed?.data || listed || []).map((s) => s?.id || s?.name));
1224
+ } catch (err) {
1225
+ log(host, 'warn', 'skill.list() failed; assuming an empty registry', errDetail(err));
1226
+ }
1227
+
1228
+ const registration = await host.skill.transform((draft) => {
1229
+ const claimed = new Set(existing);
1230
+ const added = [];
1231
+ for (const skill of skills) {
1232
+ if (claimed.has(skill.id)) continue;
1233
+ claimed.add(skill.id);
1234
+ added.push(skill.id);
1235
+ draft.add(skill);
1236
+ }
1237
+ log(host, 'debug', 'skill.transform pass', { added });
1238
+ });
1239
+ try {
1240
+ if (typeof host.skill.reload === 'function') await host.skill.reload();
1241
+ log(host, 'info', 'skills registered', { count: skills.length });
1242
+ } catch (err) {
1243
+ log(host, 'warn', 'skill.reload() failed', errDetail(err));
1244
+ }
1245
+ return registration ? [registration] : [];
1246
+ }
1247
+
1248
+ function readPkgJson(file) {
1249
+ try {
1250
+ return JSON.parse(readFileSync(file, 'utf-8'));
1251
+ } catch {
1252
+ return undefined;
1253
+ }
1254
+ }
1255
+
1013
1256
  async function applyConfigOptions(host, opts, context) {
1014
1257
  if (!opts || typeof opts !== 'object') return;
1015
1258
  const updates = {};
@@ -1043,6 +1286,36 @@ function startNudgeSubscription(host, opts, context, cleanups) {
1043
1286
  }
1044
1287
  }
1045
1288
 
1289
+ /**
1290
+ * Best-effort session agent switch for commands that pin an agent.
1291
+ *
1292
+ * Live gap: `/factory` declared `agent: 'manager'` but the session kept running
1293
+ * as `build`, so the manager playbook was never loaded. The V2 SessionDomain
1294
+ * exposes `switchAgent`; its exact call shape is not pinned in the public types,
1295
+ * so try the plausible shapes and never throw into the host.
1296
+ */
1297
+ async function switchSessionAgent(host, sessionID, agentId) {
1298
+ if (!agentId || !sessionID) return false;
1299
+ const fn = host?.session?.switchAgent;
1300
+ if (typeof fn !== 'function') return false;
1301
+ const attempts = [
1302
+ () => fn.call(host.session, { sessionID, agent: agentId }),
1303
+ () => fn.call(host.session, { sessionID, agentID: agentId }),
1304
+ () => fn.call(host.session, sessionID, agentId),
1305
+ () => fn.call(host.session, agentId),
1306
+ ];
1307
+ for (const attempt of attempts) {
1308
+ try {
1309
+ const res = attempt();
1310
+ if (res && typeof res.then === 'function') await res;
1311
+ return true;
1312
+ } catch {
1313
+ /* try the next call shape */
1314
+ }
1315
+ }
1316
+ return false;
1317
+ }
1318
+
1046
1319
  export function createOpenCodePlugin(context = {}) {
1047
1320
  return {
1048
1321
  id: 'heretek.iumbtems.epistemic-swarm',
@@ -1075,6 +1348,16 @@ export function createOpenCodePlugin(context = {}) {
1075
1348
  } catch (err) {
1076
1349
  log(host, 'error', 'registering slash commands failed', errDetail(err));
1077
1350
  }
1351
+ try {
1352
+ adopt(await registerHostAgents(host));
1353
+ } catch (err) {
1354
+ log(host, 'error', 'registering agent profiles failed', errDetail(err));
1355
+ }
1356
+ try {
1357
+ adopt(await registerHostSkills(host));
1358
+ } catch (err) {
1359
+ log(host, 'error', 'registering skills failed', errDetail(err));
1360
+ }
1078
1361
  try {
1079
1362
  adopt(await registerHostTools(host));
1080
1363
  } catch (err) {
@@ -214,16 +214,24 @@ class TestFactoryHelper(unittest.TestCase):
214
214
  def test_snippet_factory_roster(self):
215
215
  with open(PROJECT_ROOT / "config" / "opencode-snippet.json") as f:
216
216
  snippet = json.load(f)
217
+ # V2 config key is `agents` (plural) and rules are a `permissions` list.
217
218
  for agent in ("manager", "programmer", "qa-a", "qa-b"):
218
- self.assertIn(agent, snippet["agent"])
219
- manager = snippet["agent"]["manager"]
219
+ self.assertIn(agent, snippet["agents"])
220
+
221
+ def rule(agent, action, resource="*"):
222
+ return next(
223
+ r
224
+ for r in snippet["agents"][agent]["permissions"]
225
+ if r["action"] == action and r.get("resource", "*") == resource
226
+ )
227
+
228
+ manager = snippet["agents"]["manager"]
220
229
  self.assertEqual(manager["mode"], "primary")
221
- task = manager["permission"]["task"]
222
- self.assertEqual(task["*"], "deny")
230
+ self.assertEqual(rule("manager", "subagent")["effect"], "deny")
223
231
  for allowed in ("programmer", "qa-a", "qa-b", "brainstormer", "darkharvester"):
224
- self.assertEqual(task[allowed], "allow")
225
- self.assertEqual(snippet["agent"]["qa-a"]["permission"]["edit"], "deny")
226
- self.assertEqual(snippet["agent"]["qa-b"]["permission"]["edit"], "deny")
232
+ self.assertEqual(rule("manager", "subagent", allowed)["effect"], "allow")
233
+ self.assertEqual(rule("qa-a", "edit")["effect"], "deny")
234
+ self.assertEqual(rule("qa-b", "edit")["effect"], "deny")
227
235
 
228
236
  def test_factory_commands_carry_agent_subtask(self):
229
237
  res = run_node(
@@ -30,6 +30,20 @@ EXPECTED_COMMANDS = [
30
30
  "domainexpansion",
31
31
  ]
32
32
 
33
+ EXPECTED_AGENTS = [
34
+ "code-auditor",
35
+ "oss-scout",
36
+ "alpha-thesis",
37
+ "beta-redteam",
38
+ "epistemic-auditor",
39
+ "brainstormer",
40
+ "darkharvester",
41
+ "manager",
42
+ "programmer",
43
+ "qa-a",
44
+ "qa-b",
45
+ ]
46
+
33
47
 
34
48
  def run_node(code):
35
49
  return subprocess.run(
@@ -259,48 +273,44 @@ class TestInstallOpenCode(unittest.TestCase):
259
273
  self.assertEqual(first.returncode, 0, f"install failed: {first.stderr}")
260
274
  oc_path = home / ".config" / "opencode" / "opencode.json"
261
275
  cfg = json.loads(oc_path.read_text(encoding="utf-8"))
262
- # Bare object repaired into tuple form, preserving user options.
263
- tuples = [
276
+ # V1 `plugin` migrated to V2 `plugins` in object form; the trailing
277
+ # bare options object is folded in and user options are preserved.
278
+ self.assertNotIn("plugin", cfg)
279
+ self.assertFalse(
280
+ any(isinstance(p, list) for p in cfg["plugins"]),
281
+ f"V2 plugins must not use tuple form: {cfg['plugins']}",
282
+ )
283
+ ours = [
264
284
  p
265
- for p in cfg["plugin"]
266
- if isinstance(p, list) and p and p[0] == "@heretek-ai/epistemic-swarm"
285
+ for p in cfg["plugins"]
286
+ if isinstance(p, dict)
287
+ and p.get("package") == "@heretek-ai/epistemic-swarm"
267
288
  ]
268
- self.assertEqual(len(tuples), 1)
289
+ self.assertEqual(len(ours), 1)
269
290
  self.assertEqual(
270
- tuples[0][1],
291
+ ours[0]["options"],
271
292
  {
272
293
  "search_engine": "duckduckgo",
273
294
  "max_iterations": 10,
274
295
  "mode": "research",
275
296
  },
276
297
  )
277
- self.assertFalse(any(isinstance(p, dict) for p in cfg["plugin"]))
278
- # Agents merged from the snippet, MCP server absolute.
279
- for agent in [
280
- "code-auditor",
281
- "oss-scout",
282
- "alpha-thesis",
283
- "beta-redteam",
284
- "epistemic-auditor",
285
- "brainstormer",
286
- "darkharvester",
287
- "manager",
288
- "programmer",
289
- "qa-a",
290
- "qa-b",
291
- ]:
292
- self.assertIn(agent, cfg.get("agent", {}))
293
- self.assertIn("iumbtems", cfg.get("mcp", {}))
294
- cmd = cfg["mcp"]["iumbtems"]["command"]
298
+ self.assertIn("@prevalentware/opencode-goal-plugin", cfg["plugins"])
299
+ # Agents merged from the snippet under the V2 plural key.
300
+ for agent in EXPECTED_AGENTS:
301
+ self.assertIn(agent, cfg.get("agents", {}))
302
+ # MCP server absolute under V2 mcp.servers (no legacy top-level key).
303
+ self.assertNotIn("iumbtems", cfg["mcp"])
304
+ cmd = cfg["mcp"]["servers"]["iumbtems"]["command"]
295
305
  self.assertTrue(Path(cmd[-1]).is_absolute())
296
306
  self.assertEqual(cmd[-1], str(PROJECT_ROOT / "runner" / "mcp_server.py"))
297
- # Skills merged as absolute repo paths (relative entries repaired).
298
- skill_paths = cfg.get("skills", {}).get("paths", [])
307
+ # Skills merged as a V2 list of absolute repo paths.
308
+ skill_paths = cfg.get("skills")
309
+ self.assertIsInstance(skill_paths, list)
299
310
  self.assertTrue(skill_paths)
300
311
  for sp in skill_paths:
301
312
  self.assertTrue(Path(sp).is_absolute(), f"relative skill path: {sp}")
302
313
  self.assertIn(str(PROJECT_ROOT / "skills" / "grilling"), skill_paths)
303
- self.assertFalse(any(not Path(sp).is_absolute() for sp in skill_paths))
304
314
  # Second run changes nothing.
305
315
  before = oc_path.read_bytes()
306
316
  second = self._run_install(home)