@heretek-ai/epistemic-swarm 0.4.7 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/cli.js +59 -7
  3. package/config/mcp_launcher.py +75 -48
  4. package/extensions/pi/index.js +25 -5
  5. package/install.sh +5 -5
  6. package/package.json +1 -1
  7. package/plugins/antigravity/plugin.json +1 -1
  8. package/plugins/gemini/gemini-extension.json +1 -1
  9. package/plugins/opencode/index.js +308 -142
  10. package/plugins/opencode/tui.js +7 -9
  11. package/plugins/research-cache/skills/research_cache/hasher.py +27 -9
  12. package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +26 -18
  13. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  27. package/runner/auditor_engine.py +122 -102
  28. package/runner/claim_store.py +5 -4
  29. package/runner/living_dossiers.py +32 -10
  30. package/runner/mcp_protocol.py +1 -1
  31. package/runner/mcp_server.py +23 -11
  32. package/runner/path_safety.py +62 -0
  33. package/runner/pcrb.py +68 -39
  34. package/runner/pcrb_verify.py +82 -52
  35. package/runner/refinement.py +61 -47
  36. package/runner/research_swarm.py +61 -7
  37. package/runner/state_machine.py +9 -6
  38. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  52. package/runner/tests/test_mcp_server.py +0 -1
  53. package/runner/tests/test_opencode_ux.py +368 -33
  54. package/runner/tests/test_swarm.py +20 -9
  55. package/runner/tests/test_sweep_regressions.py +373 -0
  56. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  57. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  58. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  59. package/scripts/auction_experiment.py +38 -30
  60. package/scripts/bet1_advisory_spike.py +41 -25
  61. package/scripts/build_adapters.py +52 -37
  62. package/scripts/divergence_experiment.py +61 -31
  63. package/skills/brainstorming/scripts/brainstorm.py +3 -2
  64. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  65. package/skills/epistemic_search/scripts/fetch.py +0 -1
  66. package/skills/epistemic_search/scripts/search.py +202 -73
  67. package/skills/epistemic_search/scripts/webcache.py +1 -1
  68. package/skills/grilling/socratic_tree.py +26 -18
  69. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  70. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  71. package/skills/research_cache/hasher.py +27 -9
  72. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  73. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  74. package/skills/swarm_config/configure.py +32 -28
@@ -5,30 +5,42 @@ Advisory mode ONLY: findings are formatted as PR comments, never block, and
5
5
  the process always exits 0. Measures: seeded-defect hit rate (finding cites
6
6
  seeded file + line within +/-3) and clean-fixture false-positive rate.
7
7
 
8
- python3 scripts/bet1_advisory_spike.py --generate --dir /tmp/bet1
9
- python3 scripts/bet1_advisory_spike.py --run --mock --dir /tmp/bet1
10
- python3 scripts/bet1_advisory_spike.py --run --live --only bet1-01,bet1-02 --dir /tmp/bet1
11
- python3 scripts/bet1_advisory_spike.py --score --dir /tmp/bet1
8
+ The default workdir is a per-user scratch dir under the system temp dir
9
+ (`<tmpdir>/bet1_<uid>`), not the shared world-writable `/tmp/bet1` it used to
10
+ be. Pass --dir explicitly to override:
11
+
12
+ python3 scripts/bet1_advisory_spike.py --generate
13
+ python3 scripts/bet1_advisory_spike.py --run --mock
14
+ python3 scripts/bet1_advisory_spike.py --run --live --only bet1-01,bet1-02
15
+ python3 scripts/bet1_advisory_spike.py --score
12
16
 
13
17
  Live runs invoke real audits (LLM spend); mock runs exercise dispatch only.
14
18
  """
15
19
 
16
20
  import argparse
17
21
  import json
22
+ import os
18
23
  import re
19
24
  import subprocess
20
25
  import sys
26
+ import tempfile
21
27
  from pathlib import Path
22
28
 
23
29
  PROJECT_ROOT = Path(__file__).resolve().parent.parent
24
30
  LINE_TOLERANCE = 3
25
31
 
32
+ IMPORT_OS = "import os"
33
+ IMPORT_SQLITE3 = "import sqlite3"
34
+ IMPORT_THREADING = "import threading"
35
+ SQLITE_CONNECT_DB = " conn = sqlite3.connect(db)"
36
+ MANIFEST_FILENAME = "manifest.json"
37
+
26
38
  # name -> (filename, lines, seeded_line_1based, description)
27
39
  FIXTURES = {
28
40
  "hardcoded-secret-1": (
29
41
  "auth.py",
30
42
  [
31
- 'API_KEY = "sk-live-51H7x9Q2mZ4v8K3n" # production key',
43
+ 'API_KEY = "dummy-test-key-51H7x9Q2mZ4v8K3n" # production key',
32
44
  "",
33
45
  "def headers():",
34
46
  ' return {"Authorization": f"Bearer {API_KEY}"}',
@@ -39,7 +51,7 @@ FIXTURES = {
39
51
  "hardcoded-secret-2": (
40
52
  "config.py",
41
53
  [
42
- "import os",
54
+ IMPORT_OS,
43
55
  "",
44
56
  'DB_PASSWORD = "P@ssw0rd-2024-prod"',
45
57
  "",
@@ -52,10 +64,10 @@ FIXTURES = {
52
64
  "sqli-concat-1": (
53
65
  "users.py",
54
66
  [
55
- "import sqlite3",
67
+ IMPORT_SQLITE3,
56
68
  "",
57
69
  "def get_user(db, username):",
58
- " conn = sqlite3.connect(db)",
70
+ SQLITE_CONNECT_DB,
59
71
  " cur = conn.cursor()",
60
72
  ' query = "SELECT * FROM users WHERE name = \'" + username + "\'"',
61
73
  " cur.execute(query)",
@@ -67,10 +79,10 @@ FIXTURES = {
67
79
  "sqli-concat-2": (
68
80
  "orders.py",
69
81
  [
70
- "import sqlite3",
82
+ IMPORT_SQLITE3,
71
83
  "",
72
84
  "def get_orders(db, status, limit):",
73
- " conn = sqlite3.connect(db)",
85
+ SQLITE_CONNECT_DB,
74
86
  " sql = f\"SELECT * FROM orders WHERE status='{status}' LIMIT {limit}\"",
75
87
  " return conn.execute(sql).fetchall()",
76
88
  ],
@@ -80,7 +92,7 @@ FIXTURES = {
80
92
  "race-counter-1": (
81
93
  "counter.py",
82
94
  [
83
- "import threading",
95
+ IMPORT_THREADING,
84
96
  "",
85
97
  "total = 0",
86
98
  "",
@@ -97,7 +109,7 @@ FIXTURES = {
97
109
  "race-counter-2": (
98
110
  "cache.py",
99
111
  [
100
- "import threading",
112
+ IMPORT_THREADING,
101
113
  "",
102
114
  "_cache = {}",
103
115
  "",
@@ -205,7 +217,7 @@ FIXTURES = {
205
217
  "path-traversal-1": (
206
218
  "files.py",
207
219
  [
208
- "import os",
220
+ IMPORT_OS,
209
221
  "",
210
222
  'BASE = "/srv/uploads"',
211
223
  "",
@@ -219,7 +231,7 @@ FIXTURES = {
219
231
  "path-traversal-2": (
220
232
  "export.py",
221
233
  [
222
- "import os",
234
+ IMPORT_OS,
223
235
  "import shutil",
224
236
  "",
225
237
  "def export_report(name, dest_dir):",
@@ -243,10 +255,10 @@ CLEAN_FIXTURES = {
243
255
  "clean-2": (
244
256
  "queries.py",
245
257
  [
246
- "import sqlite3",
258
+ IMPORT_SQLITE3,
247
259
  "",
248
260
  "def get_user(db, username):",
249
- " conn = sqlite3.connect(db)",
261
+ SQLITE_CONNECT_DB,
250
262
  " return conn.execute(",
251
263
  ' "SELECT * FROM users WHERE name = ?", (username,)',
252
264
  " ).fetchall()",
@@ -269,7 +281,7 @@ CLEAN_FIXTURES = {
269
281
  "clean-4": (
270
282
  "counter_safe.py",
271
283
  [
272
- "import threading",
284
+ IMPORT_THREADING,
273
285
  "",
274
286
  "_lock = threading.Lock()",
275
287
  "total = 0",
@@ -285,7 +297,7 @@ CLEAN_FIXTURES = {
285
297
 
286
298
 
287
299
  def generate(root):
288
- root = Path(root)
300
+ root = Path(os.path.realpath(str(root)))
289
301
  manifest = []
290
302
  for fid, (fname, lines, line, desc) in FIXTURES.items():
291
303
  d = root / fid
@@ -315,7 +327,7 @@ def generate(root):
315
327
  "clean": True,
316
328
  }
317
329
  )
318
- root.joinpath("manifest.json").write_text(
330
+ root.joinpath(MANIFEST_FILENAME).write_text(
319
331
  json.dumps(manifest, indent=2), encoding="utf-8"
320
332
  )
321
333
  print(f"generated {len(manifest)} fixtures in {root}")
@@ -324,7 +336,8 @@ def generate(root):
324
336
 
325
337
  def run_audit(target_dir, mock, timeout=600):
326
338
  """Invoke the real dispatch path. Advisory: findings only, never blocks."""
327
- args = {"target": str(target_dir)}
339
+ clean_target = os.path.realpath(str(target_dir))
340
+ args = {"target": clean_target}
328
341
  if mock:
329
342
  args["mock_mode"] = True
330
343
  proc = subprocess.run(
@@ -339,6 +352,7 @@ def run_audit(target_dir, mock, timeout=600):
339
352
  text=True,
340
353
  timeout=timeout,
341
354
  cwd=str(PROJECT_ROOT),
355
+ shell=False,
342
356
  )
343
357
  return proc.stdout + proc.stderr
344
358
 
@@ -367,10 +381,11 @@ def score_fixture(entry, findings):
367
381
 
368
382
 
369
383
  def score_all(root):
370
- manifest = json.loads((Path(root) / "manifest.json").read_text(encoding="utf-8"))
384
+ canonical_root = Path(os.path.realpath(str(root)))
385
+ manifest = json.loads((canonical_root / MANIFEST_FILENAME).read_text(encoding="utf-8"))
371
386
  results = []
372
387
  for entry in manifest:
373
- comment_file = Path(root) / entry["id"] / "advisory.md"
388
+ comment_file = canonical_root / entry["id"] / "advisory.md"
374
389
  findings = (
375
390
  comment_file.read_text(encoding="utf-8") if comment_file.exists() else ""
376
391
  )
@@ -393,7 +408,8 @@ def score_all(root):
393
408
 
394
409
  def main(argv=None):
395
410
  ap = argparse.ArgumentParser()
396
- ap.add_argument("--dir", default="/tmp/bet1")
411
+ default_dir = os.path.join(tempfile.gettempdir(), f"bet1_{os.getuid() if hasattr(os, 'getuid') else 'user'}")
412
+ ap.add_argument("--dir", default=default_dir)
397
413
  ap.add_argument("--generate", action="store_true")
398
414
  ap.add_argument("--run", action="store_true")
399
415
  ap.add_argument("--score", action="store_true")
@@ -401,12 +417,12 @@ def main(argv=None):
401
417
  ap.add_argument("--live", action="store_true")
402
418
  ap.add_argument("--only", default="")
403
419
  args = ap.parse_args(argv)
404
- root = Path(args.dir)
420
+ root = Path(os.path.realpath(str(args.dir)))
405
421
 
406
422
  if args.generate or (not args.run and not args.score):
407
423
  generate(root)
408
424
  if args.run:
409
- manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
425
+ manifest = json.loads((root / MANIFEST_FILENAME).read_text(encoding="utf-8"))
410
426
  only = {s.strip() for s in args.only.split(",") if s.strip()}
411
427
  for entry in manifest:
412
428
  if only and entry["id"] not in only:
@@ -28,6 +28,7 @@ import json
28
28
  import shutil
29
29
  import sys
30
30
  from pathlib import Path
31
+ from typing import List
31
32
 
32
33
  PROJECT_ROOT = Path(__file__).resolve().parent.parent
33
34
  CANONICAL_SKILLS = [
@@ -85,6 +86,9 @@ Epistemic rules apply regardless of harness: tag claims as
85
86
  """
86
87
 
87
88
 
89
+ SKILL_FILENAME = "SKILL.md"
90
+
91
+
88
92
  def render_stub(skill: str) -> str:
89
93
  tools = ", ".join(f"`{t}`" for t in SKILL_TOOLS.get(skill, []))
90
94
  return STUB_TEMPLATE.format(
@@ -94,62 +98,73 @@ def render_stub(skill: str) -> str:
94
98
  )
95
99
 
96
100
 
101
+ def _build_skill_stub(target_base: Path, skill: str) -> bool:
102
+ src = PROJECT_ROOT / "skills" / skill
103
+ if not src.exists():
104
+ print(f"[WARN] canonical skill missing: skills/{skill}", file=sys.stderr)
105
+ return False
106
+ if not (src / SKILL_FILENAME).exists():
107
+ print(f"[WARN] skills/{skill}/{SKILL_FILENAME} missing", file=sys.stderr)
108
+ return False
109
+ dst = target_base / skill
110
+ if dst.exists():
111
+ shutil.rmtree(dst)
112
+ dst.mkdir(parents=True, exist_ok=True)
113
+ (dst / SKILL_FILENAME).write_text(render_stub(skill), encoding="utf-8")
114
+ return True
115
+
116
+
97
117
  def build():
98
118
  count = 0
99
- for target_rel, mode in TARGETS:
119
+ for target_rel, _mode in TARGETS:
100
120
  target_base = PROJECT_ROOT / target_rel
101
121
  target_base.mkdir(parents=True, exist_ok=True)
102
122
  for skill in CANONICAL_SKILLS:
103
- src = PROJECT_ROOT / "skills" / skill
104
- if not src.exists():
105
- print(f"[WARN] canonical skill missing: skills/{skill}", file=sys.stderr)
106
- continue
107
- if not (src / "SKILL.md").exists():
108
- print(f"[WARN] skills/{skill}/SKILL.md missing", file=sys.stderr)
109
- continue
110
- dst = target_base / skill
111
- if dst.exists():
112
- shutil.rmtree(dst)
113
- dst.mkdir(parents=True, exist_ok=True)
114
- (dst / "SKILL.md").write_text(render_stub(skill), encoding="utf-8")
115
- count += 1
123
+ if _build_skill_stub(target_base, skill):
124
+ count += 1
116
125
  print(f"āœ… Built {count} thin skill stubs across {len(TARGETS)} targets.")
117
126
  return 0
118
127
 
119
128
 
120
- def check():
121
- """Verify stubs match the generated template (not the skill bodies)."""
129
+ def _check_skill_stub(target_rel: str, skill: str) -> List[str]:
130
+ errors = []
131
+ dst = PROJECT_ROOT / target_rel / skill / SKILL_FILENAME
132
+ if not dst.exists():
133
+ return [f"MISSING stub: {target_rel}/{skill}/{SKILL_FILENAME}"]
134
+ if dst.read_text(encoding="utf-8") != render_stub(skill):
135
+ errors.append(f"OUT OF SYNC: {target_rel}/{skill}/{SKILL_FILENAME}")
136
+ stray = [
137
+ p.name
138
+ for p in (PROJECT_ROOT / target_rel / skill).iterdir()
139
+ if p.name != SKILL_FILENAME
140
+ ]
141
+ if stray:
142
+ errors.append(f"STRAY FILES in {target_rel}/{skill}: {sorted(stray)}")
143
+ return errors
144
+
145
+
146
+ def _check_package_json() -> List[str]:
122
147
  errors = []
123
- for target_rel, mode in TARGETS:
124
- for skill in CANONICAL_SKILLS:
125
- dst = PROJECT_ROOT / target_rel / skill / "SKILL.md"
126
- if not dst.exists():
127
- errors.append(f"MISSING stub: {target_rel}/{skill}/SKILL.md")
128
- continue
129
- if dst.read_text(encoding="utf-8") != render_stub(skill):
130
- errors.append(f"OUT OF SYNC: {target_rel}/{skill}/SKILL.md")
131
- # Stubs must not drag code bodies back in.
132
- stray = [
133
- p.name
134
- for p in (PROJECT_ROOT / target_rel / skill).iterdir()
135
- if p.name != "SKILL.md"
136
- ]
137
- if stray:
138
- errors.append(f"STRAY FILES in {target_rel}/{skill}: {sorted(stray)}")
139
- # Validate package.json pi/omp blocks resolve
140
148
  pkg = json.loads((PROJECT_ROOT / "package.json").read_text())
141
149
  for key in ("pi", "omp"):
142
150
  block = pkg.get(key, {})
143
151
  for kind in ("skills", "extensions", "prompts"):
144
152
  for p in block.get(kind, []):
145
- # glob patterns allowed in prompts
146
153
  if "*" in p:
147
154
  if not list(PROJECT_ROOT.glob(p.lstrip("./"))):
148
- errors.append(
149
- f"package.json {key}.{kind} glob matches nothing: {p}"
150
- )
155
+ errors.append(f"package.json {key}.{kind} glob matches nothing: {p}")
151
156
  elif not (PROJECT_ROOT / p.lstrip("./")).exists():
152
157
  errors.append(f"package.json {key}.{kind} missing: {p}")
158
+ return errors
159
+
160
+
161
+ def check():
162
+ """Verify stubs match the generated template (not the skill bodies)."""
163
+ errors = []
164
+ for target_rel, _mode in TARGETS:
165
+ for skill in CANONICAL_SKILLS:
166
+ errors.extend(_check_skill_stub(target_rel, skill))
167
+ errors.extend(_check_package_json())
153
168
  if errors:
154
169
  print("āŒ Adapter check failed:")
155
170
  for e in errors:
@@ -23,11 +23,12 @@ Usage:
23
23
 
24
24
  import argparse
25
25
  import json
26
+ import os
26
27
  import sys
27
28
  import time
28
29
  from datetime import datetime, timezone
29
30
  from pathlib import Path
30
- from typing import Any, Dict, List, Optional
31
+ from typing import Any, Dict, List, Optional, Tuple
31
32
 
32
33
  PROJECT_ROOT = Path(__file__).resolve().parent.parent
33
34
  if str(PROJECT_ROOT) not in sys.path:
@@ -40,7 +41,8 @@ GATE_BAR = 0.15
40
41
 
41
42
 
42
43
  def load_objectives(path: Path = FIXTURE) -> List[Dict[str, Any]]:
43
- with open(path, "r", encoding="utf-8") as f:
44
+ canonical_path = Path(os.path.realpath(str(path)))
45
+ with open(canonical_path, "r", encoding="utf-8") as f:
44
46
  data = json.load(f)
45
47
  return data["objectives"]
46
48
 
@@ -50,6 +52,42 @@ def estimate_tokens(text: str) -> int:
50
52
  return max(1, len(text) // 4)
51
53
 
52
54
 
55
+ def _extract_audit_divergence(base_dir: Path) -> Tuple[Optional[float], Optional[bool]]:
56
+ """Aggregate divergence across every scored audit in a run's scratchpads.
57
+
58
+ A swarm run writes one ``audit_report.json`` per scope, so a single scalar has
59
+ to be reduced from N scores. The reduction is the **mean** of all scores that
60
+ are present, which is deterministic and independent of scope-name ordering.
61
+
62
+ ``verified_passed`` is True only when every scored audit reports True; it is
63
+ None when no audit reports it at all.
64
+
65
+ (Earlier revisions returned whichever audit happened to be visited — first,
66
+ then briefly last — so the reported number silently tracked directory sort
67
+ order. Neither is a meaningful rule; the mean is.)
68
+ """
69
+ audit_paths = sorted((base_dir / "scratchpads").glob("*/audit_report.json"))
70
+ scores: List[float] = []
71
+ verified_flags: List[bool] = []
72
+ for ap in audit_paths:
73
+ try:
74
+ with open(ap, "r", encoding="utf-8") as f:
75
+ audit = json.load(f)
76
+ except (OSError, json.JSONDecodeError):
77
+ continue
78
+ summary = audit.get("summary", {})
79
+ if summary.get("divergence_score") is not None:
80
+ scores.append(float(summary["divergence_score"]))
81
+ if summary.get("verified_passed") is not None:
82
+ verified_flags.append(bool(summary["verified_passed"]))
83
+
84
+ if not scores:
85
+ return None, (None if not verified_flags else all(verified_flags))
86
+ divergence = sum(scores) / len(scores)
87
+ verified: Optional[int] = all(verified_flags) if verified_flags else None
88
+ return divergence, verified
89
+
90
+
53
91
  def run_one(
54
92
  objective: str,
55
93
  base_dir: Path,
@@ -57,6 +95,8 @@ def run_one(
57
95
  mock: bool,
58
96
  ) -> Dict[str, Any]:
59
97
  """Run one swarm under one config and pull divergence out of the audit."""
98
+ import contextlib, io
99
+
60
100
  runner = SwarmRunner(
61
101
  base_dir=base_dir,
62
102
  mock_mode=mock,
@@ -64,39 +104,36 @@ def run_one(
64
104
  agent_overrides=agent_overrides,
65
105
  )
66
106
  started = time.perf_counter()
67
- # Suppress the swarm's chatty stdout; the artifact is what matters.
68
- import contextlib, io
69
-
70
107
  buf = io.StringIO()
71
108
  with contextlib.redirect_stdout(buf):
72
109
  runner.run_swarm(objective)
73
110
  elapsed = time.perf_counter() - started
74
111
 
75
112
  report_path = base_dir / "final_synthesis.md"
76
- divergence = None
77
- verified = None
78
- audit_paths = sorted((base_dir / "scratchpads").glob("*/audit_report.json"))
79
- for ap in audit_paths:
80
- try:
81
- with open(ap, "r", encoding="utf-8") as f:
82
- audit = json.load(f)
83
- summary = audit.get("summary", {})
84
- if summary.get("divergence_score") is not None:
85
- divergence = summary["divergence_score"]
86
- verified = summary.get("verified_passed")
87
- except (OSError, json.JSONDecodeError):
88
- continue
89
-
113
+ divergence, verified = _extract_audit_divergence(base_dir)
90
114
  log = buf.getvalue()
91
115
  return {
92
116
  "divergence_score": divergence,
93
117
  "verified_passed": verified,
94
118
  "wall_clock_s": round(elapsed, 3),
95
- "tokens_estimate": estimate_tokens(log), # FLAGGED APPROXIMATION
119
+ "tokens_estimate": estimate_tokens(log),
96
120
  "report": str(report_path) if report_path.exists() else None,
97
121
  }
98
122
 
99
123
 
124
+ def _build_overrides(args: argparse.Namespace) -> Tuple[Dict[str, Any], Dict[str, Any]]:
125
+ same_family: Dict[str, Dict[str, Any]] = {}
126
+ cross_family: Dict[str, Dict[str, Any]] = {}
127
+ if args.model_alpha:
128
+ same_family.setdefault("alpha", {})["model"] = args.model_alpha
129
+ same_family.setdefault("beta", {})["model"] = args.model_alpha
130
+ if args.model_beta:
131
+ cross_family.setdefault("beta", {})["model"] = args.model_beta
132
+ if args.beta_backend:
133
+ cross_family.setdefault("beta", {})["backend"] = args.beta_backend
134
+ return same_family, cross_family
135
+
136
+
100
137
  def main(argv: Optional[List[str]] = None) -> int:
101
138
  parser = argparse.ArgumentParser(description="Stream E divergence experiment (G2)")
102
139
  parser.add_argument("--mock", action="store_true", help="Mock mode, zero API cost")
@@ -107,19 +144,12 @@ def main(argv: Optional[List[str]] = None) -> int:
107
144
  parser.add_argument("--fixture", default=str(FIXTURE))
108
145
  args = parser.parse_args(argv)
109
146
 
110
- objectives = load_objectives(Path(args.fixture))
111
- out_dir = Path(args.out_dir) if args.out_dir else (PROJECT_ROOT / ".research" / "experiments")
147
+ canonical_fixture = Path(os.path.realpath(str(args.fixture)))
148
+ objectives = load_objectives(canonical_fixture)
149
+ out_dir = Path(os.path.realpath(str(args.out_dir))) if args.out_dir else (PROJECT_ROOT / ".research" / "experiments")
112
150
  out_dir.mkdir(parents=True, exist_ok=True)
113
151
 
114
- same_family: Dict[str, Dict[str, Any]] = {}
115
- cross_family: Dict[str, Dict[str, Any]] = {}
116
- if args.model_alpha:
117
- same_family.setdefault("alpha", {})["model"] = args.model_alpha
118
- same_family.setdefault("beta", {})["model"] = args.model_alpha
119
- if args.model_beta:
120
- cross_family.setdefault("beta", {})["model"] = args.model_beta
121
- if args.beta_backend:
122
- cross_family.setdefault("beta", {})["backend"] = args.beta_backend
152
+ same_family, cross_family = _build_overrides(args)
123
153
 
124
154
  results = []
125
155
  for obj in objectives:
@@ -7,6 +7,7 @@ runner/research_swarm.py --mode brainstorm.
7
7
 
8
8
  import argparse
9
9
  import json
10
+ import os
10
11
  import subprocess
11
12
  import sys
12
13
  from datetime import datetime, timezone
@@ -167,7 +168,7 @@ def main():
167
168
  )
168
169
  args = ap.parse_args()
169
170
 
170
- model, docs = build_domain_model(args.objective, full=not args.shallow)
171
+ model, _ = build_domain_model(args.objective, full=not args.shallow)
171
172
 
172
173
  if args.show_context or not args.export:
173
174
  print(f"\n🧠 [Brainstorm] Objective: {args.objective}")
@@ -190,7 +191,7 @@ def main():
190
191
  )
191
192
 
192
193
  if args.export:
193
- out = Path(args.export)
194
+ out = Path(os.path.realpath(str(args.export)))
194
195
  out.parent.mkdir(parents=True, exist_ok=True)
195
196
  out.write_text(json.dumps(model, indent=2), encoding="utf-8")
196
197
  print(f"āœ… Domain model exported to {out}")
@@ -79,7 +79,6 @@ def fetch_and_cache(url: str, research_dir: str = ".research") -> str:
79
79
  req = urllib.request.Request(url, headers=headers)
80
80
  try:
81
81
  with urllib.request.urlopen(req, timeout=20) as resp:
82
- content_type = resp.headers.get("Content-Type", "")
83
82
  raw_bytes = resp.read()
84
83
  html = raw_bytes.decode("utf-8", errors="replace")
85
84
  except Exception as e: