@heretek-ai/epistemic-swarm 0.7.0 → 0.7.3

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 +66 -6
  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 +349 -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 +325 -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,103 @@ 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
+
47
+ def _default_backend_cmd(config_host: Optional[str] = None) -> List[str]:
48
+ """Host-native default backend argv.
49
+
50
+ Precedence: explicit config_host ("claude"/"opencode") > IUMBTEMS_HOST env
51
+ > binary probe (opencode when claude is absent) > legacy ["claude", "-p"].
52
+ """
53
+ host = (config_host or "").strip().lower()
54
+ if host in ("", "auto"):
55
+ host = os.environ.get("IUMBTEMS_HOST", "").strip().lower()
56
+ if host == "opencode":
57
+ return list(OPENCODE_RUN_BASE)
58
+ if host == "claude":
59
+ return ["claude", "-p"]
60
+ if shutil.which("opencode") and not shutil.which("claude"):
61
+ return list(OPENCODE_RUN_BASE)
62
+ return ["claude", "-p"]
63
+
64
+
65
+ def _backend_family(backend: List[str]) -> str:
66
+ return Path(backend[0]).name if backend else "claude"
67
+
68
+
69
+ def _build_opencode_cmd(
70
+ backend: List[str],
71
+ prompt: str,
72
+ model: Optional[str],
73
+ agent: Optional[str],
74
+ ) -> List[str]:
75
+ """Argv for `opencode run` (docs: positional prompt, -m provider/model,
76
+ --agent <name>, --format json). No --tools/--system-prompt flags exist."""
77
+ cmd = list(backend) + [prompt]
78
+ if agent:
79
+ cmd.extend(["--agent", str(agent)])
80
+ if model:
81
+ cmd.extend(["-m", str(model)])
82
+ cmd.extend(["--format", "json"])
83
+ return cmd
84
+
85
+
86
+ _TEXT_EVENT_KEYS = ("text", "content", "message", "output", "result")
87
+ _TEXT_EVENT_MARKERS = ("message", "text", "result", "output", "content")
88
+
89
+
90
+ def _event_text(obj: Dict[str, Any]) -> Optional[str]:
91
+ """Text payload of one parsed event line, or None.
92
+
93
+ Handles the real `opencode run --format json` shape
94
+ ({"type":"text", "part":{"type":"text","text":"..."}}) plus top-level
95
+ variants for tolerance.
96
+ """
97
+ part = obj.get("part")
98
+ if isinstance(part, dict):
99
+ val = part.get("text")
100
+ if isinstance(val, str) and val.strip():
101
+ return val
102
+ kind = str(obj.get("type", "")).lower()
103
+ if kind and not any(m in kind for m in _TEXT_EVENT_MARKERS):
104
+ return None
105
+ for key in _TEXT_EVENT_KEYS:
106
+ val = obj.get(key)
107
+ if isinstance(val, str) and val.strip():
108
+ return val
109
+ return None
110
+
111
+
112
+ def _extract_opencode_text(raw: str) -> str:
113
+ """Best-effort final text from `opencode run --format json` event stream.
114
+
115
+ Tolerant by design: collects text fields from message/result/output style
116
+ events and falls back to the raw stream when nothing parses, so unknown
117
+ event shapes degrade to unparsed text rather than empty dossiers.
118
+ """
119
+ texts: List[str] = []
120
+ for line in (raw or "").splitlines():
121
+ line = line.strip()
122
+ if not line.startswith("{"):
123
+ continue
124
+ try:
125
+ obj = json.loads(line)
126
+ except (ValueError, TypeError):
127
+ continue
128
+ if isinstance(obj, dict):
129
+ text = _event_text(obj)
130
+ if text:
131
+ texts.append(text)
132
+ return "\n".join(texts).strip() if texts else (raw or "").strip()
133
+
37
134
 
38
135
  def _validate_backend(backend: List[str]) -> List[str]:
39
136
  """Resolve-check a backend argv. Raises ValueError only if nothing can run it.
@@ -72,6 +169,14 @@ def _validate_backend(backend: List[str]) -> List[str]:
72
169
  return list(backend)
73
170
 
74
171
 
172
+ def _first(*values):
173
+ """First truthy value (keeps config-precedence chains flat for S3776)."""
174
+ for v in values:
175
+ if v:
176
+ return v
177
+ return None
178
+
179
+
75
180
  class SwarmRunner:
76
181
  def __init__(
77
182
  self,
@@ -87,14 +192,15 @@ class SwarmRunner:
87
192
  self.base_dir = base_dir or Path(".research")
88
193
  self.mock_mode = mock_mode
89
194
  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)
195
+ cfg = self.config
196
+ self.mode = _first(mode, cfg.get("mode"), "research")
197
+ self.engine = _first(engine, cfg.get("search_engine"), "duckduckgo")
198
+ self.depth = _first(depth, cfg.get("max_iterations"), 2)
93
199
  # Stream F: "dag" (legacy default) or "auction" (Frontier Markets).
94
- self.allocation = allocation or self.config.get("allocation", "dag")
200
+ self.allocation = _first(allocation, cfg.get("allocation"), "dag")
95
201
  # Stream G: optional Domain Pack (constitution) for the auditor.
96
202
  self.domain_pack = (
97
- domain_pack if domain_pack is not None else self.config.get("domain_pack")
203
+ domain_pack if domain_pack is not None else cfg.get("domain_pack")
98
204
  )
99
205
  # Per-agent backend/model overrides (CLI > env > config > default).
100
206
  # Keys are role names ("alpha", "beta"); values are {"backend": [...],
@@ -108,9 +214,10 @@ class SwarmRunner:
108
214
  def _resolve_agent_backend(self, role: str) -> Tuple[List[str], Optional[str]]:
109
215
  """Resolve (backend_cmd, model) for an agent role.
110
216
 
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.
217
+ Precedence: explicit override (set by CLI flags) > env >
218
+ config > host-native default. Returns the legacy ["claude", "-p"]
219
+ only when nothing else selects opencode, preserving prior behavior
220
+ byte-for-byte on Claude Code hosts.
114
221
  """
115
222
  override = self.agent_overrides.get(role) or {}
116
223
  env_backend = os.environ.get(f"IUMBTEMS_BACKEND_{role.upper()}")
@@ -123,11 +230,23 @@ class SwarmRunner:
123
230
  override.get("backend")
124
231
  or (env_backend.split() if env_backend else None)
125
232
  or role_cfg.get("backend")
126
- or ["claude", "-p"]
233
+ or _default_backend_cmd(self.config.get("backend"))
127
234
  )
128
235
  model = override.get("model") or env_model or role_cfg.get("model")
129
236
  return list(backend), model
130
237
 
238
+ def _resolve_opencode_agent(self, role: str) -> Optional[str]:
239
+ """Explicitly configured OpenCode agent for this role, else None.
240
+
241
+ Default is None: the system prompt is inlined into the message because
242
+ `--agent <name>` fails hard ("Agent not found") unless the user
243
+ installed the snippet agent profiles — observed live, so the backend
244
+ must not depend on them. Set `agents.<role>.opencode_agent` (or the
245
+ top-level `opencode_agent` config key) to opt in.
246
+ """
247
+ role_cfg = (self.config.get("agents") or {}).get(role) or {}
248
+ return role_cfg.get("opencode_agent") or self.config.get("opencode_agent")
249
+
131
250
  def build_agent_cmd(
132
251
  self,
133
252
  prompt: str,
@@ -139,8 +258,29 @@ class SwarmRunner:
139
258
 
140
259
  Exposed separately so tests can assert argv shape (e.g. `--model`
141
260
  present when configured, absent in mock/default) without spawning.
261
+ Claude keeps the legacy shape; opencode builds `opencode run` argv.
142
262
  """
143
263
  backend, model = self._resolve_agent_backend(role)
264
+ # S8701 residual: argv-list + shell=False already blocks shell
265
+ # injection, but a prompt beginning with "-" would be parsed as a
266
+ # CLI flag by the backend. Agent prompts are generated text and never
267
+ # legitimately start with a dash.
268
+ if prompt.startswith("-"):
269
+ raise ValueError(
270
+ "Refusing to pass a prompt starting with '-' to the agent "
271
+ "backend (CLI flag injection)"
272
+ )
273
+ if _backend_family(backend) == "opencode":
274
+ agent = self._resolve_opencode_agent(role)
275
+ # No --system-prompt flag exists on `opencode run`, and --agent
276
+ # only works when the user installed the profile. Inline the
277
+ # system prompt into the message so runs never depend on
278
+ # host-side agent configuration.
279
+ if system_prompt_file and system_prompt_file.exists():
280
+ prompt = (
281
+ system_prompt_file.read_text(encoding="utf-8") + "\n\n" + prompt
282
+ )
283
+ return _build_opencode_cmd(backend, prompt, model, agent)
144
284
  cmd = list(backend) + [prompt, "--tools", tools]
145
285
  if model:
146
286
  cmd.extend(["--model", str(model)])
@@ -160,6 +300,7 @@ class SwarmRunner:
160
300
  return self._mock_claude_response(prompt)
161
301
 
162
302
  cmd = self.build_agent_cmd(prompt, system_prompt_file, tools, role=role)
303
+ family = _backend_family(cmd)
163
304
 
164
305
  try:
165
306
  if cmd:
@@ -170,12 +311,21 @@ class SwarmRunner:
170
311
  text=True,
171
312
  check=True,
172
313
  cwd=str(PROJECT_ROOT),
314
+ # stdin MUST be DEVNULL: `opencode run` reads piped stdin to
315
+ # EOF before starting, and the MCP server's inherited stdin
316
+ # pipe is held open by the harness — every agent hung forever
317
+ # (observed live: 10+ min, 1s CPU, no network I/O).
318
+ stdin=subprocess.DEVNULL,
173
319
  shell=False,
174
320
  )
321
+ if family == "opencode":
322
+ return _extract_opencode_text(res.stdout)
175
323
  return res.stdout.strip()
176
324
  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}")
325
+ print(
326
+ f"[ERROR] {family} backend process failed: {e.stderr}", file=sys.stderr
327
+ )
328
+ raise RuntimeError(f"{family} backend execution failed: {e.stderr}")
179
329
  except ValueError as e:
180
330
  # Bad backend config: report it as a run failure, not an abort of the
181
331
  # whole swarm. Matches the pre-allowlist behavior of surfacing the
@@ -238,19 +388,14 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
238
388
  orchestrator_prompt, system_prompt_file=system_prompt
239
389
  )
240
390
 
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())
391
+ # Parse JSON (fence-aware; falls back to a single-scope decomposition)
392
+ manifest_data = self._parse_json_block(raw_output, require_key="scopes")
393
+ if manifest_data is not None:
250
394
  scopes = manifest_data.get("scopes", [])
251
- except Exception as e:
395
+ else:
252
396
  print(
253
- f"[WARN] Failed to parse JSON from orchestrator output: {e}. Using fallback decomposition."
397
+ "[WARN] Failed to parse JSON from orchestrator output. "
398
+ "Using fallback decomposition."
254
399
  )
255
400
  scopes = [
256
401
  {
@@ -269,6 +414,67 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
269
414
  print(f"✅ Generated {len(scopes)} decoupled dialectic scopes.")
270
415
  return scopes
271
416
 
417
+ @staticmethod
418
+ def _parse_json_block(
419
+ text: str, require_key: str = "scope_id"
420
+ ) -> Optional[Dict[str, Any]]:
421
+ """Extract a JSON object from free-form agent stdout (fence-aware)."""
422
+ if not text or not text.strip():
423
+ return None
424
+ candidates = [text]
425
+ if _JSON_FENCE in text:
426
+ candidates.append(text.split(_JSON_FENCE, 1)[1].split("```")[0])
427
+ elif "```" in text:
428
+ candidates.append(text.split("```", 1)[1].split("```")[0])
429
+ start = text.find("{")
430
+ end = text.rfind("}")
431
+ if start >= 0 and end > start:
432
+ candidates.append(text[start : end + 1])
433
+ for candidate in candidates:
434
+ try:
435
+ obj = json.loads(candidate.strip())
436
+ except (ValueError, TypeError):
437
+ continue
438
+ if isinstance(obj, dict) and obj.get(require_key):
439
+ return obj
440
+ return None
441
+
442
+ @staticmethod
443
+ def _parse_dossier_json(text: str) -> Optional[Dict[str, Any]]:
444
+ """Extract a dossier dict from free-form agent stdout (fence-aware)."""
445
+ return SwarmRunner._parse_json_block(text, require_key="scope_id")
446
+
447
+ def _load_agent_dossier(
448
+ self,
449
+ dossier_path: Path,
450
+ scope_id: str,
451
+ role: str,
452
+ transcript: Optional[str] = None,
453
+ ) -> Dict[str, Any]:
454
+ """Load an agent dossier from disk, falling back to stdout parsing.
455
+
456
+ Headless agents sometimes answer in chat instead of writing the
457
+ dossier file; without this fallback the whole scope dies on
458
+ FileNotFoundError (observed live: 10+ minute runs, zero dossiers).
459
+ Parsed stdout dossiers are tagged so the auditor treats them as
460
+ recovered, not natively filed.
461
+ """
462
+ if dossier_path.exists():
463
+ with open(dossier_path, "r", encoding="utf-8") as f:
464
+ return json.load(f)
465
+ recovered = self._parse_dossier_json(transcript or "")
466
+ if recovered is not None:
467
+ recovered.setdefault("recovered_from_stdout", True)
468
+ dossier_path.parent.mkdir(parents=True, exist_ok=True)
469
+ with open(dossier_path, "w", encoding="utf-8") as f:
470
+ json.dump(recovered, f, indent=2)
471
+ print(f" [{role}] ⚠️ Dossier file missing; recovered from stdout.")
472
+ return recovered
473
+ raise FileNotFoundError(
474
+ f"{role} dossier not found at {dossier_path} and no dossier JSON "
475
+ f"in transcript for scope {scope_id}"
476
+ )
477
+
272
478
  def run_agent_alpha(self, scope: Dict[str, Any]):
273
479
  """Executes Agent Alpha (Thesis / Proponent / Structural Auditor) for a scope."""
274
480
  scope_id = scope["scope_id"]
@@ -424,14 +630,15 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
424
630
  system_prompt = self.prompts_dir / "agent_darkharvest.md"
425
631
  else:
426
632
  system_prompt = self.prompts_dir / "agent_alpha_thesis.md"
427
- self.run_claude_process(
633
+ transcript = self.run_claude_process(
428
634
  prompt, system_prompt_file=system_prompt, role="alpha"
429
635
  )
430
636
  dossier_path = (
431
637
  self.state_machine.get_scope_dir(scope_id) / "alpha_dossier.json"
432
638
  )
433
- with open(dossier_path, "r", encoding="utf-8") as f:
434
- dossier = json.load(f)
639
+ dossier = self._load_agent_dossier(
640
+ dossier_path, scope_id, "alpha", transcript
641
+ )
435
642
 
436
643
  self.state_machine.record_agent_completion(scope_id, "alpha", dossier)
437
644
  print(f" [Alpha] ✅ Completed Agent Alpha for [{scope_id}].")
@@ -618,14 +825,15 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
618
825
  system_prompt = self.prompts_dir / "agent_darkharvest.md"
619
826
  else:
620
827
  system_prompt = self.prompts_dir / "agent_beta_antithesis.md"
621
- self.run_claude_process(
828
+ transcript = self.run_claude_process(
622
829
  prompt, system_prompt_file=system_prompt, role="beta"
623
830
  )
624
831
  dossier_path = (
625
832
  self.state_machine.get_scope_dir(scope_id) / "beta_dossier.json"
626
833
  )
627
- with open(dossier_path, "r", encoding="utf-8") as f:
628
- dossier = json.load(f)
834
+ dossier = self._load_agent_dossier(
835
+ dossier_path, scope_id, "beta", transcript
836
+ )
629
837
 
630
838
  self.state_machine.record_agent_completion(scope_id, "beta", dossier)
631
839
  print(f" [Beta] ✅ Completed Agent Beta for [{scope_id}].")
@@ -662,6 +870,64 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
662
870
  )
663
871
  return audit_report
664
872
 
873
+ def _auction_dispatch(self, ready_scopes: List[Dict[str, Any]]) -> None:
874
+ """Stream F: order the ready batch by expected information gain."""
875
+ from runner.auctioneer import (
876
+ estimate_tokens,
877
+ record_scope_telemetry,
878
+ score_scopes,
879
+ )
880
+
881
+ scored = score_scopes(ready_scopes, base_dir=self.base_dir)
882
+ bids = {s.get("scope_id"): b for b, s in scored}
883
+ for _bid, ordered_scope in scored:
884
+ audit_report = self.execute_scope_dialectic(ordered_scope)
885
+ summary = (audit_report or {}).get("summary", {})
886
+ sid = ordered_scope.get("scope_id", "")
887
+ record_scope_telemetry(
888
+ self.base_dir,
889
+ sid,
890
+ # tokens_used is a chars/4 ESTIMATE — flagged approximation.
891
+ tokens_used=estimate_tokens("x" * self._dossier_chars(sid)),
892
+ verified_claims=summary.get("verified_passed", 0),
893
+ bid=bids.get(sid),
894
+ )
895
+
896
+ def _dossier_chars(self, scope_id: str) -> int:
897
+ scope_dir = self.state_machine.get_scope_dir(scope_id)
898
+ total = 0
899
+ for name in ("alpha_dossier.json", "beta_dossier.json"):
900
+ p = scope_dir / name
901
+ if p.exists():
902
+ total += p.stat().st_size
903
+ return total
904
+
905
+ def _run_scope_batches(self) -> bool:
906
+ """Drive scopes by DAG until complete. False on deadlock."""
907
+ while True:
908
+ ready_scopes = self.state_machine.get_ready_scopes()
909
+ if not ready_scopes:
910
+ manifest = self.state_machine.load_global_manifest()
911
+ if self._all_scopes_complete(manifest):
912
+ return True
913
+ print("[ERROR] Deadlock in scope dependency graph.", file=sys.stderr)
914
+ self.state_machine.update_session_status(SessionStatus.FAILED)
915
+ return False
916
+ # "auction" reorders each ready batch by expected information
917
+ # gain; "dag" keeps legacy dependency order.
918
+ if self.allocation == "auction":
919
+ self._auction_dispatch(ready_scopes)
920
+ else:
921
+ for scope in ready_scopes:
922
+ self.execute_scope_dialectic(scope)
923
+
924
+ def _all_scopes_complete(self, manifest: Dict[str, Any]) -> bool:
925
+ return all(
926
+ self.state_machine.load_scope_manifest(s["scope_id"]).get("status")
927
+ == ScopeStatus.COMPLETE.value
928
+ for s in manifest["scopes"]
929
+ )
930
+
665
931
  def run_swarm(self, objective: str, frontier_file: Optional[Path] = None):
666
932
  """Full end-to-end execution loop."""
667
933
  start_time = datetime.now(timezone.utc)
@@ -678,59 +944,8 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
678
944
  self.orchestrate_objective(objective, frontier_file)
679
945
 
680
946
  # 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)
947
+ if not self._run_scope_batches():
948
+ return
734
949
 
735
950
  # 3. Master Synthesis Compilation
736
951
  print(
@@ -742,6 +957,36 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
742
957
  duration = (datetime.now(timezone.utc) - start_time).total_seconds()
743
958
  print(f"\n🎉 Swarm run completed in {duration:.1f}s. Report: {report_path}")
744
959
 
960
+ def _collect_scope_totals(
961
+ self, manifest: Dict[str, Any], synthesis_lines: List[str]
962
+ ) -> Tuple[int, int, List[float]]:
963
+ """Fold per-scope audit/synthesis artifacts into running totals."""
964
+ total_verified = 0
965
+ total_rejected = 0
966
+ all_divergences: List[float] = []
967
+ for scope in manifest["scopes"]:
968
+ sid = scope["scope_id"]
969
+ scope_dir = self.state_machine.get_scope_dir(sid)
970
+ audit_file = scope_dir / "audit_report.json"
971
+ synth_file = scope_dir / "scope_synthesis.md"
972
+
973
+ if audit_file.exists():
974
+ ar = json.loads(audit_file.read_text(encoding="utf-8"))
975
+ summary = ar["summary"]
976
+ total_verified += summary["verified_passed"]
977
+ total_rejected += summary["unverified_rejected"]
978
+ all_divergences.append(summary["divergence_score"])
979
+
980
+ if synth_file.exists():
981
+ synthesis_lines.append(synth_file.read_text(encoding="utf-8"))
982
+ synthesis_lines.append("\n---\n")
983
+ return total_verified, total_rejected, all_divergences
984
+
985
+ def _write_report(self, filename: str, lines: List[str]) -> Path:
986
+ path = self.base_dir / filename
987
+ path.write_text("\n".join(lines), encoding="utf-8")
988
+ return path
989
+
745
990
  def _compile_master_synthesis(self, objective: str) -> Path:
746
991
  manifest = self.state_machine.load_global_manifest()
747
992
  mode_titles = {
@@ -762,27 +1007,9 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
762
1007
  "## Scope Findings & Dialectic Balance Sheets\n",
763
1008
  ]
764
1009
 
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")
1010
+ total_verified, total_rejected, all_divergences = self._collect_scope_totals(
1011
+ manifest, synthesis_lines
1012
+ )
786
1013
 
787
1014
  avg_div = round(sum(all_divergences) / max(1, len(all_divergences)), 2)
788
1015
  synthesis_lines.append("\n## Swarm Epistemic Audit Totals\n")
@@ -794,32 +1021,17 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
794
1021
  )
795
1022
  synthesis_lines.append(f"- **Mean Swarm Divergence Score**: `{avg_div}`")
796
1023
 
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
1024
+ final_path = self._write_report("final_synthesis.md", synthesis_lines)
822
1025
 
1026
+ # Also write specialized report files for audit/scout/brainstorm/darkharvest.
1027
+ specialized = {
1028
+ "audit": "code_audit_report.md",
1029
+ "scout": "oss_scout_report.md",
1030
+ "brainstorm": "brainstorm_report.md",
1031
+ "darkharvest": "darkharvest_report.md",
1032
+ }.get(self.mode)
1033
+ if specialized:
1034
+ return self._write_report(specialized, synthesis_lines)
823
1035
  return final_path
824
1036
 
825
1037
 
@@ -917,11 +1129,21 @@ def main():
917
1129
  default=None,
918
1130
  help="Regulated Domain Pack id (config/domain_packs/<id>.json), e.g. biopharma",
919
1131
  )
1132
+ parser.add_argument(
1133
+ "--backend",
1134
+ choices=["auto", "claude", "opencode"],
1135
+ default=None,
1136
+ help="Agent runtime backend for both roles (default: auto = host-native; explicit beats env/config)",
1137
+ )
920
1138
 
921
1139
  args = parser.parse_args()
922
1140
  frontier_path = Path(args.frontier) if args.frontier else None
923
1141
 
924
1142
  agent_overrides: Dict[str, Dict[str, Any]] = {}
1143
+ if args.backend and args.backend != "auto":
1144
+ argv = ["claude", "-p"] if args.backend == "claude" else ["opencode", "run"]
1145
+ agent_overrides.setdefault("alpha", {})["backend"] = argv
1146
+ agent_overrides.setdefault("beta", {})["backend"] = argv
925
1147
  if args.model_alpha:
926
1148
  agent_overrides.setdefault("alpha", {})["model"] = args.model_alpha
927
1149
  if args.model_beta: