@heretek-ai/epistemic-swarm 0.7.7 → 0.7.8

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/package.json +1 -1
  3. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  4. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  5. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  6. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  7. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  8. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  9. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  17. package/runner/mcp_server.py +7 -1
  18. package/runner/research_swarm.py +65 -3
  19. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  20. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  21. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  22. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  23. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  24. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  25. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  26. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  27. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  28. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  29. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  30. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  31. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  32. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  33. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  34. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  35. package/runner/tests/__pycache__/test_swarm_config.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_swarm_config.py +263 -0
  39. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  40. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  41. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  42. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  43. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  44. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  45. package/skills/swarm_config/__init__.py +15 -2
  46. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  47. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  48. package/skills/swarm_config/configure.py +103 -6
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epistemic-swarm",
3
- "version": "0.7.7",
3
+ "version": "0.7.8",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretek-ai/epistemic-swarm",
3
- "version": "0.7.7",
3
+ "version": "0.7.8",
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": {
@@ -84,12 +84,16 @@ def _handle_config(args: Dict[str, Any]) -> str:
84
84
  """Inspect or modify swarm parameters in .research/config.json."""
85
85
  from skills.swarm_config.configure import (
86
86
  display_config,
87
+ heal_config,
87
88
  load_config,
88
89
  save_config,
89
90
  )
90
91
 
91
92
  base_dir = str(_resolve_base_dir(args))
92
93
  cfg = load_config(base_dir)
94
+ # Persist a legacy ["claude", "-p"] agent pin migration so an OpenCode host
95
+ # stops silently spawning Claude on every run.
96
+ healed = heal_config(base_dir)
93
97
 
94
98
  updates = {}
95
99
  for key in (
@@ -99,6 +103,8 @@ def _handle_config(args: Dict[str, Any]) -> str:
99
103
  "depth",
100
104
  "mode",
101
105
  "divergence_threshold",
106
+ "backend",
107
+ "agents",
102
108
  ):
103
109
  if key in args and args[key] is not None:
104
110
  canon = {
@@ -119,7 +125,7 @@ def _handle_config(args: Dict[str, Any]) -> str:
119
125
  }
120
126
  )
121
127
 
122
- return _tool_text({"status": "current", "config": cfg})
128
+ return _tool_text({"status": "current", "config": cfg, "migrated": healed})
123
129
 
124
130
 
125
131
  def _run_swarm_mode(mode: Optional[str], args: Dict[str, Any]) -> str:
@@ -66,6 +66,23 @@ def _backend_family(backend: List[str]) -> str:
66
66
  return Path(backend[0]).name if backend else "claude"
67
67
 
68
68
 
69
+ def _agent_cwd(project_root: Path) -> str:
70
+ """Working directory for spawned agent processes.
71
+
72
+ The canonical agent prompts name evidence paths RELATIVELY (`.research/...`)
73
+ and the runner reads dossiers from `<base_dir>/scratchpads/...`. Spawning in
74
+ the package root made those writes land inside node_modules (denied). Spawn
75
+ in the project root instead so `.research/` means the operator's evidence
76
+ tree. Override with IUMBTEMS_AGENT_CWD=package|project|<absolute path>.
77
+ """
78
+ override = os.environ.get("IUMBTEMS_AGENT_CWD", "").strip()
79
+ if override == "package":
80
+ return str(PROJECT_ROOT)
81
+ if override and override != "project":
82
+ return override
83
+ return str(project_root)
84
+
85
+
69
86
  def _build_opencode_cmd(
70
87
  backend: List[str],
71
88
  prompt: str,
@@ -190,6 +207,13 @@ class SwarmRunner:
190
207
  domain_pack: Optional[str] = None,
191
208
  ):
192
209
  self.base_dir = base_dir or Path(".research")
210
+ # Project root = the directory containing the evidence dir. Agents are
211
+ # spawned here so the relative `.research/...` paths named by the
212
+ # canonical prompts resolve to the RUNNER's evidence tree. Spawning in
213
+ # PROJECT_ROOT (the installed package) sent those writes into
214
+ # node_modules, where they were denied and no agent-authored dossier
215
+ # ever landed — runs survived only on the stdout-recovery fallback.
216
+ self.project_root = Path(os.path.realpath(str(self.base_dir))).parent
193
217
  self.mock_mode = mock_mode
194
218
  self.config = load_config(str(self.base_dir))
195
219
  cfg = self.config
@@ -233,8 +257,46 @@ class SwarmRunner:
233
257
  or _default_backend_cmd(self.config.get("backend"))
234
258
  )
235
259
  model = override.get("model") or env_model or role_cfg.get("model")
260
+ self._warn_backend_host_mismatch(role, backend)
236
261
  return list(backend), model
237
262
 
263
+ @staticmethod
264
+ def _warn_backend_host_mismatch(role: str, backend: List[str]) -> None:
265
+ """Surface a config pin that silently defeats the host-native default.
266
+
267
+ Observed live: a pre-0.7.6 config pin of ["claude", "-p"] beat
268
+ IUMBTEMS_HOST=opencode, so an OpenCode host spawned Claude with no
269
+ signal. Warn loudly instead of failing silently.
270
+ """
271
+ host = os.environ.get("IUMBTEMS_HOST", "").strip().lower()
272
+ if host not in ("claude", "opencode"):
273
+ return
274
+ family = _backend_family(backend)
275
+ if family == host:
276
+ return
277
+ sys.stderr.write(
278
+ f"[swarm] WARNING: role={role} resolved backend={family!r} from an "
279
+ f"explicit pin/override, but IUMBTEMS_HOST={host!r}. The explicit pin "
280
+ f"wins. Set backend: {host!r}, agents.{role}.backend: null, or "
281
+ f"IUMBTEMS_BACKEND_{role.upper()}={host} to follow the host.\n"
282
+ )
283
+
284
+ def _scope_prompt(self, role: str, scope: Dict[str, Any], scope_id: str) -> str:
285
+ """Prompt for a dialectic agent, naming the absolute scratchpad dir.
286
+
287
+ The canonical prompts describe evidence paths RELATIVELY, so a bare
288
+ scope id left the agent guessing which `.research` tree to write to.
289
+ Naming the absolute directory removes the ambiguity even when the
290
+ spawn cwd is overridden.
291
+ """
292
+ return (
293
+ f"Run Agent {role} ({self.mode} mode) for scope: {json.dumps(scope)}. "
294
+ f"Engine: {self.engine}. Depth: {self.depth}. "
295
+ f"Save findings to the scratchpad directory "
296
+ f"{self.state_machine.get_scope_dir(scope_id)} (scope {scope_id}); "
297
+ f"write the dossier files there and nowhere else."
298
+ )
299
+
238
300
  def _resolve_opencode_agent(self, role: str) -> Optional[str]:
239
301
  """Explicitly configured OpenCode agent for this role, else None.
240
302
 
@@ -310,7 +372,7 @@ class SwarmRunner:
310
372
  capture_output=True,
311
373
  text=True,
312
374
  check=True,
313
- cwd=str(PROJECT_ROOT),
375
+ cwd=_agent_cwd(self.project_root),
314
376
  # stdin MUST be DEVNULL: `opencode run` reads piped stdin to
315
377
  # EOF before starting, and the MCP server's inherited stdin
316
378
  # pipe is held open by the harness — every agent hung forever
@@ -619,7 +681,7 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
619
681
  "negative_knowledge": [],
620
682
  }
621
683
  else:
622
- prompt = f"Run Agent Alpha ({self.mode} mode) for scope: {json.dumps(scope)}. Engine: {self.engine}. Depth: {self.depth}. Save findings to {scope_id} scratchpad."
684
+ prompt = self._scope_prompt("Alpha", scope, scope_id)
623
685
  if self.mode == "audit":
624
686
  system_prompt = self.prompts_dir / "agent_code_auditor.md"
625
687
  elif self.mode == "scout":
@@ -814,7 +876,7 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
814
876
  ],
815
877
  }
816
878
  else:
817
- prompt = f"Run Agent Beta ({self.mode} mode) for scope: {json.dumps(scope)}. Engine: {self.engine}. Depth: {self.depth}. Save findings to {scope_id} scratchpad."
879
+ prompt = self._scope_prompt("Beta", scope, scope_id)
818
880
  if self.mode == "audit":
819
881
  system_prompt = self.prompts_dir / "agent_code_auditor.md"
820
882
  elif self.mode == "scout":
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env python3
2
+ """Config-merge and agent-spawn regressions.
3
+
4
+ Covers two live failures observed on an OpenCode host running the installed
5
+ package against an operator project:
6
+
7
+ 1. A pre-0.7.6 config persisted `agents.<role>.backend = ["claude", "-p"]`.
8
+ The shallow merge in `load_config` preserved it and it out-ranked the
9
+ host-native default, so an OpenCode host silently spawned Claude.
10
+ 2. Agents were spawned with `cwd=PROJECT_ROOT` (the installed package) while
11
+ evidence lives in the project's `.research/`, so prompt-mandated relative
12
+ writes landed inside node_modules and were denied.
13
+ """
14
+
15
+ import io
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import unittest
22
+ from contextlib import redirect_stderr
23
+ from pathlib import Path
24
+ from unittest import mock
25
+
26
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
27
+ if str(PROJECT_ROOT) not in sys.path:
28
+ sys.path.insert(0, str(PROJECT_ROOT))
29
+
30
+ from runner.research_swarm import ( # noqa: E402
31
+ PROJECT_ROOT as PACKAGE_ROOT,
32
+ SwarmRunner,
33
+ _agent_cwd,
34
+ )
35
+ from skills.swarm_config.configure import ( # noqa: E402
36
+ DEFAULT_CONFIG,
37
+ heal_config,
38
+ load_config,
39
+ migrate_legacy_agent_backends,
40
+ save_config,
41
+ )
42
+
43
+
44
+ def _write_config(base_dir: Path, payload: dict) -> Path:
45
+ base_dir.mkdir(parents=True, exist_ok=True)
46
+ cfg_file = base_dir / "config.json"
47
+ cfg_file.write_text(json.dumps(payload, indent=2), encoding="utf-8")
48
+ return cfg_file
49
+
50
+
51
+ class TestLegacyAgentPinMigration(unittest.TestCase):
52
+ def test_legacy_pin_is_dropped_when_backend_is_auto(self):
53
+ with tempfile.TemporaryDirectory() as tmp:
54
+ base = Path(tmp) / ".research"
55
+ _write_config(
56
+ base,
57
+ {
58
+ "backend": "auto",
59
+ "agents": {
60
+ "alpha": {"backend": ["claude", "-p"], "model": None},
61
+ "beta": {"backend": ["claude", "-p"], "model": None},
62
+ },
63
+ },
64
+ )
65
+ cfg = load_config(str(base))
66
+ self.assertIsNone(cfg["agents"]["alpha"]["backend"])
67
+ self.assertIsNone(cfg["agents"]["beta"]["backend"])
68
+
69
+ def test_legacy_pin_no_longer_forces_claude_on_opencode_host(self):
70
+ with tempfile.TemporaryDirectory() as tmp:
71
+ base = Path(tmp) / ".research"
72
+ _write_config(
73
+ base,
74
+ {
75
+ "backend": "auto",
76
+ "agents": {
77
+ "alpha": {"backend": ["claude", "-p"], "model": None},
78
+ "beta": {"backend": ["claude", "-p"], "model": None},
79
+ },
80
+ },
81
+ )
82
+ with mock.patch.dict(os.environ, {"IUMBTEMS_HOST": "opencode"}):
83
+ runner = SwarmRunner(base_dir=base, mock_mode=True, mode="darkharvest")
84
+ cmd = runner.build_agent_cmd("objective", role="alpha")
85
+ self.assertEqual(cmd[:2], ["opencode", "run"])
86
+
87
+ def test_explicit_claude_choice_is_preserved(self):
88
+ with tempfile.TemporaryDirectory() as tmp:
89
+ base = Path(tmp) / ".research"
90
+ _write_config(
91
+ base,
92
+ {
93
+ "backend": "claude",
94
+ "agents": {
95
+ "alpha": {"backend": ["claude", "-p"], "model": None},
96
+ "beta": {"backend": ["claude", "-p"], "model": None},
97
+ },
98
+ },
99
+ )
100
+ with mock.patch.dict(os.environ, {"IUMBTEMS_HOST": "opencode"}):
101
+ runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
102
+ with redirect_stderr(io.StringIO()):
103
+ cmd = runner.build_agent_cmd("objective", role="alpha")
104
+ self.assertEqual(cmd[:2], ["claude", "-p"])
105
+
106
+ def test_backend_opencode_top_level_drops_legacy_pin(self):
107
+ with tempfile.TemporaryDirectory() as tmp:
108
+ base = Path(tmp) / ".research"
109
+ _write_config(
110
+ base,
111
+ {
112
+ "backend": "opencode",
113
+ "agents": {
114
+ "alpha": {"backend": ["claude", "-p"], "model": None},
115
+ },
116
+ },
117
+ )
118
+ cfg = load_config(str(base))
119
+ self.assertIsNone(cfg["agents"]["alpha"]["backend"])
120
+
121
+ def test_migration_helper_reports_change(self):
122
+ cfg = {
123
+ "backend": "auto",
124
+ "agents": {"alpha": {"backend": ["claude", "-p"]}},
125
+ }
126
+ self.assertTrue(migrate_legacy_agent_backends(cfg))
127
+ self.assertFalse(migrate_legacy_agent_backends(cfg))
128
+
129
+ def test_heal_config_persists_and_is_idempotent(self):
130
+ with tempfile.TemporaryDirectory() as tmp:
131
+ base = Path(tmp) / ".research"
132
+ cfg_file = _write_config(
133
+ base,
134
+ {
135
+ "backend": "auto",
136
+ "agents": {"alpha": {"backend": ["claude", "-p"], "model": None}},
137
+ },
138
+ )
139
+ self.assertTrue(heal_config(str(base)))
140
+ on_disk = json.loads(cfg_file.read_text(encoding="utf-8"))
141
+ self.assertIsNone(on_disk["agents"]["alpha"]["backend"])
142
+ # Second pass has nothing left to migrate.
143
+ self.assertFalse(heal_config(str(base)))
144
+
145
+
146
+ class TestConfigMergeSemantics(unittest.TestCase):
147
+ def test_partial_agents_block_does_not_clobber_defaults(self):
148
+ with tempfile.TemporaryDirectory() as tmp:
149
+ base = Path(tmp) / ".research"
150
+ _write_config(base, {"agents": {"alpha": {"model": "some-model"}}})
151
+ cfg = load_config(str(base))
152
+ self.assertEqual(cfg["agents"]["alpha"]["model"], "some-model")
153
+ self.assertIsNone(cfg["agents"]["alpha"]["backend"])
154
+ # Beta was absent from the file and keeps its defaults.
155
+ self.assertIsNone(cfg["agents"]["beta"]["backend"])
156
+
157
+ def test_loaded_config_does_not_alias_default_config(self):
158
+ with tempfile.TemporaryDirectory() as tmp:
159
+ base = Path(tmp) / ".research"
160
+ cfg = load_config(str(base))
161
+ cfg["agents"]["alpha"]["backend"] = "MUTATED"
162
+ cfg["license_whitelist"].append("MUTATED")
163
+ self.assertIsNone(DEFAULT_CONFIG["agents"]["alpha"]["backend"])
164
+ self.assertNotIn("MUTATED", DEFAULT_CONFIG["license_whitelist"])
165
+
166
+ def test_malformed_agents_block_is_tolerated(self):
167
+ with tempfile.TemporaryDirectory() as tmp:
168
+ base = Path(tmp) / ".research"
169
+ _write_config(base, {"agents": None})
170
+ cfg = load_config(str(base))
171
+ self.assertIsNone(cfg["agents"]["alpha"]["backend"])
172
+
173
+ _write_config(base, {"agents": {"alpha": None}})
174
+ cfg = load_config(str(base))
175
+ self.assertIsNone(cfg["agents"]["alpha"])
176
+ self.assertIsNone(cfg["agents"]["beta"]["backend"])
177
+
178
+ def test_non_object_config_falls_back_to_defaults(self):
179
+ with tempfile.TemporaryDirectory() as tmp:
180
+ base = Path(tmp) / ".research"
181
+ base.mkdir(parents=True, exist_ok=True)
182
+ (base / "config.json").write_text("[1, 2, 3]", encoding="utf-8")
183
+ with redirect_stderr(io.StringIO()):
184
+ cfg = load_config(str(base))
185
+ self.assertEqual(cfg["search_engine"], "duckduckgo")
186
+
187
+ def test_save_load_roundtrip_is_idempotent(self):
188
+ with tempfile.TemporaryDirectory() as tmp:
189
+ base = Path(tmp) / ".research"
190
+ save_config(load_config(str(base)), str(base))
191
+ first = load_config(str(base))
192
+ save_config(first, str(base))
193
+ self.assertEqual(first, load_config(str(base)))
194
+
195
+
196
+ class TestAgentSpawnWorkingDirectory(unittest.TestCase):
197
+ def test_project_root_is_evidence_dir_parent(self):
198
+ with tempfile.TemporaryDirectory() as tmp:
199
+ base = Path(tmp) / ".research"
200
+ runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
201
+ self.assertEqual(
202
+ runner.project_root, Path(os.path.realpath(str(base))).parent
203
+ )
204
+
205
+ def test_agent_cwd_defaults_to_project_root(self):
206
+ project = Path("/tmp/some-project")
207
+ with mock.patch.dict(os.environ, {}, clear=True):
208
+ self.assertEqual(_agent_cwd(project), str(project))
209
+
210
+ def test_agent_cwd_package_override(self):
211
+ with mock.patch.dict(os.environ, {"IUMBTEMS_AGENT_CWD": "package"}):
212
+ self.assertEqual(_agent_cwd(Path("/tmp/x")), str(PACKAGE_ROOT))
213
+
214
+ def test_agent_cwd_absolute_override(self):
215
+ with mock.patch.dict(os.environ, {"IUMBTEMS_AGENT_CWD": "/custom/dir"}):
216
+ self.assertEqual(_agent_cwd(Path("/tmp/x")), "/custom/dir")
217
+
218
+ def test_scope_prompt_names_absolute_scratchpad(self):
219
+ with tempfile.TemporaryDirectory() as tmp:
220
+ base = Path(tmp) / ".research"
221
+ runner = SwarmRunner(base_dir=base, mock_mode=True, mode="darkharvest")
222
+ prompt = runner._scope_prompt("Alpha", {"scope_id": "s1"}, "s1")
223
+ expected = str(runner.state_machine.get_scope_dir("s1"))
224
+ self.assertIn(expected, prompt)
225
+ self.assertTrue(Path(expected).is_absolute())
226
+
227
+ def test_spawn_uses_project_root_not_package_root(self):
228
+ with tempfile.TemporaryDirectory() as tmp:
229
+ base = Path(tmp) / ".research"
230
+ runner = SwarmRunner(base_dir=base, mock_mode=False, mode="research")
231
+ fake = mock.Mock()
232
+ fake.stdout = "ok"
233
+ with mock.patch.dict(os.environ, {}, clear=True):
234
+ with mock.patch("subprocess.run", return_value=fake) as run_mock:
235
+ runner.run_claude_process("objective text")
236
+ kwargs = run_mock.call_args.kwargs
237
+ self.assertEqual(kwargs["cwd"], str(runner.project_root))
238
+ self.assertNotEqual(kwargs["cwd"], str(PACKAGE_ROOT))
239
+ self.assertEqual(kwargs["stdin"], subprocess.DEVNULL)
240
+
241
+
242
+ class TestBackendHostMismatchWarning(unittest.TestCase):
243
+ def test_warns_when_pin_contradicts_host(self):
244
+ with tempfile.TemporaryDirectory() as tmp:
245
+ base = Path(tmp) / ".research"
246
+ _write_config(
247
+ base,
248
+ {
249
+ "backend": "claude",
250
+ "agents": {"alpha": {"backend": ["claude", "-p"]}},
251
+ },
252
+ )
253
+ with mock.patch.dict(os.environ, {"IUMBTEMS_HOST": "opencode"}):
254
+ runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
255
+ buf = io.StringIO()
256
+ with redirect_stderr(buf):
257
+ runner.build_agent_cmd("objective", role="alpha")
258
+ self.assertIn("IUMBTEMS_HOST", buf.getvalue())
259
+ self.assertIn("alpha", buf.getvalue())
260
+
261
+
262
+ if __name__ == "__main__":
263
+ unittest.main()
@@ -1,4 +1,17 @@
1
1
  """Epistemic Swarm configuration package."""
2
- from .configure import load_config, save_config, DEFAULT_CONFIG
3
2
 
4
- __all__ = ["load_config", "save_config", "DEFAULT_CONFIG"]
3
+ from .configure import (
4
+ DEFAULT_CONFIG,
5
+ heal_config,
6
+ load_config,
7
+ migrate_legacy_agent_backends,
8
+ save_config,
9
+ )
10
+
11
+ __all__ = [
12
+ "load_config",
13
+ "save_config",
14
+ "heal_config",
15
+ "migrate_legacy_agent_backends",
16
+ "DEFAULT_CONFIG",
17
+ ]
@@ -4,11 +4,12 @@ Interactive Configuration Manager for IUMBTEMS Epistemic Swarm.
4
4
  Reads, modifies, and persists research parameters in .research/config.json.
5
5
  """
6
6
 
7
+ import copy
7
8
  import sys
8
9
  import os
9
10
  import json
10
11
  from pathlib import Path
11
- from typing import Dict, Any
12
+ from typing import Any, Dict
12
13
 
13
14
  DEFAULT_RESEARCH_DIR = ".research"
14
15
 
@@ -42,25 +43,117 @@ DEFAULT_CONFIG: Dict[str, Any] = {
42
43
  }
43
44
 
44
45
 
46
+ # Historical pre-0.7.6 default that was persisted verbatim into user configs.
47
+ # A per-role pin equal to this value, while the top-level `backend` is not an
48
+ # explicit "claude", is a stale default rather than a deliberate choice: it must
49
+ # not override the host-native backend (opencode on OpenCode). Explicit intent
50
+ # lives in `backend: "claude"` or `IUMBTEMS_BACKEND_<ROLE>`.
51
+ LEGACY_AGENT_BACKEND_PINS = (["claude", "-p"],)
52
+
53
+ AGENT_ROLES = ("alpha", "beta")
54
+
55
+
45
56
  def get_config_path(base_dir: str = DEFAULT_RESEARCH_DIR) -> Path:
46
57
  return Path(base_dir) / "config.json"
47
58
 
48
59
 
60
+ def _merge_agents(default_agents: Any, file_agents: Any) -> Dict[str, Any]:
61
+ """Deep-merge the per-role `agents` block, one role/key at a time.
62
+
63
+ A shallow `merged.update(data)` let a single on-disk role key clobber the
64
+ whole default block, which silently re-pinned every agent to whatever the
65
+ user's file happened to contain (the host-native default was never reached).
66
+ """
67
+ merged = copy.deepcopy(default_agents) if isinstance(default_agents, dict) else {}
68
+ if not isinstance(file_agents, dict):
69
+ return merged
70
+ for role, role_cfg in file_agents.items():
71
+ if not isinstance(role_cfg, dict):
72
+ # Preserve unexpected shapes verbatim rather than crashing; the
73
+ # backend resolver tolerates non-dict role configs.
74
+ merged[role] = copy.deepcopy(role_cfg)
75
+ continue
76
+ base = merged.get(role)
77
+ if isinstance(base, dict):
78
+ base.update(copy.deepcopy(role_cfg))
79
+ else:
80
+ merged[role] = copy.deepcopy(role_cfg)
81
+ return merged
82
+
83
+
84
+ def migrate_legacy_agent_backends(cfg: Dict[str, Any]) -> bool:
85
+ """Drop stale pre-0.7.6 `["claude", "-p"]` pins in place; True if changed.
86
+
87
+ Only fires when the top-level backend is not an explicit "claude", so a user
88
+ who deliberately selected Claude is never silently switched to something
89
+ else. To keep Claude on an OpenCode host, set `backend: "claude"`.
90
+ """
91
+ if str(cfg.get("backend") or "auto").strip().lower() == "claude":
92
+ return False
93
+ agents = cfg.get("agents")
94
+ if not isinstance(agents, dict):
95
+ return False
96
+ changed = False
97
+ for role in AGENT_ROLES:
98
+ role_cfg = agents.get(role)
99
+ if (
100
+ isinstance(role_cfg, dict)
101
+ and role_cfg.get("backend") in LEGACY_AGENT_BACKEND_PINS
102
+ ):
103
+ role_cfg["backend"] = None
104
+ changed = True
105
+ return changed
106
+
107
+
49
108
  def load_config(base_dir: str = DEFAULT_RESEARCH_DIR) -> Dict[str, Any]:
109
+ """Load config merged over defaults. Pure — never writes to disk.
110
+
111
+ Applies the legacy-pin migration in memory so a stale config stops forcing
112
+ Claude immediately; `heal_config` persists that migration on next touch.
113
+ """
114
+ base = copy.deepcopy(DEFAULT_CONFIG)
50
115
  cfg_file = get_config_path(base_dir)
51
116
  if not cfg_file.exists():
52
- return dict(DEFAULT_CONFIG)
117
+ return base
53
118
  try:
54
119
  with open(cfg_file, "r", encoding="utf-8") as f:
55
120
  data = json.load(f)
56
- merged = dict(DEFAULT_CONFIG)
57
- merged.update(data)
58
- return merged
59
121
  except Exception as e:
60
122
  sys.stderr.write(
61
123
  f"[swarm-config] Warning: Failed to parse {cfg_file}: {e}. Using defaults.\n"
62
124
  )
63
- return dict(DEFAULT_CONFIG)
125
+ return base
126
+ if not isinstance(data, dict):
127
+ sys.stderr.write(
128
+ f"[swarm-config] Warning: {cfg_file} is not a JSON object. Using defaults.\n"
129
+ )
130
+ return base
131
+ merged: Dict[str, Any] = {**base, **data}
132
+ merged["agents"] = _merge_agents(base.get("agents", {}), data.get("agents"))
133
+ migrate_legacy_agent_backends(merged)
134
+ return merged
135
+
136
+
137
+ def heal_config(base_dir: str = DEFAULT_RESEARCH_DIR) -> bool:
138
+ """Persist a legacy agent-backend migration to disk; True if rewritten.
139
+
140
+ `load_config` stays side-effect free; the CLI and MCP surfaces call this so
141
+ an affected install self-heals instead of re-pinning Claude on every save.
142
+ """
143
+ cfg_file = get_config_path(base_dir)
144
+ if not cfg_file.exists():
145
+ return False
146
+ try:
147
+ with open(cfg_file, "r", encoding="utf-8") as f:
148
+ raw = json.load(f)
149
+ except Exception:
150
+ return False
151
+ if not isinstance(raw, dict):
152
+ return False
153
+ if not migrate_legacy_agent_backends(raw):
154
+ return False
155
+ save_config(load_config(base_dir), base_dir)
156
+ return True
64
157
 
65
158
 
66
159
  def save_config(cfg: Dict[str, Any], base_dir: str = DEFAULT_RESEARCH_DIR) -> Path:
@@ -218,6 +311,10 @@ def _parse_cli_updates(args: list, cfg: Dict[str, Any]) -> bool:
218
311
 
219
312
  def main():
220
313
  cfg = load_config()
314
+ if heal_config():
315
+ print(
316
+ "ℹ️ Migrated legacy ['claude', '-p'] agent backend pin → host-native default."
317
+ )
221
318
  args = sys.argv[1:]
222
319
 
223
320
  if not args or "--show" in args: