@heretek-ai/epistemic-swarm 0.7.0 → 0.7.2

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 (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/cli.js +1 -0
  3. package/extensions/pi/index.js +7 -1
  4. package/package.json +1 -1
  5. package/plugins/darkharvest/skills/darkharvest/scripts/harvest.py +76 -39
  6. package/plugins/factory/skills/factory/scripts/factory.py +1 -1
  7. package/plugins/opencode/index.js +41 -1
  8. package/plugins/opencode/tui.js +66 -38
  9. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  23. package/runner/claim_store.py +15 -4
  24. package/runner/living_dossiers.py +74 -56
  25. package/runner/mcp_server.py +37 -2
  26. package/runner/pcrb.py +53 -29
  27. package/runner/refinement.py +70 -27
  28. package/runner/research_swarm.py +336 -127
  29. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  30. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  31. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  32. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  33. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  34. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  35. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  36. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  37. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  38. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  46. package/runner/tests/test_backends.py +280 -0
  47. package/runner/tests/test_sweep_regressions.py +71 -38
  48. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  49. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  50. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  51. package/scripts/build_adapters.py +27 -18
  52. package/skills/darkharvest/scripts/harvest.py +76 -39
  53. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  54. package/skills/epistemic_search/scripts/search.py +29 -19
  55. package/skills/epistemic_search/scripts/webcache.py +29 -17
  56. package/skills/factory/scripts/factory.py +1 -1
  57. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  58. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  59. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  60. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  61. package/skills/swarm_config/configure.py +70 -37
@@ -34,6 +34,104 @@ EXAMPLE_RUST_RAFT_URL = "https://github.com/example/rust-raft"
34
34
  # run_claude_process already enforces; injection is not possible here.
35
35
  KNOWN_BACKEND_BINARIES = {"claude", "opencode", "python", "python3", "node"}
36
36
 
37
+ # Host-native default backends (parity spec section 8). Claude Code spawns
38
+ # `claude -p`; OpenCode spawns `opencode run` (opencode.ai/docs/cli). The
39
+ # OpenCode plugin exports IUMBTEMS_HOST=opencode into every MCP dispatch so
40
+ # the default follows the host; explicit flags/env/config always win.
41
+ OPENCODE_RUN_BASE = ["opencode", "run"]
42
+
43
+ # Fenced JSON block marker shared by orchestrator/dossier stdout parsers.
44
+ _JSON_FENCE = "```json"
45
+
46
+ # Swarm mode -> OpenCode agent carrying the equivalent system prompt
47
+ # (`opencode run` has no --system-prompt flag; the prompt rides on --agent).
48
+ MODE_OPENCODE_AGENT = {
49
+ "research": "alpha-thesis",
50
+ "audit": "code-auditor",
51
+ "scout": "oss-scout",
52
+ "hybrid": "alpha-thesis",
53
+ "brainstorm": "brainstormer",
54
+ "darkharvest": "darkharvester",
55
+ }
56
+
57
+
58
+ def _default_backend_cmd(config_host: Optional[str] = None) -> List[str]:
59
+ """Host-native default backend argv.
60
+
61
+ Precedence: explicit config_host ("claude"/"opencode") > IUMBTEMS_HOST env
62
+ > binary probe (opencode when claude is absent) > legacy ["claude", "-p"].
63
+ """
64
+ host = (config_host or "").strip().lower()
65
+ if host in ("", "auto"):
66
+ host = os.environ.get("IUMBTEMS_HOST", "").strip().lower()
67
+ if host == "opencode":
68
+ return list(OPENCODE_RUN_BASE)
69
+ if host == "claude":
70
+ return ["claude", "-p"]
71
+ if shutil.which("opencode") and not shutil.which("claude"):
72
+ return list(OPENCODE_RUN_BASE)
73
+ return ["claude", "-p"]
74
+
75
+
76
+ def _backend_family(backend: List[str]) -> str:
77
+ return Path(backend[0]).name if backend else "claude"
78
+
79
+
80
+ def _build_opencode_cmd(
81
+ backend: List[str],
82
+ prompt: str,
83
+ model: Optional[str],
84
+ agent: Optional[str],
85
+ ) -> List[str]:
86
+ """Argv for `opencode run` (docs: positional prompt, -m provider/model,
87
+ --agent <name>, --format json). No --tools/--system-prompt flags exist."""
88
+ cmd = list(backend) + [prompt]
89
+ if agent:
90
+ cmd.extend(["--agent", str(agent)])
91
+ if model:
92
+ cmd.extend(["-m", str(model)])
93
+ cmd.extend(["--format", "json"])
94
+ return cmd
95
+
96
+
97
+ _TEXT_EVENT_KEYS = ("text", "content", "message", "output", "result")
98
+ _TEXT_EVENT_MARKERS = ("message", "text", "result", "output", "content")
99
+
100
+
101
+ def _event_text(obj: Dict[str, Any]) -> Optional[str]:
102
+ """Text payload of one parsed event line, or None."""
103
+ kind = str(obj.get("type", "")).lower()
104
+ if kind and not any(m in kind for m in _TEXT_EVENT_MARKERS):
105
+ return None
106
+ for key in _TEXT_EVENT_KEYS:
107
+ val = obj.get(key)
108
+ if isinstance(val, str) and val.strip():
109
+ return val
110
+ return None
111
+
112
+
113
+ def _extract_opencode_text(raw: str) -> str:
114
+ """Best-effort final text from `opencode run --format json` event stream.
115
+
116
+ Tolerant by design: collects text fields from message/result/output style
117
+ events and falls back to the raw stream when nothing parses, so unknown
118
+ event shapes degrade to unparsed text rather than empty dossiers.
119
+ """
120
+ texts: List[str] = []
121
+ for line in (raw or "").splitlines():
122
+ line = line.strip()
123
+ if not line.startswith("{"):
124
+ continue
125
+ try:
126
+ obj = json.loads(line)
127
+ except (ValueError, TypeError):
128
+ continue
129
+ if isinstance(obj, dict):
130
+ text = _event_text(obj)
131
+ if text:
132
+ texts.append(text)
133
+ return "\n".join(texts).strip() if texts else (raw or "").strip()
134
+
37
135
 
38
136
  def _validate_backend(backend: List[str]) -> List[str]:
39
137
  """Resolve-check a backend argv. Raises ValueError only if nothing can run it.
@@ -72,6 +170,14 @@ def _validate_backend(backend: List[str]) -> List[str]:
72
170
  return list(backend)
73
171
 
74
172
 
173
+ def _first(*values):
174
+ """First truthy value (keeps config-precedence chains flat for S3776)."""
175
+ for v in values:
176
+ if v:
177
+ return v
178
+ return None
179
+
180
+
75
181
  class SwarmRunner:
76
182
  def __init__(
77
183
  self,
@@ -87,14 +193,15 @@ class SwarmRunner:
87
193
  self.base_dir = base_dir or Path(".research")
88
194
  self.mock_mode = mock_mode
89
195
  self.config = load_config(str(self.base_dir))
90
- self.mode = mode or self.config.get("mode", "research")
91
- self.engine = engine or self.config.get("search_engine", "duckduckgo")
92
- self.depth = depth or self.config.get("max_iterations", 2)
196
+ cfg = self.config
197
+ self.mode = _first(mode, cfg.get("mode"), "research")
198
+ self.engine = _first(engine, cfg.get("search_engine"), "duckduckgo")
199
+ self.depth = _first(depth, cfg.get("max_iterations"), 2)
93
200
  # Stream F: "dag" (legacy default) or "auction" (Frontier Markets).
94
- self.allocation = allocation or self.config.get("allocation", "dag")
201
+ self.allocation = _first(allocation, cfg.get("allocation"), "dag")
95
202
  # Stream G: optional Domain Pack (constitution) for the auditor.
96
203
  self.domain_pack = (
97
- domain_pack if domain_pack is not None else self.config.get("domain_pack")
204
+ domain_pack if domain_pack is not None else cfg.get("domain_pack")
98
205
  )
99
206
  # Per-agent backend/model overrides (CLI > env > config > default).
100
207
  # Keys are role names ("alpha", "beta"); values are {"backend": [...],
@@ -108,9 +215,10 @@ class SwarmRunner:
108
215
  def _resolve_agent_backend(self, role: str) -> Tuple[List[str], Optional[str]]:
109
216
  """Resolve (backend_cmd, model) for an agent role.
110
217
 
111
- Precedence (Stream E): explicit override (set by CLI flags) > env >
112
- config > default. Returns (["claude", "-p"], None) when nothing is
113
- configured, preserving prior behavior byte-for-byte.
218
+ Precedence: explicit override (set by CLI flags) > env >
219
+ config > host-native default. Returns the legacy ["claude", "-p"]
220
+ only when nothing else selects opencode, preserving prior behavior
221
+ byte-for-byte on Claude Code hosts.
114
222
  """
115
223
  override = self.agent_overrides.get(role) or {}
116
224
  env_backend = os.environ.get(f"IUMBTEMS_BACKEND_{role.upper()}")
@@ -123,11 +231,21 @@ class SwarmRunner:
123
231
  override.get("backend")
124
232
  or (env_backend.split() if env_backend else None)
125
233
  or role_cfg.get("backend")
126
- or ["claude", "-p"]
234
+ or _default_backend_cmd(self.config.get("backend"))
127
235
  )
128
236
  model = override.get("model") or env_model or role_cfg.get("model")
129
237
  return list(backend), model
130
238
 
239
+ def _resolve_opencode_agent(self, role: str) -> Optional[str]:
240
+ """OpenCode agent carrying the system prompt for this mode/role."""
241
+ agents_cfg = self.config.get("agents") or {}
242
+ role_cfg = agents_cfg.get(role) or {}
243
+ if role_cfg.get("opencode_agent"):
244
+ return role_cfg["opencode_agent"]
245
+ if self.mode == "research" and role == "beta":
246
+ return "beta-redteam"
247
+ return MODE_OPENCODE_AGENT.get(self.mode)
248
+
131
249
  def build_agent_cmd(
132
250
  self,
133
251
  prompt: str,
@@ -139,8 +257,22 @@ class SwarmRunner:
139
257
 
140
258
  Exposed separately so tests can assert argv shape (e.g. `--model`
141
259
  present when configured, absent in mock/default) without spawning.
260
+ Claude keeps the legacy shape; opencode builds `opencode run` argv.
142
261
  """
143
262
  backend, model = self._resolve_agent_backend(role)
263
+ # S8701 residual: argv-list + shell=False already blocks shell
264
+ # injection, but a prompt beginning with "-" would be parsed as a
265
+ # CLI flag by the backend. Agent prompts are generated text and never
266
+ # legitimately start with a dash.
267
+ if prompt.startswith("-"):
268
+ raise ValueError(
269
+ "Refusing to pass a prompt starting with '-' to the agent "
270
+ "backend (CLI flag injection)"
271
+ )
272
+ if _backend_family(backend) == "opencode":
273
+ return _build_opencode_cmd(
274
+ backend, prompt, model, self._resolve_opencode_agent(role)
275
+ )
144
276
  cmd = list(backend) + [prompt, "--tools", tools]
145
277
  if model:
146
278
  cmd.extend(["--model", str(model)])
@@ -160,6 +292,7 @@ class SwarmRunner:
160
292
  return self._mock_claude_response(prompt)
161
293
 
162
294
  cmd = self.build_agent_cmd(prompt, system_prompt_file, tools, role=role)
295
+ family = _backend_family(cmd)
163
296
 
164
297
  try:
165
298
  if cmd:
@@ -172,10 +305,14 @@ class SwarmRunner:
172
305
  cwd=str(PROJECT_ROOT),
173
306
  shell=False,
174
307
  )
308
+ if family == "opencode":
309
+ return _extract_opencode_text(res.stdout)
175
310
  return res.stdout.strip()
176
311
  except subprocess.CalledProcessError as e:
177
- print(f"[ERROR] Claude process failed: {e.stderr}", file=sys.stderr)
178
- raise RuntimeError(f"Claude execution failed: {e.stderr}")
312
+ print(
313
+ f"[ERROR] {family} backend process failed: {e.stderr}", file=sys.stderr
314
+ )
315
+ raise RuntimeError(f"{family} backend execution failed: {e.stderr}")
179
316
  except ValueError as e:
180
317
  # Bad backend config: report it as a run failure, not an abort of the
181
318
  # whole swarm. Matches the pre-allowlist behavior of surfacing the
@@ -238,19 +375,14 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
238
375
  orchestrator_prompt, system_prompt_file=system_prompt
239
376
  )
240
377
 
241
- # Parse JSON
242
- try:
243
- # Handle potential markdown fence blocks
244
- clean_json = raw_output
245
- if "```json" in clean_json:
246
- clean_json = clean_json.split("```json")[1].split("```")[0]
247
- elif "```" in clean_json:
248
- clean_json = clean_json.split("```")[1].split("```")[0]
249
- manifest_data = json.loads(clean_json.strip())
378
+ # Parse JSON (fence-aware; falls back to a single-scope decomposition)
379
+ manifest_data = self._parse_json_block(raw_output, require_key="scopes")
380
+ if manifest_data is not None:
250
381
  scopes = manifest_data.get("scopes", [])
251
- except Exception as e:
382
+ else:
252
383
  print(
253
- f"[WARN] Failed to parse JSON from orchestrator output: {e}. Using fallback decomposition."
384
+ "[WARN] Failed to parse JSON from orchestrator output. "
385
+ "Using fallback decomposition."
254
386
  )
255
387
  scopes = [
256
388
  {
@@ -269,6 +401,67 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
269
401
  print(f"✅ Generated {len(scopes)} decoupled dialectic scopes.")
270
402
  return scopes
271
403
 
404
+ @staticmethod
405
+ def _parse_json_block(
406
+ text: str, require_key: str = "scope_id"
407
+ ) -> Optional[Dict[str, Any]]:
408
+ """Extract a JSON object from free-form agent stdout (fence-aware)."""
409
+ if not text or not text.strip():
410
+ return None
411
+ candidates = [text]
412
+ if _JSON_FENCE in text:
413
+ candidates.append(text.split(_JSON_FENCE, 1)[1].split("```")[0])
414
+ elif "```" in text:
415
+ candidates.append(text.split("```", 1)[1].split("```")[0])
416
+ start = text.find("{")
417
+ end = text.rfind("}")
418
+ if start >= 0 and end > start:
419
+ candidates.append(text[start : end + 1])
420
+ for candidate in candidates:
421
+ try:
422
+ obj = json.loads(candidate.strip())
423
+ except (ValueError, TypeError):
424
+ continue
425
+ if isinstance(obj, dict) and obj.get(require_key):
426
+ return obj
427
+ return None
428
+
429
+ @staticmethod
430
+ def _parse_dossier_json(text: str) -> Optional[Dict[str, Any]]:
431
+ """Extract a dossier dict from free-form agent stdout (fence-aware)."""
432
+ return SwarmRunner._parse_json_block(text, require_key="scope_id")
433
+
434
+ def _load_agent_dossier(
435
+ self,
436
+ dossier_path: Path,
437
+ scope_id: str,
438
+ role: str,
439
+ transcript: Optional[str] = None,
440
+ ) -> Dict[str, Any]:
441
+ """Load an agent dossier from disk, falling back to stdout parsing.
442
+
443
+ Headless agents sometimes answer in chat instead of writing the
444
+ dossier file; without this fallback the whole scope dies on
445
+ FileNotFoundError (observed live: 10+ minute runs, zero dossiers).
446
+ Parsed stdout dossiers are tagged so the auditor treats them as
447
+ recovered, not natively filed.
448
+ """
449
+ if dossier_path.exists():
450
+ with open(dossier_path, "r", encoding="utf-8") as f:
451
+ return json.load(f)
452
+ recovered = self._parse_dossier_json(transcript or "")
453
+ if recovered is not None:
454
+ recovered.setdefault("recovered_from_stdout", True)
455
+ dossier_path.parent.mkdir(parents=True, exist_ok=True)
456
+ with open(dossier_path, "w", encoding="utf-8") as f:
457
+ json.dump(recovered, f, indent=2)
458
+ print(f" [{role}] ⚠️ Dossier file missing; recovered from stdout.")
459
+ return recovered
460
+ raise FileNotFoundError(
461
+ f"{role} dossier not found at {dossier_path} and no dossier JSON "
462
+ f"in transcript for scope {scope_id}"
463
+ )
464
+
272
465
  def run_agent_alpha(self, scope: Dict[str, Any]):
273
466
  """Executes Agent Alpha (Thesis / Proponent / Structural Auditor) for a scope."""
274
467
  scope_id = scope["scope_id"]
@@ -424,14 +617,15 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
424
617
  system_prompt = self.prompts_dir / "agent_darkharvest.md"
425
618
  else:
426
619
  system_prompt = self.prompts_dir / "agent_alpha_thesis.md"
427
- self.run_claude_process(
620
+ transcript = self.run_claude_process(
428
621
  prompt, system_prompt_file=system_prompt, role="alpha"
429
622
  )
430
623
  dossier_path = (
431
624
  self.state_machine.get_scope_dir(scope_id) / "alpha_dossier.json"
432
625
  )
433
- with open(dossier_path, "r", encoding="utf-8") as f:
434
- dossier = json.load(f)
626
+ dossier = self._load_agent_dossier(
627
+ dossier_path, scope_id, "alpha", transcript
628
+ )
435
629
 
436
630
  self.state_machine.record_agent_completion(scope_id, "alpha", dossier)
437
631
  print(f" [Alpha] ✅ Completed Agent Alpha for [{scope_id}].")
@@ -618,14 +812,15 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
618
812
  system_prompt = self.prompts_dir / "agent_darkharvest.md"
619
813
  else:
620
814
  system_prompt = self.prompts_dir / "agent_beta_antithesis.md"
621
- self.run_claude_process(
815
+ transcript = self.run_claude_process(
622
816
  prompt, system_prompt_file=system_prompt, role="beta"
623
817
  )
624
818
  dossier_path = (
625
819
  self.state_machine.get_scope_dir(scope_id) / "beta_dossier.json"
626
820
  )
627
- with open(dossier_path, "r", encoding="utf-8") as f:
628
- dossier = json.load(f)
821
+ dossier = self._load_agent_dossier(
822
+ dossier_path, scope_id, "beta", transcript
823
+ )
629
824
 
630
825
  self.state_machine.record_agent_completion(scope_id, "beta", dossier)
631
826
  print(f" [Beta] ✅ Completed Agent Beta for [{scope_id}].")
@@ -662,6 +857,64 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
662
857
  )
663
858
  return audit_report
664
859
 
860
+ def _auction_dispatch(self, ready_scopes: List[Dict[str, Any]]) -> None:
861
+ """Stream F: order the ready batch by expected information gain."""
862
+ from runner.auctioneer import (
863
+ estimate_tokens,
864
+ record_scope_telemetry,
865
+ score_scopes,
866
+ )
867
+
868
+ scored = score_scopes(ready_scopes, base_dir=self.base_dir)
869
+ bids = {s.get("scope_id"): b for b, s in scored}
870
+ for _bid, ordered_scope in scored:
871
+ audit_report = self.execute_scope_dialectic(ordered_scope)
872
+ summary = (audit_report or {}).get("summary", {})
873
+ sid = ordered_scope.get("scope_id", "")
874
+ record_scope_telemetry(
875
+ self.base_dir,
876
+ sid,
877
+ # tokens_used is a chars/4 ESTIMATE — flagged approximation.
878
+ tokens_used=estimate_tokens("x" * self._dossier_chars(sid)),
879
+ verified_claims=summary.get("verified_passed", 0),
880
+ bid=bids.get(sid),
881
+ )
882
+
883
+ def _dossier_chars(self, scope_id: str) -> int:
884
+ scope_dir = self.state_machine.get_scope_dir(scope_id)
885
+ total = 0
886
+ for name in ("alpha_dossier.json", "beta_dossier.json"):
887
+ p = scope_dir / name
888
+ if p.exists():
889
+ total += p.stat().st_size
890
+ return total
891
+
892
+ def _run_scope_batches(self) -> bool:
893
+ """Drive scopes by DAG until complete. False on deadlock."""
894
+ while True:
895
+ ready_scopes = self.state_machine.get_ready_scopes()
896
+ if not ready_scopes:
897
+ manifest = self.state_machine.load_global_manifest()
898
+ if self._all_scopes_complete(manifest):
899
+ return True
900
+ print("[ERROR] Deadlock in scope dependency graph.", file=sys.stderr)
901
+ self.state_machine.update_session_status(SessionStatus.FAILED)
902
+ return False
903
+ # "auction" reorders each ready batch by expected information
904
+ # gain; "dag" keeps legacy dependency order.
905
+ if self.allocation == "auction":
906
+ self._auction_dispatch(ready_scopes)
907
+ else:
908
+ for scope in ready_scopes:
909
+ self.execute_scope_dialectic(scope)
910
+
911
+ def _all_scopes_complete(self, manifest: Dict[str, Any]) -> bool:
912
+ return all(
913
+ self.state_machine.load_scope_manifest(s["scope_id"]).get("status")
914
+ == ScopeStatus.COMPLETE.value
915
+ for s in manifest["scopes"]
916
+ )
917
+
665
918
  def run_swarm(self, objective: str, frontier_file: Optional[Path] = None):
666
919
  """Full end-to-end execution loop."""
667
920
  start_time = datetime.now(timezone.utc)
@@ -678,59 +931,8 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
678
931
  self.orchestrate_objective(objective, frontier_file)
679
932
 
680
933
  # 2. Execute scopes according to DAG
681
- while True:
682
- ready_scopes = self.state_machine.get_ready_scopes()
683
- if not ready_scopes:
684
- # Check if all scopes are complete
685
- manifest = self.state_machine.load_global_manifest()
686
- all_complete = all(
687
- self.state_machine.load_scope_manifest(s["scope_id"]).get("status")
688
- == ScopeStatus.COMPLETE.value
689
- for s in manifest["scopes"]
690
- )
691
- if all_complete:
692
- break
693
- else:
694
- print(
695
- "[ERROR] Deadlock in scope dependency graph.", file=sys.stderr
696
- )
697
- self.state_machine.update_session_status(SessionStatus.FAILED)
698
- return
699
-
700
- # Stream F: "auction" reorders each ready batch by expected
701
- # information gain; "dag" keeps legacy dependency order (all ready
702
- # scopes dispatched in the batch, unchanged).
703
- if self.allocation == "auction":
704
- from runner.auctioneer import (
705
- estimate_tokens,
706
- record_scope_telemetry,
707
- score_scopes,
708
- )
709
-
710
- scored = score_scopes(ready_scopes, base_dir=self.base_dir)
711
- ordered = [s for _b, s in scored]
712
- bids = {s.get("scope_id"): b for b, s in scored}
713
- for ordered_scope in ordered:
714
- audit_report = self.execute_scope_dialectic(ordered_scope)
715
- summary = (audit_report or {}).get("summary", {})
716
- sid = ordered_scope.get("scope_id", "")
717
- # tokens_used is a chars/4 ESTIMATE — flagged approximation.
718
- dossier_chars = 0
719
- scope_dir = self.state_machine.get_scope_dir(sid)
720
- for name in ("alpha_dossier.json", "beta_dossier.json"):
721
- p = scope_dir / name
722
- if p.exists():
723
- dossier_chars += p.stat().st_size
724
- record_scope_telemetry(
725
- self.base_dir,
726
- sid,
727
- tokens_used=estimate_tokens("x" * dossier_chars),
728
- verified_claims=summary.get("verified_passed", 0),
729
- bid=bids.get(sid),
730
- )
731
- else:
732
- for scope in ready_scopes:
733
- self.execute_scope_dialectic(scope)
934
+ if not self._run_scope_batches():
935
+ return
734
936
 
735
937
  # 3. Master Synthesis Compilation
736
938
  print(
@@ -742,6 +944,36 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
742
944
  duration = (datetime.now(timezone.utc) - start_time).total_seconds()
743
945
  print(f"\n🎉 Swarm run completed in {duration:.1f}s. Report: {report_path}")
744
946
 
947
+ def _collect_scope_totals(
948
+ self, manifest: Dict[str, Any], synthesis_lines: List[str]
949
+ ) -> Tuple[int, int, List[float]]:
950
+ """Fold per-scope audit/synthesis artifacts into running totals."""
951
+ total_verified = 0
952
+ total_rejected = 0
953
+ all_divergences: List[float] = []
954
+ for scope in manifest["scopes"]:
955
+ sid = scope["scope_id"]
956
+ scope_dir = self.state_machine.get_scope_dir(sid)
957
+ audit_file = scope_dir / "audit_report.json"
958
+ synth_file = scope_dir / "scope_synthesis.md"
959
+
960
+ if audit_file.exists():
961
+ ar = json.loads(audit_file.read_text(encoding="utf-8"))
962
+ summary = ar["summary"]
963
+ total_verified += summary["verified_passed"]
964
+ total_rejected += summary["unverified_rejected"]
965
+ all_divergences.append(summary["divergence_score"])
966
+
967
+ if synth_file.exists():
968
+ synthesis_lines.append(synth_file.read_text(encoding="utf-8"))
969
+ synthesis_lines.append("\n---\n")
970
+ return total_verified, total_rejected, all_divergences
971
+
972
+ def _write_report(self, filename: str, lines: List[str]) -> Path:
973
+ path = self.base_dir / filename
974
+ path.write_text("\n".join(lines), encoding="utf-8")
975
+ return path
976
+
745
977
  def _compile_master_synthesis(self, objective: str) -> Path:
746
978
  manifest = self.state_machine.load_global_manifest()
747
979
  mode_titles = {
@@ -762,27 +994,9 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
762
994
  "## Scope Findings & Dialectic Balance Sheets\n",
763
995
  ]
764
996
 
765
- total_verified = 0
766
- total_rejected = 0
767
- all_divergences = []
768
-
769
- for scope in manifest["scopes"]:
770
- sid = scope["scope_id"]
771
- scope_dir = self.state_machine.get_scope_dir(sid)
772
- audit_file = scope_dir / "audit_report.json"
773
- synth_file = scope_dir / "scope_synthesis.md"
774
-
775
- if audit_file.exists():
776
- with open(audit_file, "r", encoding="utf-8") as f:
777
- ar = json.load(f)
778
- total_verified += ar["summary"]["verified_passed"]
779
- total_rejected += ar["summary"]["unverified_rejected"]
780
- all_divergences.append(ar["summary"]["divergence_score"])
781
-
782
- if synth_file.exists():
783
- with open(synth_file, "r", encoding="utf-8") as f:
784
- synthesis_lines.append(f.read())
785
- synthesis_lines.append("\n---\n")
997
+ total_verified, total_rejected, all_divergences = self._collect_scope_totals(
998
+ manifest, synthesis_lines
999
+ )
786
1000
 
787
1001
  avg_div = round(sum(all_divergences) / max(1, len(all_divergences)), 2)
788
1002
  synthesis_lines.append("\n## Swarm Epistemic Audit Totals\n")
@@ -794,32 +1008,17 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
794
1008
  )
795
1009
  synthesis_lines.append(f"- **Mean Swarm Divergence Score**: `{avg_div}`")
796
1010
 
797
- final_path = self.base_dir / "final_synthesis.md"
798
- with open(final_path, "w", encoding="utf-8") as f:
799
- f.write("\n".join(synthesis_lines))
800
-
801
- # Also write specialized report files for audit and scout modes
802
- if self.mode == "audit":
803
- audit_path = self.base_dir / "code_audit_report.md"
804
- with open(audit_path, "w", encoding="utf-8") as f:
805
- f.write("\n".join(synthesis_lines))
806
- return audit_path
807
- elif self.mode == "scout":
808
- scout_path = self.base_dir / "oss_scout_report.md"
809
- with open(scout_path, "w", encoding="utf-8") as f:
810
- f.write("\n".join(synthesis_lines))
811
- return scout_path
812
- elif self.mode == "brainstorm":
813
- brainstorm_path = self.base_dir / "brainstorm_report.md"
814
- with open(brainstorm_path, "w", encoding="utf-8") as f:
815
- f.write("\n".join(synthesis_lines))
816
- return brainstorm_path
817
- elif self.mode == "darkharvest":
818
- darkharvest_path = self.base_dir / "darkharvest_report.md"
819
- with open(darkharvest_path, "w", encoding="utf-8") as f:
820
- f.write("\n".join(synthesis_lines))
821
- return darkharvest_path
1011
+ final_path = self._write_report("final_synthesis.md", synthesis_lines)
822
1012
 
1013
+ # Also write specialized report files for audit/scout/brainstorm/darkharvest.
1014
+ specialized = {
1015
+ "audit": "code_audit_report.md",
1016
+ "scout": "oss_scout_report.md",
1017
+ "brainstorm": "brainstorm_report.md",
1018
+ "darkharvest": "darkharvest_report.md",
1019
+ }.get(self.mode)
1020
+ if specialized:
1021
+ return self._write_report(specialized, synthesis_lines)
823
1022
  return final_path
824
1023
 
825
1024
 
@@ -917,11 +1116,21 @@ def main():
917
1116
  default=None,
918
1117
  help="Regulated Domain Pack id (config/domain_packs/<id>.json), e.g. biopharma",
919
1118
  )
1119
+ parser.add_argument(
1120
+ "--backend",
1121
+ choices=["auto", "claude", "opencode"],
1122
+ default=None,
1123
+ help="Agent runtime backend for both roles (default: auto = host-native; explicit beats env/config)",
1124
+ )
920
1125
 
921
1126
  args = parser.parse_args()
922
1127
  frontier_path = Path(args.frontier) if args.frontier else None
923
1128
 
924
1129
  agent_overrides: Dict[str, Dict[str, Any]] = {}
1130
+ if args.backend and args.backend != "auto":
1131
+ argv = ["claude", "-p"] if args.backend == "claude" else ["opencode", "run"]
1132
+ agent_overrides.setdefault("alpha", {})["backend"] = argv
1133
+ agent_overrides.setdefault("beta", {})["backend"] = argv
925
1134
  if args.model_alpha:
926
1135
  agent_overrides.setdefault("alpha", {})["model"] = args.model_alpha
927
1136
  if args.model_beta: