@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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli.js +1 -0
- package/extensions/pi/index.js +7 -1
- package/package.json +1 -1
- package/plugins/darkharvest/skills/darkharvest/scripts/harvest.py +76 -39
- package/plugins/factory/skills/factory/scripts/factory.py +1 -1
- package/plugins/opencode/index.js +66 -6
- package/plugins/opencode/tui.js +66 -38
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/claim_store.py +15 -4
- package/runner/living_dossiers.py +74 -56
- package/runner/mcp_server.py +37 -2
- package/runner/pcrb.py +53 -29
- package/runner/refinement.py +70 -27
- package/runner/research_swarm.py +349 -127
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/test_backends.py +325 -0
- package/runner/tests/test_sweep_regressions.py +71 -38
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/build_adapters.py +27 -18
- package/skills/darkharvest/scripts/harvest.py +76 -39
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/search.py +29 -19
- package/skills/epistemic_search/scripts/webcache.py +29 -17
- package/skills/factory/scripts/factory.py +1 -1
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- package/skills/swarm_config/configure.py +70 -37
package/runner/research_swarm.py
CHANGED
|
@@ -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
|
-
|
|
91
|
-
self.
|
|
92
|
-
self.
|
|
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
|
|
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
|
|
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
|
|
112
|
-
config > default. Returns
|
|
113
|
-
|
|
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
|
|
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(
|
|
178
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
395
|
+
else:
|
|
252
396
|
print(
|
|
253
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
682
|
-
|
|
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 =
|
|
766
|
-
|
|
767
|
-
|
|
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.
|
|
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:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|