@heretek-ai/epistemic-swarm 0.7.6 → 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 +72 -8
  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 +113 -16
  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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epistemic-swarm",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "High-Integrity Dialectic Research Agent Harness for Claude Code enforcing empirical evidence over parametric hallucination.",
5
5
  "author": {
6
6
  "name": "Heretek AI",
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "$schema": "https://opencode.ai/config.json",
3
3
  "mcp": {
4
- "iumbtems": {
5
- "type": "local",
6
- "command": ["python3", "<IUMBTEMS_ROOT>/runner/mcp_server.py"],
7
- "enabled": true
4
+ "servers": {
5
+ "iumbtems": {
6
+ "type": "local",
7
+ "command": ["python3", "<IUMBTEMS_ROOT>/runner/mcp_server.py"],
8
+ "enabled": true
9
+ }
8
10
  }
9
11
  },
10
12
  "plugins": [
@@ -17,187 +19,138 @@
17
19
  }
18
20
  }
19
21
  ],
20
- "agent": {
22
+ "agents": {
21
23
  "code-auditor": {
22
24
  "description": "IUMBTEMS Codebase Auditor: Dialectic architecture mapping and vulnerability red-teaming with line-level proof.",
23
- "tools": {
24
- "read": true,
25
- "write": true,
26
- "grep": true,
27
- "glob": true,
28
- "iumbtems_code_audit": true,
29
- "iumbtems_config": true
30
- }
25
+ "mode": "all",
26
+ "permissions": [
27
+ { "action": "read", "resource": "*", "effect": "allow" },
28
+ { "action": "grep", "resource": "*", "effect": "allow" },
29
+ { "action": "glob", "resource": "*", "effect": "allow" },
30
+ { "action": "edit", "resource": "*", "effect": "allow" },
31
+ { "action": "shell", "resource": "*", "effect": "ask" }
32
+ ]
31
33
  },
32
34
  "oss-scout": {
33
35
  "description": "IUMBTEMS Open Source Scout: Discovers mature OSS packages, audits copyleft licensing, and generates clean-room blueprints.",
34
- "tools": {
35
- "read": true,
36
- "write": true,
37
- "grep": true,
38
- "glob": true,
39
- "iumbtems_oss_scout": true,
40
- "iumbtems_config": true
41
- }
36
+ "mode": "all",
37
+ "permissions": [
38
+ { "action": "read", "resource": "*", "effect": "allow" },
39
+ { "action": "grep", "resource": "*", "effect": "allow" },
40
+ { "action": "glob", "resource": "*", "effect": "allow" },
41
+ { "action": "edit", "resource": "*", "effect": "allow" },
42
+ { "action": "shell", "resource": "*", "effect": "ask" }
43
+ ]
42
44
  },
43
45
  "alpha-thesis": {
44
46
  "description": "IUMBTEMS Proponent Agent: Gathers corroborating primary sources, benchmark results, and empirical proofs.",
45
- "tools": {
46
- "bash": true,
47
- "read": true,
48
- "write": true,
49
- "grep": true,
50
- "glob": true,
51
- "task": true,
52
- "iumbtems_swarm_research": true,
53
- "iumbtems_verify_quote": true,
54
- "iumbtems_config": true
55
- }
47
+ "mode": "all",
48
+ "permissions": [
49
+ { "action": "read", "resource": "*", "effect": "allow" },
50
+ { "action": "grep", "resource": "*", "effect": "allow" },
51
+ { "action": "glob", "resource": "*", "effect": "allow" },
52
+ { "action": "edit", "resource": "*", "effect": "allow" },
53
+ { "action": "shell", "resource": "*", "effect": "ask" },
54
+ { "action": "subagent", "resource": "*", "effect": "allow" }
55
+ ]
56
56
  },
57
57
  "beta-redteam": {
58
58
  "description": "IUMBTEMS Adversary Agent: Active falsification, red-teaming, hunting for counter-benchmarks, and edge-case failure modes.",
59
- "tools": {
60
- "bash": true,
61
- "read": true,
62
- "write": true,
63
- "grep": true,
64
- "glob": true,
65
- "task": true,
66
- "iumbtems_swarm_research": true,
67
- "iumbtems_verify_quote": true
68
- }
59
+ "mode": "all",
60
+ "permissions": [
61
+ { "action": "read", "resource": "*", "effect": "allow" },
62
+ { "action": "grep", "resource": "*", "effect": "allow" },
63
+ { "action": "glob", "resource": "*", "effect": "allow" },
64
+ { "action": "edit", "resource": "*", "effect": "allow" },
65
+ { "action": "shell", "resource": "*", "effect": "ask" },
66
+ { "action": "subagent", "resource": "*", "effect": "allow" }
67
+ ]
69
68
  },
70
69
  "epistemic-auditor": {
71
70
  "description": "IUMBTEMS Synthesizer & Auditor: Verifies verbatim source quotes, downgrades unverified claims, and calculates divergence.",
72
- "tools": {
73
- "read": true,
74
- "write": true,
75
- "grep": true,
76
- "glob": true,
77
- "iumbtems_verify_quote": true
78
- }
71
+ "mode": "all",
72
+ "permissions": [
73
+ { "action": "read", "resource": "*", "effect": "allow" },
74
+ { "action": "grep", "resource": "*", "effect": "allow" },
75
+ { "action": "glob", "resource": "*", "effect": "allow" },
76
+ { "action": "edit", "resource": "*", "effect": "allow" }
77
+ ]
79
78
  },
80
79
  "brainstormer": {
81
80
  "description": "IUMBTEMS Lateral Brainstormer: divergent what-if features, paradigm inversions, falsifiable spikes (never bug fixes).",
82
- "tools": {
83
- "read": true,
84
- "write": true,
85
- "grep": true,
86
- "glob": true,
87
- "iumbtems_brainstorm": true,
88
- "iumbtems_config": true
89
- }
81
+ "mode": "all",
82
+ "permissions": [
83
+ { "action": "read", "resource": "*", "effect": "allow" },
84
+ { "action": "grep", "resource": "*", "effect": "allow" },
85
+ { "action": "glob", "resource": "*", "effect": "allow" },
86
+ { "action": "edit", "resource": "*", "effect": "allow" }
87
+ ]
90
88
  },
91
89
  "darkharvester": {
92
90
  "description": "IUMBTEMS Competitor Teardown: seed plus expand, product plus code matrix, per-feature harvest verdicts with SPDX attribution.",
93
- "tools": {
94
- "read": true,
95
- "write": true,
96
- "grep": true,
97
- "glob": true,
98
- "iumbtems_darkharvest": true,
99
- "iumbtems_verify_quote": true,
100
- "iumbtems_config": true
101
- }
91
+ "mode": "all",
92
+ "permissions": [
93
+ { "action": "read", "resource": "*", "effect": "allow" },
94
+ { "action": "grep", "resource": "*", "effect": "allow" },
95
+ { "action": "glob", "resource": "*", "effect": "allow" },
96
+ { "action": "edit", "resource": "*", "effect": "allow" }
97
+ ]
102
98
  },
103
99
  "manager": {
104
100
  "description": "IUMBTEMS Factory Manager: grill-gated phased builds. Owns gates, swarm dispatch, roadmap synthesis, QA tiebreaks. Never writes code.",
105
101
  "mode": "primary",
106
- "temperature": 0.2,
107
- "permission": {
108
- "edit": "deny",
109
- "bash": "ask",
110
- "task": {
111
- "*": "deny",
112
- "factory-*": "allow",
113
- "programmer": "allow",
114
- "qa-a": "allow",
115
- "qa-b": "allow",
116
- "brainstormer": "allow",
117
- "darkharvester": "allow"
118
- }
119
- },
120
- "tools": {
121
- "read": true,
122
- "write": true,
123
- "grep": true,
124
- "glob": true,
125
- "task": true,
126
- "iumbtems_brainstorm": true,
127
- "iumbtems_darkharvest": true,
128
- "iumbtems_verify_quote": true,
129
- "iumbtems_socratic_frontier": true,
130
- "iumbtems_config": true
131
- }
102
+ "system": "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.",
103
+ "permissions": [
104
+ { "action": "edit", "resource": "*", "effect": "deny" },
105
+ { "action": "shell", "resource": "*", "effect": "ask" },
106
+ { "action": "subagent", "resource": "*", "effect": "deny" },
107
+ { "action": "subagent", "resource": "programmer", "effect": "allow" },
108
+ { "action": "subagent", "resource": "qa-a", "effect": "allow" },
109
+ { "action": "subagent", "resource": "qa-b", "effect": "allow" },
110
+ { "action": "subagent", "resource": "brainstormer", "effect": "allow" },
111
+ { "action": "subagent", "resource": "darkharvester", "effect": "allow" }
112
+ ]
132
113
  },
133
114
  "programmer": {
134
115
  "description": "IUMBTEMS Factory Programmer: implements exactly one phase brief per spawn. Cites phase evidence hashes. Never invokes swarms or other programmers.",
135
116
  "mode": "subagent",
136
- "temperature": 0.3,
137
- "permission": {
138
- "edit": "allow",
139
- "bash": "ask",
140
- "task": {
141
- "*": "deny"
142
- }
143
- },
144
- "tools": {
145
- "read": true,
146
- "write": true,
147
- "bash": true,
148
- "grep": true,
149
- "glob": true,
150
- "iumbtems_verify_quote": true
151
- }
117
+ "system": "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.",
118
+ "permissions": [
119
+ { "action": "edit", "resource": "*", "effect": "allow" },
120
+ { "action": "shell", "resource": "*", "effect": "ask" },
121
+ { "action": "subagent", "resource": "*", "effect": "deny" }
122
+ ]
152
123
  },
153
124
  "qa-a": {
154
125
  "description": "IUMBTEMS Factory QA (functional): verifies phase acceptance criteria pass on the real surface. Read-only plus test execution. Diverged prompt from qa-b.",
155
126
  "mode": "subagent",
156
- "temperature": 0.1,
157
- "permission": {
158
- "edit": "deny",
159
- "bash": "ask",
160
- "task": {
161
- "*": "deny"
162
- }
163
- },
164
- "tools": {
165
- "read": true,
166
- "bash": true,
167
- "grep": true,
168
- "glob": true
169
- }
127
+ "system": "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).",
128
+ "permissions": [
129
+ { "action": "edit", "resource": "*", "effect": "deny" },
130
+ { "action": "shell", "resource": "*", "effect": "ask" },
131
+ { "action": "subagent", "resource": "*", "effect": "deny" }
132
+ ]
170
133
  },
171
134
  "qa-b": {
172
135
  "description": "IUMBTEMS Factory QA (adversarial): hunts edge cases, regressions, and acceptance loopholes the functional pass missed. Read-only plus test execution. Diverged prompt from qa-a.",
173
136
  "mode": "subagent",
174
- "temperature": 0.4,
175
- "permission": {
176
- "edit": "deny",
177
- "bash": "ask",
178
- "task": {
179
- "*": "deny"
180
- }
181
- },
182
- "tools": {
183
- "read": true,
184
- "bash": true,
185
- "grep": true,
186
- "glob": true
187
- }
137
+ "system": "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.",
138
+ "permissions": [
139
+ { "action": "edit", "resource": "*", "effect": "deny" },
140
+ { "action": "shell", "resource": "*", "effect": "ask" },
141
+ { "action": "subagent", "resource": "*", "effect": "deny" }
142
+ ]
188
143
  }
189
144
  },
190
- "skills": {
191
- "paths": [
192
- "./skills/grilling",
193
- "./skills/research_cache",
194
- "./skills/epistemic_search",
195
- "./skills/swarm_config",
196
- "./skills/code_audit",
197
- "./skills/oss_scout",
198
- "./skills/brainstorming",
199
- "./skills/darkharvest",
200
- "./skills/factory"
201
- ]
202
- }
145
+ "skills": [
146
+ "./skills/grilling",
147
+ "./skills/research_cache",
148
+ "./skills/epistemic_search",
149
+ "./skills/swarm_config",
150
+ "./skills/code_audit",
151
+ "./skills/oss_scout",
152
+ "./skills/brainstorming",
153
+ "./skills/darkharvest",
154
+ "./skills/factory"
155
+ ]
203
156
  }
package/install.sh CHANGED
@@ -109,7 +109,7 @@ except Exception as e:
109
109
  EOF
110
110
  fi
111
111
 
112
- # 5. OpenCode V2 integration (plugin tuple, agents, MCP server — merge only)
112
+ # 5. OpenCode V2 integration (plugins/agents/skills/mcp.servers — merge only)
113
113
  OPENCODE_JSON="$HOME/.config/opencode/opencode.json"
114
114
  echo "🔧 Merging OpenCode V2 configuration into $OPENCODE_JSON..."
115
115
  python3 - <<EOF
@@ -124,91 +124,114 @@ OPTION_KEYS = {"search_engine", "max_iterations", "mode", "divergence_threshold"
124
124
  oc_path.parent.mkdir(parents=True, exist_ok=True)
125
125
  cfg = json.loads(oc_path.read_text(encoding="utf-8")) if oc_path.exists() else {}
126
126
 
127
- # --- plugin entry: tuple form [package, options]; repair bare-object form ---
128
- plugins = cfg.get("plugin", [])
127
+ # --- plugins (V2): object form {"package", "options"}; migrate legacy `plugin` ---
128
+ plugins = cfg.get("plugins")
129
129
  if not isinstance(plugins, list):
130
- print(" ⚠️ 'plugin' is not a list; leaving untouched")
131
- plugins = cfg.get("plugin", [])
132
- else:
133
- repaired = []
130
+ plugins = []
131
+
132
+ # Migrate any legacy V1 `plugin` entries: bare strings, [pkg, options] tuples,
133
+ # and the "wild" malformation where a bare options object trails our package.
134
+ legacy = cfg.get("plugin")
135
+ if isinstance(legacy, list):
134
136
  i = 0
135
- changed = False
136
- while i < len(plugins):
137
- item = plugins[i]
138
- # Bare options object following our package string (or standalone):
139
- # fold into a tuple instead of leaving an invalid entry.
140
- if isinstance(item, dict) and set(item) & OPTION_KEYS:
141
- prev_is_pkg = repaired and repaired[-1] == PKG
142
- prev_is_tuple = (
143
- repaired and isinstance(repaired[-1], list)
144
- and repaired[-1] and repaired[-1][0] == PKG
145
- )
146
- if prev_is_pkg:
147
- repaired[-1] = [PKG, item]
148
- changed = True
149
- print(" ~ Repaired bare options object into tuple entry")
150
- elif prev_is_tuple:
151
- merged = {**(repaired[-1][1] if len(repaired[-1]) > 1 else {}), **item}
152
- repaired[-1] = [PKG, merged]
153
- changed = True
154
- print(" ~ Folded stray options into existing tuple entry")
137
+ while i < len(legacy):
138
+ item = legacy[i]
139
+ if isinstance(item, str):
140
+ if item == PKG and i + 1 < len(legacy) and isinstance(legacy[i + 1], dict) and (set(legacy[i + 1]) & OPTION_KEYS):
141
+ plugins.append({"package": PKG, "options": legacy[i + 1]})
142
+ i += 2
143
+ continue
144
+ plugins.append(item)
145
+ elif isinstance(item, list) and item:
146
+ opts = item[1] if len(item) > 1 and isinstance(item[1], dict) else {}
147
+ plugins.append({"package": item[0], "options": opts})
148
+ elif isinstance(item, dict):
149
+ if "package" in item:
150
+ plugins.append(item)
151
+ elif set(item) & OPTION_KEYS:
152
+ plugins.append({"package": PKG, "options": item})
155
153
  else:
156
- repaired.append([PKG, item])
157
- changed = True
158
- print(" ~ Attached stray options object to new tuple entry")
159
- else:
160
- repaired.append(item)
154
+ plugins.append(item)
161
155
  i += 1
162
- if not any((p == PKG or (isinstance(p, list) and p and p[0] == PKG)) for p in repaired):
163
- repaired.append([PKG, {"search_engine": "duckduckgo", "max_iterations": 2, "mode": "research"}])
164
- changed = True
165
- print(f" + Added plugin entry: {PKG}")
166
- else:
167
- print(f" ℹ️ Plugin entry already configured: {PKG}")
168
- cfg["plugin"] = repaired
169
-
170
- # --- agents: merge snippet definitions, never overwrite user agents ---
156
+ cfg.pop("plugin", None)
157
+ print(" ~ Migrated legacy 'plugin' entries to V2 'plugins' (object form)")
158
+
159
+ # Normalize our entry to the V2 object form and ensure default options exist.
160
+ DEFAULT_OPTIONS = {"search_engine": "duckduckgo", "max_iterations": 2, "mode": "research"}
161
+ our = None
162
+ for idx, entry in enumerate(plugins):
163
+ if entry == PKG:
164
+ our = {"package": PKG, "options": {}}
165
+ plugins[idx] = our
166
+ elif isinstance(entry, dict) and entry.get("package") == PKG:
167
+ our = entry
168
+ entry["options"] = {**DEFAULT_OPTIONS, **(entry.get("options") or {})}
169
+ if our is None:
170
+ our = {"package": PKG, "options": dict(DEFAULT_OPTIONS)}
171
+ plugins.append(our)
172
+ print(f" + Added plugin entry: {PKG}")
173
+ else:
174
+ print(f" ℹ️ Plugin entry already configured: {PKG}")
175
+ cfg["plugins"] = plugins
176
+
177
+ # --- agents (V2 plural): merge snippet definitions, never overwrite users ---
171
178
  try:
172
179
  snippet = json.loads((repo / "config" / "opencode-snippet.json").read_text(encoding="utf-8"))
173
- agents = cfg.setdefault("agent", {})
174
- for name, definition in snippet.get("agent", {}).items():
180
+ agents = cfg.setdefault("agents", {})
181
+ legacy_agents = cfg.get("agent")
182
+ if isinstance(legacy_agents, dict):
183
+ for name, definition in legacy_agents.items():
184
+ agents.setdefault(name, definition)
185
+ cfg.pop("agent", None)
186
+ print(" ~ Migrated legacy 'agent' entries to V2 'agents'")
187
+ for name, definition in (snippet.get("agents") or snippet.get("agent") or {}).items():
175
188
  if name not in agents:
176
189
  agents[name] = definition
177
190
  print(f" + Added agent: {name}")
178
191
  else:
179
192
  print(f" ℹ️ Agent already configured: {name}")
180
193
 
181
- # Skills: snippet paths are repo-relative ("./skills/x") which breaks
182
- # from ~/.config — merge as absolute paths, repairing relative entries.
194
+ # Skills: V2 is a list of directories/URLs. Snippet paths are repo-relative
195
+ # ("./skills/x") which breaks from ~/.config — merge as absolute paths and
196
+ # repair a legacy `{"paths": [...]}` object or relative entries in place.
183
197
  import os as _os
184
198
  wanted = []
185
- for sp in snippet.get("skills", {}).get("paths", []):
199
+ for sp in snippet.get("skills") or []:
186
200
  wanted.append(sp if _os.path.isabs(sp) else str(repo / sp.lstrip("./")))
187
- skills_cfg = cfg.setdefault("skills", {})
188
- paths = skills_cfg.setdefault("paths", [])
189
- # Repair repo-relative entries in place, then append missing absolutes.
190
- for i, existing in enumerate(list(paths)):
201
+ skills = cfg.get("skills")
202
+ if isinstance(skills, dict):
203
+ skills = cfg["skills"] = list(skills.get("paths") or [])
204
+ print(" ~ Migrated legacy skills.paths to the V2 skills list")
205
+ elif not isinstance(skills, list):
206
+ skills = cfg["skills"] = []
207
+ for i, existing in enumerate(list(skills)):
208
+ if existing in wanted:
209
+ continue
191
210
  for abs_sp in wanted:
192
- if existing == abs_sp:
193
- break
194
211
  rel = "./" + str(Path(abs_sp).relative_to(repo)) if str(abs_sp).startswith(str(repo)) else None
195
212
  if rel and existing == rel:
196
- paths[i] = abs_sp
213
+ skills[i] = abs_sp
197
214
  print(f" ~ Repaired relative skill path: {rel} -> {abs_sp}")
198
215
  break
199
216
  for abs_sp in wanted:
200
- if abs_sp not in paths:
201
- paths.append(abs_sp)
217
+ if abs_sp not in skills:
218
+ skills.append(abs_sp)
202
219
  print(f" + Added skill path: {abs_sp}")
203
220
  else:
204
221
  print(f" ℹ️ Skill path already configured: {abs_sp}")
205
222
  except Exception as e:
206
223
  print(f" ⚠️ Non-critical warning merging agents: {e}")
207
224
 
208
- # --- MCP server: absolute runner path (relative paths only work at repo root) ---
225
+ # --- MCP server (V2 `mcp.servers`): absolute runner path ---
209
226
  mcp = cfg.setdefault("mcp", {})
210
- if "iumbtems" not in mcp:
211
- mcp["iumbtems"] = {
227
+ servers = mcp.setdefault("servers", {})
228
+ legacy_mcp = mcp.get("iumbtems")
229
+ if isinstance(legacy_mcp, dict):
230
+ servers.setdefault("iumbtems", legacy_mcp)
231
+ mcp.pop("iumbtems", None)
232
+ print(" ~ Migrated legacy mcp.iumbtems to V2 mcp.servers.iumbtems")
233
+ if "iumbtems" not in servers:
234
+ servers["iumbtems"] = {
212
235
  "type": "local",
213
236
  "command": ["python3", str(repo / "runner" / "mcp_server.py")],
214
237
  "enabled": True,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretek-ai/epistemic-swarm",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "IUMBTEMS: I Use My Brain To Express My Self — High-Integrity Dialectic Research Agent Harness for Claude Code, OpenCode V2, Pi, OMP (oh-my-pi), Gemini CLI, Codex CLI, and AntiGravity",
5
5
  "main": "bin/cli.js",
6
6
  "bin": {
@@ -920,7 +920,8 @@ async function registerHostCommands(host) {
920
920
  await host.session.prompt({
921
921
  ...prompt,
922
922
  sessionID: input?.sessionID,
923
- ...(cmd.agent ? { agent: cmd.agent } : {}),
923
+ // No `agent` field here: V2's SessionPromptInput has none (agent
924
+ // mentions only). Pinning happens via switchAgent above.
924
925
  text: cmd.template.split('$ARGUMENTS').join(String(args).trim()),
925
926
  delivery: input?.delivery,
926
927
  });
@@ -1028,7 +1029,9 @@ async function registerHostAgents(host) {
1028
1029
  // every step of a factory session executed as `build`, not `manager`).
1029
1030
  if (typeof host?.agent?.transform !== 'function') return [];
1030
1031
  const snippet = readPkgJson(path.join(PKG_ROOT, 'config', 'opencode-snippet.json'));
1031
- const agentDefs = (snippet && snippet.agent) || {};
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)) || {};
1032
1035
  if (Object.keys(agentDefs).length === 0) return [];
1033
1036
 
1034
1037
  let existing = new Set();
@@ -1046,7 +1049,7 @@ async function registerHostAgents(host) {
1046
1049
  if (claimed.has(name)) continue;
1047
1050
  claimed.add(name);
1048
1051
  added.push(name);
1049
- draft.add(toAgentInfo(name, def));
1052
+ registerAgent(draft, name, def);
1050
1053
  }
1051
1054
  log(host, 'debug', 'agent.transform pass', { added });
1052
1055
  });
@@ -1071,18 +1074,49 @@ const FACTORY_SYSTEM = {
1071
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.',
1072
1075
  };
1073
1076
 
1074
- /** Map one snippet agent definition to an Agent.Info shape. */
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
+ */
1075
1096
  function toAgentInfo(name, def) {
1076
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
+ }
1077
1107
  for (const [tool, enabled] of Object.entries(def?.tools || {})) {
1078
- permissions.push({ action: tool, resource: '*', effect: enabled ? 'allow' : 'deny' });
1108
+ permissions.push({
1109
+ action: normalizeAction(tool),
1110
+ resource: '*',
1111
+ effect: enabled ? 'allow' : 'deny',
1112
+ });
1079
1113
  }
1080
1114
  for (const [key, value] of Object.entries(def?.permission || {})) {
1081
1115
  if (typeof value === 'string') {
1082
- permissions.push({ action: key, resource: '*', effect: value });
1116
+ permissions.push({ action: normalizeAction(key), resource: '*', effect: normalizeEffect(value) });
1083
1117
  } else if (value && typeof value === 'object') {
1084
1118
  for (const [resource, effect] of Object.entries(value)) {
1085
- permissions.push({ action: key, resource, effect });
1119
+ permissions.push({ action: normalizeAction(key), resource, effect: normalizeEffect(effect) });
1086
1120
  }
1087
1121
  }
1088
1122
  }
@@ -1097,11 +1131,41 @@ function toAgentInfo(name, def) {
1097
1131
  };
1098
1132
  if (def?.steps) info.steps = def.steps;
1099
1133
  if (def?.color) info.color = def.color;
1100
- const system = FACTORY_SYSTEM[name];
1134
+ // The canonical short role prompt wins; fall back to a snippet-provided one.
1135
+ const system = FACTORY_SYSTEM[name] || def?.system;
1101
1136
  if (system) info.system = system;
1102
1137
  return info;
1103
1138
  }
1104
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
+
1105
1169
  /** Canonical skills the plugin registers so agents never hunt the filesystem. */
1106
1170
  const BUNDLED_SKILLS = [
1107
1171
  'factory',
@@ -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)
@@ -7,6 +7,12 @@ ran as the generic `build` agent, and skills were undiscoverable — the agent
7
7
  ran `find / -name factory.py` to learn its own mechanics. These tests pin the
8
8
  plugin-side fix: profiles and skills are registered through the V2 domains, and
9
9
  the command switches the session agent.
10
+
11
+ Regression (0.7.6, opencode v2.0.18): the mock below used to hand the plugin an
12
+ `{add}` agent editor. The real V2 `AgentEditor` has NO `add()` — only
13
+ `list/get/default/update/remove`, where `update()` upserts. The plugin's
14
+ `draft.add(...)` threw `draft.add is not a function` and the host disabled the
15
+ whole plugin. The mock now models the real editor so that defect cannot pass.
10
16
  """
11
17
 
12
18
  import json
@@ -16,21 +22,71 @@ from pathlib import Path
16
22
 
17
23
  PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
18
24
 
19
- V2_HOST = """
20
- const reg = {agents: [], skills: [], commands: [], tools: [], prompts: [], switches: []};
25
+ # Faithful V2 AgentEditor: `update(id, fn)` upserts, seeding a new entry from
26
+ # Agent.Info.default(id) (mode primary, allow-list) + global rules, exactly as
27
+ # the opencode v2.0.18 agent registry does. No `add`.
28
+ AGENT_EDITOR_JS = """
29
+ function makeAgentEditor(reg) {
30
+ const store = new Map();
31
+ return {
32
+ store,
33
+ editor: {
34
+ list: () => [...store.values()],
35
+ get: (id) => store.get(id),
36
+ default: (id) => { reg.defaultAgent = id; },
37
+ update: (id, fn) => {
38
+ reg.agentUpdates.push(id);
39
+ let agent = store.get(id);
40
+ if (!agent) {
41
+ agent = {
42
+ id, name: id,
43
+ request: {settings: {}, headers: {}, body: {}},
44
+ mode: 'primary', hidden: false,
45
+ permissions: [{action: '*', resource: '*', effect: 'allow'}],
46
+ };
47
+ store.set(id, agent);
48
+ }
49
+ fn(agent);
50
+ agent.id = id;
51
+ },
52
+ remove: (id) => { store.delete(id); },
53
+ },
54
+ };
55
+ }
56
+ function seedAgent(box, id) {
57
+ box.store.set(id, {
58
+ id, name: id,
59
+ request: {settings: {}, headers: {}, body: {}},
60
+ mode: 'primary', hidden: false,
61
+ permissions: [{action: '*', resource: '*', effect: 'allow'}],
62
+ });
63
+ }
64
+ """
65
+
66
+ V2_HOST = (
67
+ AGENT_EDITOR_JS
68
+ + """
69
+ const reg = {skills: [], commands: [], tools: [], prompts: [], switches: [], agentUpdates: [], defaultAgent: undefined};
70
+ const agentBox = makeAgentEditor(reg);
21
71
  const host = {
22
72
  options: {},
23
73
  location: {directory: '/home/john/Projects/STC'},
24
74
  command: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:(x)=>{reg.commands.push(x);}}); return {dispose:()=>{}};}, reload: async()=>{}},
25
75
  tool: {transform: async(fn)=>{fn({add:(t)=>{reg.tools.push(t);}}); return {dispose:()=>{}};}, reload: async()=>{}},
26
- agent: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:(a)=>{reg.agents.push(a);}}); return {dispose:()=>{}};}, reload: async()=>{}},
76
+ agent: {
77
+ list: async()=>({data: [...agentBox.store.values()].map((a)=>({id: a.id, name: a.name}))}),
78
+ transform: async(fn)=>{fn(agentBox.editor); return {dispose:()=>{}};},
79
+ reload: async()=>{}
80
+ },
27
81
  skill: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:(s)=>{reg.skills.push(s);}}); return {dispose:()=>{}};}, reload: async()=>{}},
28
82
  session: {
29
83
  prompt: async(p)=>{reg.prompts.push(p); return {};},
30
84
  switchAgent: async(a)=>{reg.switches.push(a); return {};}
31
85
  }
32
86
  };
87
+ const agents = () => [...agentBox.store.values()];
33
88
  """
89
+ )
34
90
 
35
91
 
36
92
  def run_node(code):
@@ -54,16 +110,17 @@ class TestV2Registration(unittest.TestCase):
54
110
  + """
55
111
  import plugin from "./plugins/opencode/index.js";
56
112
  await plugin.setup(host);
57
- const manager = reg.agents.find(a => a.id === 'manager');
58
- const qa = reg.agents.find(a => a.id === 'qa-a');
113
+ const manager = agents().find(a => a.id === 'manager');
114
+ const qa = agents().find(a => a.id === 'qa-a');
59
115
  const factory = reg.skills.find(s => s.id === 'factory');
60
116
  console.log(JSON.stringify({
61
- agentIds: reg.agents.map(a => a.id).sort(),
117
+ agentIds: agents().map(a => a.id).sort(),
62
118
  managerMode: manager?.mode,
63
119
  managerSystem: Boolean(manager?.system),
64
120
  managerPermissions: manager?.permissions?.length || 0,
65
121
  managerDeniesEdit: (manager?.permissions || []).some(p => p.action === 'edit' && p.effect === 'deny'),
66
122
  qaMode: qa?.mode,
123
+ qaDeniesEdit: (qa?.permissions || []).some(p => p.action === 'edit' && p.effect === 'deny'),
67
124
  skillIds: reg.skills.map(s => s.id).sort(),
68
125
  factoryHasContent: Boolean(factory?.content?.includes('Factory')),
69
126
  factoryPath: factory?.path,
@@ -85,11 +142,46 @@ class TestV2Registration(unittest.TestCase):
85
142
  self.assertTrue(d["managerSystem"])
86
143
  self.assertTrue(d["managerDeniesEdit"], "manager must not edit code")
87
144
  self.assertEqual(d["qaMode"], "subagent")
145
+ self.assertTrue(d["qaDeniesEdit"], "qa must not edit code")
88
146
  self.assertIn("factory", d["skillIds"])
89
147
  self.assertIn("darkharvest", d["skillIds"])
90
148
  self.assertTrue(d["factoryHasContent"])
91
149
  self.assertTrue(d["factoryPath"].endswith("skills/factory/SKILL.md"))
92
150
 
151
+ def test_agent_editor_never_calls_add(self):
152
+ """Pin the exact 0.7.6 failure: AgentEditor has no `add`."""
153
+ res = run_node(
154
+ """
155
+ const reg = {agentUpdates: []};
156
+ const editor = {
157
+ list: () => [],
158
+ get: () => undefined,
159
+ default: () => {},
160
+ update: (id, fn) => {
161
+ reg.agentUpdates.push(id);
162
+ fn({id, permissions: []});
163
+ },
164
+ remove: () => {},
165
+ // intentionally NO add
166
+ };
167
+ const host = {
168
+ options: {}, location: {directory: '/tmp'},
169
+ command: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:()=>{}}); return {dispose:()=>{}};}, reload: async()=>{}},
170
+ tool: {transform: async()=>({dispose:()=>{}}), reload: async()=>{}},
171
+ agent: {list: async()=>({data:[]}), transform: async(fn)=>{fn(editor); return {dispose:()=>{}};}, reload: async()=>{}},
172
+ skill: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:()=>{}}); return {dispose:()=>{}};}, reload: async()=>{}},
173
+ session: {prompt: async()=>({})}
174
+ };
175
+ import plugin from "./plugins/opencode/index.js";
176
+ await plugin.setup(host);
177
+ console.log(JSON.stringify({agentUpdates: reg.agentUpdates}));
178
+ """
179
+ )
180
+ self.assertEqual(res.returncode, 0, res.stderr)
181
+ d = last_json_object(res.stdout)
182
+ self.assertIn("manager", d["agentUpdates"])
183
+ self.assertIn("qa-b", d["agentUpdates"])
184
+
93
185
  def test_command_switches_session_agent(self):
94
186
  res = run_node(
95
187
  V2_HOST
@@ -100,7 +192,7 @@ class TestV2Registration(unittest.TestCase):
100
192
  await factory.execute({sessionID: 'ses_test', prompt: {text: 'Cockpit beta'}, delivery: 'steer'});
101
193
  console.log(JSON.stringify({
102
194
  switches: reg.switches,
103
- promptAgent: reg.prompts[0]?.agent,
195
+ promptAgentKey: Object.prototype.hasOwnProperty.call(reg.prompts[0] || {}, 'agent'),
104
196
  promptHasArena: reg.prompts[0]?.text?.includes('Cockpit beta'),
105
197
  }));
106
198
  """
@@ -108,39 +200,44 @@ class TestV2Registration(unittest.TestCase):
108
200
  self.assertEqual(res.returncode, 0, res.stderr)
109
201
  d = last_json_object(res.stdout)
110
202
  self.assertTrue(d["switches"], "switchAgent was never called")
111
- self.assertEqual(d["promptAgent"], "manager")
203
+ self.assertEqual(d["switches"][0]["agent"], "manager")
204
+ # V2 SessionPromptInput has no `agent` field; pinning is switchAgent's job.
205
+ self.assertFalse(d["promptAgentKey"])
112
206
  self.assertTrue(d["promptHasArena"])
113
207
 
114
208
  def test_preexisting_agent_and_skill_preserved(self):
115
209
  res = run_node(
116
- """
117
- const agents = [], skills = [];
210
+ AGENT_EDITOR_JS
211
+ + """
212
+ const reg = {agentUpdates: [], skills: []};
213
+ const agentBox = makeAgentEditor(reg);
214
+ seedAgent(agentBox, 'manager'); // user-defined manager wins
118
215
  const host = {
119
216
  options: {}, location: {directory: '/tmp'},
120
217
  command: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:()=>{}}); return {dispose:()=>{}};}, reload: async()=>{}},
121
218
  tool: {transform: async()=>({dispose:()=>{}}), reload: async()=>{}},
122
219
  agent: {
123
- list: async()=>({data:[{id:'manager'}]}),
124
- transform: async(fn)=>{fn({add:(a)=>{agents.push(a.id);}}); return {dispose:()=>{}};},
220
+ list: async()=>({data: [...agentBox.store.values()].map((a)=>({id: a.id}))}),
221
+ transform: async(fn)=>{fn(agentBox.editor); return {dispose:()=>{}};},
125
222
  reload: async()=>{}
126
223
  },
127
224
  skill: {
128
225
  list: async()=>({data:[{id:'factory'}]}),
129
- transform: async(fn)=>{fn({add:(s)=>{skills.push(s.id);}}); return {dispose:()=>{}};},
226
+ transform: async(fn)=>{fn({add:(s)=>{reg.skills.push(s.id);}}); return {dispose:()=>{}};},
130
227
  reload: async()=>{}
131
228
  },
132
229
  session: {prompt: async()=>({})}
133
230
  };
134
231
  import plugin from "./plugins/opencode/index.js";
135
232
  await plugin.setup(host);
136
- console.log(JSON.stringify({agents, skills}));
233
+ console.log(JSON.stringify({agentUpdates: reg.agentUpdates, skills: reg.skills}));
137
234
  """
138
235
  )
139
236
  self.assertEqual(res.returncode, 0, res.stderr)
140
237
  d = last_json_object(res.stdout)
141
- self.assertNotIn("manager", d["agents"], "user-defined manager must win")
238
+ self.assertNotIn("manager", d["agentUpdates"], "user-defined manager must win")
142
239
  self.assertNotIn("factory", d["skills"], "user-defined factory skill must win")
143
- self.assertIn("qa-a", d["agents"])
240
+ self.assertIn("qa-a", d["agentUpdates"])
144
241
 
145
242
  def test_graceful_without_agent_skill_domains(self):
146
243
  res = run_node(