arbiter-dev 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ """Arbiter analyzers — pluggable code quality analysis tools."""
@@ -0,0 +1,44 @@
1
+ """Base analyzer interface and shared data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class Finding:
12
+ """A single quality finding from any analyzer."""
13
+
14
+ file_path: str
15
+ line: int
16
+ severity: str # CRITICAL, HIGH, MEDIUM, LOW
17
+ rule_id: str
18
+ message: str
19
+ tool: str # which analyzer produced this
20
+
21
+
22
+ # Default paths to exclude from analysis
23
+ DEFAULT_EXCLUDE_PATHS = [
24
+ "node_modules", "__pycache__", ".venv", "venv", "dist", "build",
25
+ ".git", ".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache",
26
+ "vendor", "third_party", "generated",
27
+ ]
28
+
29
+
30
+ class Analyzer(ABC):
31
+ """Abstract base for code quality analyzers."""
32
+
33
+ @property
34
+ @abstractmethod
35
+ def name(self) -> str:
36
+ """Unique analyzer name."""
37
+
38
+ @abstractmethod
39
+ def analyze_repo(self, repo_path: Path, exclude_paths: list[str] | None = None) -> list[Finding]:
40
+ """Analyze an entire repository. Returns list of findings."""
41
+
42
+ def is_available(self) -> bool:
43
+ """Check if the underlying tool is installed."""
44
+ return True
@@ -0,0 +1,66 @@
1
+ """Complexity Analyzer — Cyclomatic complexity via radon CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from arbiter.analyzers.base import Analyzer, Finding
10
+
11
+ # Map radon CC grades to severity
12
+ _GRADE_SEVERITY = {
13
+ "A": "LOW", # 1-5 (simple)
14
+ "B": "LOW", # 6-10 (well structured)
15
+ "C": "MEDIUM", # 11-20 (slightly complex)
16
+ "D": "HIGH", # 21-30 (more complex)
17
+ "E": "CRITICAL", # 31-40 (complex)
18
+ "F": "CRITICAL", # 41+ (very complex)
19
+ }
20
+
21
+
22
+ class ComplexityAnalyzer(Analyzer):
23
+ """Runs radon cc and parses JSON output."""
24
+
25
+ @property
26
+ def name(self) -> str:
27
+ return "complexity"
28
+
29
+ def is_available(self) -> bool:
30
+ try:
31
+ subprocess.run(["radon", "--version"], capture_output=True, timeout=5)
32
+ return True
33
+ except (FileNotFoundError, subprocess.TimeoutExpired):
34
+ return False
35
+
36
+ def analyze_repo(self, repo_path: Path, exclude_paths: list[str] | None = None) -> list[Finding]:
37
+ result = subprocess.run(
38
+ ["radon", "cc", "--json", "--min", "C", str(repo_path)],
39
+ capture_output=True, text=True, timeout=120,
40
+ )
41
+ if not result.stdout.strip():
42
+ return []
43
+
44
+ try:
45
+ data = json.loads(result.stdout)
46
+ except json.JSONDecodeError:
47
+ return []
48
+
49
+ findings: list[Finding] = []
50
+ for file_path, blocks in data.items():
51
+ if not isinstance(blocks, list):
52
+ continue
53
+ for block in blocks:
54
+ grade = block.get("rank", "A")
55
+ cc = block.get("complexity", 0)
56
+ name = block.get("name", "?")
57
+ lineno = block.get("lineno", 0)
58
+ findings.append(Finding(
59
+ file_path=file_path,
60
+ line=lineno,
61
+ severity=_GRADE_SEVERITY.get(grade, "MEDIUM"),
62
+ rule_id=f"CC-{grade}",
63
+ message=f"{name}: cyclomatic complexity {cc} (grade {grade})",
64
+ tool="radon",
65
+ ))
66
+ return findings
@@ -0,0 +1,52 @@
1
+ """Dead Code Analyzer — Unused code detection via Vulture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from arbiter.analyzers.base import Analyzer, Finding
10
+
11
+
12
+ class DeadCodeAnalyzer(Analyzer):
13
+ """Runs vulture and parses text output."""
14
+
15
+ @property
16
+ def name(self) -> str:
17
+ return "vulture"
18
+
19
+ def is_available(self) -> bool:
20
+ try:
21
+ subprocess.run(["vulture", "--version"], capture_output=True, timeout=5)
22
+ return True
23
+ except (FileNotFoundError, subprocess.TimeoutExpired):
24
+ return False
25
+
26
+ def analyze_repo(self, repo_path: Path, exclude_paths: list[str] | None = None) -> list[Finding]:
27
+ result = subprocess.run(
28
+ ["vulture", str(repo_path), "--min-confidence", "80"],
29
+ capture_output=True, text=True, timeout=120,
30
+ )
31
+ if not result.stdout.strip():
32
+ return []
33
+
34
+ findings: list[Finding] = []
35
+ # vulture output: filepath:line: message (confidence%)
36
+ pattern = re.compile(r"^(.+?):(\d+):\s+(.+?)\s+\((\d+)% confidence\)$")
37
+ for line in result.stdout.strip().split("\n"):
38
+ m = pattern.match(line.strip())
39
+ if not m:
40
+ continue
41
+ filepath, lineno, message, confidence = m.groups()
42
+ conf = int(confidence)
43
+ severity = "HIGH" if conf >= 90 else "MEDIUM"
44
+ findings.append(Finding(
45
+ file_path=filepath,
46
+ line=int(lineno),
47
+ severity=severity,
48
+ rule_id=f"DEAD-{conf}",
49
+ message=message,
50
+ tool="vulture",
51
+ ))
52
+ return findings
@@ -0,0 +1,96 @@
1
+ """Duplication Analyzer — AST-based near-duplicate function detection.
2
+
3
+ Uses Python's ast module to normalize function bodies and detect
4
+ functions with identical or near-identical structure.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import hashlib
11
+ from collections import defaultdict
12
+ from pathlib import Path
13
+
14
+ from arbiter.analyzers.base import Analyzer, Finding
15
+
16
+
17
+ class DuplicationAnalyzer(Analyzer):
18
+ """Detects duplicate functions via AST hashing."""
19
+
20
+ @property
21
+ def name(self) -> str:
22
+ return "duplication"
23
+
24
+ def analyze_repo(self, repo_path: Path, exclude_paths: list[str] | None = None) -> list[Finding]:
25
+ # Collect function AST hashes across all Python files
26
+ hash_to_locations: dict[str, list[tuple[str, int, str]]] = defaultdict(list)
27
+
28
+ for py_file in repo_path.rglob("*.py"):
29
+ if _should_skip(py_file, exclude_paths):
30
+ continue
31
+ try:
32
+ source = py_file.read_text(encoding="utf-8", errors="ignore")
33
+ tree = ast.parse(source)
34
+ except (SyntaxError, ValueError):
35
+ continue
36
+
37
+ for node in ast.walk(tree):
38
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
39
+ body_hash = _hash_function_body(node)
40
+ if body_hash:
41
+ rel_path = str(py_file.relative_to(repo_path))
42
+ hash_to_locations[body_hash].append(
43
+ (rel_path, node.lineno, node.name)
44
+ )
45
+
46
+ # Report groups with 2+ identical functions
47
+ findings: list[Finding] = []
48
+ for body_hash, locations in hash_to_locations.items():
49
+ if len(locations) < 2:
50
+ continue
51
+ severity = "HIGH" if len(locations) >= 3 else "MEDIUM"
52
+ names = ", ".join(f"{loc[2]}@{loc[0]}:{loc[1]}" for loc in locations)
53
+ for file_path, lineno, func_name in locations:
54
+ findings.append(Finding(
55
+ file_path=file_path,
56
+ line=lineno,
57
+ severity=severity,
58
+ rule_id=f"DUP-{len(locations)}",
59
+ message=f"Duplicate function body ({len(locations)} copies): {names}",
60
+ tool="duplication",
61
+ ))
62
+ return findings
63
+
64
+
65
+ def _hash_function_body(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str | None:
66
+ """Hash the normalized AST of a function body.
67
+
68
+ Normalizes by:
69
+ - Stripping all variable names (replaced with positional placeholders)
70
+ - Stripping docstrings
71
+ - Only hashing functions with 3+ statements (skip trivial functions)
72
+ """
73
+ body = node.body
74
+
75
+ # Skip docstring if present
76
+ if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant):
77
+ body = body[1:]
78
+
79
+ if len(body) < 5:
80
+ return None # Skip small functions (< 5 statements)
81
+
82
+ # Dump AST and hash it
83
+ try:
84
+ dump = ast.dump(ast.Module(body=body, type_ignores=[]))
85
+ return hashlib.sha256(dump.encode()).hexdigest()[:16]
86
+ except Exception:
87
+ return None
88
+
89
+
90
+ def _should_skip(path: Path, extra_excludes: list[str] | None = None) -> bool:
91
+ """Skip test files, venvs, and other non-source directories."""
92
+ parts = path.parts
93
+ skip_dirs = {"__pycache__", ".venv", "venv", ".git", "node_modules", ".tox"}
94
+ if extra_excludes:
95
+ skip_dirs.update(extra_excludes)
96
+ return any(p in skip_dirs for p in parts) or path.name.startswith("test_")
@@ -0,0 +1,78 @@
1
+ """Ruff Analyzer — Lint analysis via ruff CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from arbiter.analyzers.base import Analyzer, Finding
10
+
11
+ # Map ruff rule prefixes to severity levels
12
+ _SEVERITY_MAP = {
13
+ "F": "MEDIUM", # Pyflakes (unused imports, undefined names)
14
+ "E": "MEDIUM", # pycodestyle errors
15
+ "W": "LOW", # pycodestyle warnings
16
+ "B": "HIGH", # flake8-bugbear (likely bugs)
17
+ "S": "HIGH", # flake8-bandit (security)
18
+ "C": "MEDIUM", # conventions
19
+ "I": "LOW", # isort
20
+ "N": "LOW", # naming
21
+ "D": "LOW", # docstrings
22
+ "SIM": "MEDIUM", # simplify
23
+ "UP": "LOW", # pyupgrade
24
+ }
25
+
26
+
27
+ def _severity_for_rule(rule_id: str) -> str:
28
+ """Map a ruff rule ID to a severity level."""
29
+ for prefix, severity in _SEVERITY_MAP.items():
30
+ if rule_id.startswith(prefix):
31
+ return severity
32
+ return "MEDIUM"
33
+
34
+
35
+ class RuffAnalyzer(Analyzer):
36
+ """Runs ruff check and parses JSON output."""
37
+
38
+ @property
39
+ def name(self) -> str:
40
+ return "ruff"
41
+
42
+ def is_available(self) -> bool:
43
+ try:
44
+ subprocess.run(["ruff", "--version"], capture_output=True, timeout=5)
45
+ return True
46
+ except (FileNotFoundError, subprocess.TimeoutExpired):
47
+ return False
48
+
49
+ def analyze_repo(self, repo_path: Path, exclude_paths: list[str] | None = None) -> list[Finding]:
50
+ cmd = ["ruff", "check", "--output-format", "json"]
51
+ if exclude_paths:
52
+ for ep in exclude_paths:
53
+ cmd.extend(["--exclude", ep])
54
+ cmd.append(str(repo_path))
55
+ result = subprocess.run(
56
+ cmd, capture_output=True, text=True, timeout=120,
57
+ )
58
+ # ruff returns exit code 1 when findings exist — that's expected
59
+ if not result.stdout.strip():
60
+ return []
61
+
62
+ try:
63
+ data = json.loads(result.stdout)
64
+ except json.JSONDecodeError:
65
+ return []
66
+
67
+ findings: list[Finding] = []
68
+ for item in data:
69
+ code = item.get("code", "")
70
+ findings.append(Finding(
71
+ file_path=item.get("filename", ""),
72
+ line=item.get("location", {}).get("row", 0),
73
+ severity=_severity_for_rule(code),
74
+ rule_id=code,
75
+ message=item.get("message", ""),
76
+ tool="ruff",
77
+ ))
78
+ return findings
@@ -0,0 +1,64 @@
1
+ """Security Analyzer — Security findings via Bandit CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from arbiter.analyzers.base import Analyzer, Finding
10
+
11
+ # Map Bandit severity to Arbiter severity (one level up)
12
+ _SEVERITY_MAP = {
13
+ "HIGH": "CRITICAL",
14
+ "MEDIUM": "HIGH",
15
+ "LOW": "MEDIUM",
16
+ }
17
+
18
+
19
+ class SecurityAnalyzer(Analyzer):
20
+ """Runs bandit and parses JSON output."""
21
+
22
+ @property
23
+ def name(self) -> str:
24
+ return "bandit"
25
+
26
+ def is_available(self) -> bool:
27
+ try:
28
+ subprocess.run(["bandit", "--version"], capture_output=True, timeout=5)
29
+ return True
30
+ except (FileNotFoundError, subprocess.TimeoutExpired):
31
+ return False
32
+
33
+ def analyze_repo(self, repo_path: Path, exclude_paths: list[str] | None = None) -> list[Finding]:
34
+ result = subprocess.run(
35
+ [
36
+ "bandit", "-r", str(repo_path),
37
+ "-f", "json",
38
+ "--exclude", "*/tests/*,*/__pycache__/*",
39
+ "-ll", # LOW and above
40
+ ],
41
+ capture_output=True, text=True, timeout=600,
42
+ )
43
+ # bandit returns exit code 1 when findings exist
44
+ output = result.stdout.strip()
45
+ if not output:
46
+ return []
47
+
48
+ try:
49
+ data = json.loads(output)
50
+ except json.JSONDecodeError:
51
+ return []
52
+
53
+ findings: list[Finding] = []
54
+ for item in data.get("results", []):
55
+ sev = item.get("issue_severity", "LOW")
56
+ findings.append(Finding(
57
+ file_path=item.get("filename", ""),
58
+ line=item.get("line_number", 0),
59
+ severity=_SEVERITY_MAP.get(sev, "MEDIUM"),
60
+ rule_id=item.get("test_id", ""),
61
+ message=item.get("issue_text", ""),
62
+ tool="bandit",
63
+ ))
64
+ return findings
arbiter/api.py ADDED
@@ -0,0 +1,173 @@
1
+ """Arbiter API — stdlib HTTP server with JSON endpoints.
2
+
3
+ No FastAPI, no Flask — just http.server + json.
4
+
5
+ Endpoints:
6
+ GET /api/score Current repo score
7
+ GET /api/agents Agent leaderboard
8
+ GET /api/trend Quality trend over time
9
+ GET /api/worst Worst files
10
+ GET /api/commits Recent commits with scores
11
+ GET /api/health System health
12
+ GET / Dashboard (HTML)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ from http.server import HTTPServer, BaseHTTPRequestHandler
20
+ from pathlib import Path
21
+ from typing import Any
22
+ from urllib.parse import parse_qs, urlparse
23
+
24
+ from arbiter.store import Store
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _DASHBOARD_DIR = Path(__file__).resolve().parent.parent.parent / "dashboard"
29
+
30
+
31
+ class ArbiterHandler(BaseHTTPRequestHandler):
32
+ """HTTP request handler for Arbiter API."""
33
+
34
+ store: Store # Set by run_server
35
+
36
+ def do_GET(self) -> None:
37
+ parsed = urlparse(self.path)
38
+ path = parsed.path.rstrip("/")
39
+ params = parse_qs(parsed.query)
40
+
41
+ routes = {
42
+ "/api/score": self._handle_score,
43
+ "/api/agents": self._handle_agents,
44
+ "/api/trend": self._handle_trend,
45
+ "/api/worst": self._handle_worst,
46
+ "/api/commits": self._handle_commits,
47
+ "/api/health": self._handle_health,
48
+ }
49
+
50
+ handler = routes.get(path)
51
+ if handler:
52
+ handler(params)
53
+ elif path.startswith("/api/agents/") and path.count("/") == 4 and path.endswith("/trend"):
54
+ # /api/agents/{name}/trend
55
+ agent_name = path.split("/")[3]
56
+ self._handle_agent_trend(params, agent_name)
57
+ elif path.startswith("/api/commits/") and path.count("/") == 3:
58
+ # /api/commits/{hash}
59
+ commit_hash = path.split("/")[3]
60
+ self._handle_commit_detail(params, commit_hash)
61
+ elif path == "/api/fleet":
62
+ self._handle_fleet(params)
63
+ elif path == "" or path == "/index.html":
64
+ self._serve_dashboard()
65
+ else:
66
+ self._send_404()
67
+
68
+ def _handle_score(self, params: dict) -> None:
69
+ trend = self.store.get_trend(days=1)
70
+ if trend:
71
+ latest = trend[-1]
72
+ self._send_json(latest)
73
+ else:
74
+ self._send_json({"error": "No data. Run 'arbiter analyze' first."}, status=404)
75
+
76
+ def _handle_agents(self, params: dict) -> None:
77
+ board = self.store.get_agent_leaderboard()
78
+ self._send_json([
79
+ {
80
+ "agent": a.agent_name,
81
+ "avg_score": a.avg_score,
82
+ "commit_count": a.commit_count,
83
+ "total_loc": a.total_loc,
84
+ "trend": a.trend,
85
+ }
86
+ for a in board
87
+ ])
88
+
89
+ def _handle_trend(self, params: dict) -> None:
90
+ days = int(params.get("days", ["30"])[0])
91
+ trend = self.store.get_trend(days=days)
92
+ self._send_json(trend)
93
+
94
+ def _handle_worst(self, params: dict) -> None:
95
+ limit = int(params.get("limit", ["20"])[0])
96
+ worst = self.store.get_worst_files(limit=limit)
97
+ self._send_json([
98
+ {
99
+ "file_path": f.file_path,
100
+ "finding_count": f.finding_count,
101
+ "worst_severity": f.worst_severity,
102
+ "last_agent": f.last_agent,
103
+ }
104
+ for f in worst
105
+ ])
106
+
107
+ def _handle_commits(self, params: dict) -> None:
108
+ agent = params.get("agent", [None])[0]
109
+ limit = int(params.get("limit", ["50"])[0])
110
+ commits = self.store.get_recent_commits(agent=agent, limit=limit)
111
+ self._send_json(commits)
112
+
113
+ def _handle_agent_trend(self, params: dict, agent_name: str) -> None:
114
+ days = int(params.get("days", ["30"])[0])
115
+ trend = self.store.get_agent_trend(agent_name, days=days)
116
+ self._send_json(trend)
117
+
118
+ def _handle_fleet(self, params: dict) -> None:
119
+ report = self.store.get_fleet_report()
120
+ self._send_json(report)
121
+
122
+ def _handle_commit_detail(self, params: dict, commit_hash: str) -> None:
123
+ commits = self.store.get_recent_commits(limit=500)
124
+ match = [c for c in commits if c.get("commit_hash", "").startswith(commit_hash)]
125
+ if match:
126
+ self._send_json(match[0])
127
+ else:
128
+ self._send_json({"error": "Commit not found"}, status=404)
129
+
130
+ def _handle_health(self, params: dict) -> None:
131
+ self._send_json({"status": "ok", "version": "0.2.0"})
132
+
133
+ def _serve_dashboard(self) -> None:
134
+ index = _DASHBOARD_DIR / "index.html"
135
+ if not index.exists():
136
+ self._send_response(200, "text/html", b"<h1>Arbiter</h1><p>Dashboard not found. Place index.html in dashboard/</p>")
137
+ return
138
+ content = index.read_bytes()
139
+ self._send_response(200, "text/html", content)
140
+
141
+ def _send_json(self, data: Any, status: int = 200) -> None:
142
+ body = json.dumps(data, indent=2, default=str).encode("utf-8")
143
+ self._send_response(status, "application/json", body)
144
+
145
+ def _send_response(self, status: int, content_type: str, body: bytes) -> None:
146
+ self.send_response(status)
147
+ self.send_header("Content-Type", content_type)
148
+ self.send_header("Content-Length", str(len(body)))
149
+ self.send_header("Access-Control-Allow-Origin", "*")
150
+ self.end_headers()
151
+ self.wfile.write(body)
152
+
153
+ def _send_404(self) -> None:
154
+ self._send_json({"error": "Not found"}, status=404)
155
+
156
+ def log_message(self, format: str, *args: Any) -> None:
157
+ logger.info(format, *args)
158
+
159
+
160
+ def run_server(host: str = "0.0.0.0", port: int = 8080, db_path: str | None = None) -> None:
161
+ """Start the Arbiter HTTP server."""
162
+ store = Store(db_path or "arbiter_data.db")
163
+ ArbiterHandler.store = store
164
+
165
+ server = HTTPServer((host, port), ArbiterHandler)
166
+ print(f"Arbiter dashboard: http://{host}:{port}/")
167
+ print(f"Arbiter API: http://{host}:{port}/api/")
168
+ print(f"Database: {db_path or 'arbiter_data.db'}")
169
+ try:
170
+ server.serve_forever()
171
+ except KeyboardInterrupt:
172
+ print("\nShutting down.")
173
+ server.shutdown()
arbiter/bus_bridge.py ADDED
@@ -0,0 +1,82 @@
1
+ """Bus Bridge — Posts quality milestones to founder-mode coordination bus.
2
+
3
+ Only used when Arbiter runs in the founder-mode context.
4
+ Uses subprocess to call bus_writer (no import dependency on founder-mode).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+
14
+ # Default bus path for founder-mode
15
+ _DEFAULT_BUS_PATH = Path.home() / "workspace" / "founder-mode" / "_state" / "coordination" / "messages.tsv"
16
+
17
+
18
+ def post_quality_milestone(
19
+ score: float,
20
+ grade: str,
21
+ findings: int,
22
+ repo_name: str = "",
23
+ *,
24
+ bus_path: str | Path | None = None,
25
+ founder_mode_root: str | Path | None = None,
26
+ ) -> bool:
27
+ """Post a quality scan milestone to the coordination bus.
28
+
29
+ Returns True if the message was posted successfully.
30
+ """
31
+ label = f"{repo_name}: " if repo_name else ""
32
+ message = f"Arbiter scan complete: {label}{score:.1f} ({grade}), {findings} findings"
33
+ return _post_bus_message("MILESTONE", message, bus_path=bus_path, founder_mode_root=founder_mode_root)
34
+
35
+
36
+ def post_quality_alert(
37
+ score: float,
38
+ threshold: float,
39
+ repo_name: str = "",
40
+ *,
41
+ bus_path: str | Path | None = None,
42
+ founder_mode_root: str | Path | None = None,
43
+ ) -> bool:
44
+ """Post a quality alert when score drops below threshold."""
45
+ label = f"{repo_name}: " if repo_name else ""
46
+ message = f"Quality alert: {label}score {score:.1f} below threshold {threshold:.1f}"
47
+ return _post_bus_message("STATUS", message, bus_path=bus_path, founder_mode_root=founder_mode_root)
48
+
49
+
50
+ def _post_bus_message(
51
+ msg_type: str,
52
+ message: str,
53
+ *,
54
+ bus_path: str | Path | None = None,
55
+ founder_mode_root: str | Path | None = None,
56
+ ) -> bool:
57
+ """Post a message to the coordination bus via bus_writer."""
58
+ fm_root = Path(founder_mode_root) if founder_mode_root else Path.home() / "workspace" / "founder-mode"
59
+
60
+ cmd = [
61
+ sys.executable, "-m", "founder_mode.bus.bus_writer",
62
+ "arbiter", "all", msg_type, message,
63
+ ]
64
+
65
+ env_additions = {}
66
+ if bus_path:
67
+ env_additions["BUS_PATH"] = str(bus_path)
68
+
69
+ try:
70
+ import os
71
+ env = {**os.environ, **env_additions}
72
+ result = subprocess.run(
73
+ cmd,
74
+ capture_output=True,
75
+ text=True,
76
+ timeout=10,
77
+ cwd=str(fm_root),
78
+ env=env,
79
+ )
80
+ return result.returncode == 0
81
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
82
+ return False