@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.
- 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 +41 -1
- 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 +336 -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 +280 -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,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
|
-
|
|
91
|
-
self.
|
|
92
|
-
self.
|
|
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
|
|
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
|
|
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
|
|
112
|
-
config > default. Returns
|
|
113
|
-
|
|
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
|
|
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(
|
|
178
|
-
|
|
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
|
-
|
|
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())
|
|
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
|
-
|
|
382
|
+
else:
|
|
252
383
|
print(
|
|
253
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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")
|
|
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.
|
|
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:
|
|
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
|