Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ opencode = ["httpx>=0.28"]
# dev gets tui via `uv sync --all-extras` (groups can't self-reference an extra).
dev = [
"pytest>=8.0",
"ruff>=0.4",
"ruff>=0.15.17",
"pytest-asyncio>=1.0",
"pytest-rerunfailures>=16.0",
"pytest-xdist>=3.6",
Expand All @@ -60,6 +60,18 @@ target-version = "py311"
# skill assets contain template placeholders that are not valid Python
extend-exclude = [".agents", ".claude", "src/bmad_loop/data/skills"]

[tool.ruff.lint]
# CI lints with trunk's ruff (pinned 0.15.17), whose plugin injects no `--select`
# and so runs ruff's own default rule set. With no lint table that default is
# resolved per ruff version and can drift, so pin the families explicitly. The
# repo's ruff is pinned to 0.15.17 via trunk (`trunk check` = what CI runs and
# the documented local lint, see CONTRIBUTING) and floored at >=0.15.17 in the
# dev deps (uv.lock resolves 0.15.17), so those paths match CI; a bare, newer
# ruff gets rule-family parity only — new rules can land under these prefixes.
# This spells out ruff 0.15.17's default set (`E4`, `E7`, `E9`, `F`) (#246). Do
# NOT ratchet stricter than CI without moving CI first.
select = ["E4", "E7", "E9", "F"]

[tool.black]
line-length = 100
target-version = ["py311"]
Expand Down
2 changes: 1 addition & 1 deletion src/bmad_loop/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def interactive_env(self, spec: SessionSpec) -> dict[str, str]:
"""Env vars to layer onto the caller's environment for interactive_argv."""
return dict(spec.env)

def kill(self, handle: SessionHandle) -> None: # noqa: B027 - optional cleanup
def kill(self, handle: SessionHandle) -> None: # optional cleanup
pass

def read_usage(self, result: SessionResult) -> TokenUsage | None:
Expand Down
6 changes: 3 additions & 3 deletions src/bmad_loop/adapters/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -766,13 +766,13 @@ def kill(self, handle: SessionHandle) -> None:
for pid in repane:
try:
host.force_kill(pid)
except Exception: # noqa: BLE001 # nosec B110 - already-gone races are fine
except Exception: # nosec B110 - already-gone races are fine
pass
for pid, identity in tree.items():
if pid not in repane and identity is not None and host.alive_and_ours(pid, identity):
try:
host.force_kill(pid)
except Exception: # noqa: BLE001 # nosec B110 - already-gone races are fine
except Exception: # nosec B110 - already-gone races are fine
pass
self.mux.kill_window(handle.native_id)
try:
Expand Down Expand Up @@ -830,7 +830,7 @@ def _confirmed_survivors() -> list[int]:
try:
host.force_kill(pid)
forced.append(pid)
except Exception: # noqa: BLE001 # nosec B110 - already-gone races are fine
except Exception: # nosec B110 - already-gone races are fine
pass
unreaped = [pid for pid, identity in tree.items() if host.alive_and_ours(pid, identity)]
# Distinct field name (`unreaped`, a pid list) from the wedged branch's
Expand Down
6 changes: 3 additions & 3 deletions src/bmad_loop/adapters/multiplexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,13 +354,13 @@ def _load_external_backends() -> None:
_EXTERNALS_LOADED = True
try:
eps = importlib.metadata.entry_points(group=MUX_BACKENDS_GROUP)
except Exception as exc: # noqa: BLE001 — diagnostics path, never crash selection
except Exception as exc: # diagnostics path, never crash selection
_EXTERNAL_ERRORS["<entry-point scan>"] = f"{type(exc).__name__}: {exc}"
return
for ep in eps:
try:
ep.load() # module import runs register_multiplexer(...)
except Exception as exc: # noqa: BLE001 — one bad package must not hide the rest
except Exception as exc: # one bad package must not hide the rest
_EXTERNAL_ERRORS[ep.name] = f"{type(exc).__name__}: {exc}"


Expand Down Expand Up @@ -442,7 +442,7 @@ def mux_usable(backend: TerminalMultiplexer | None = None) -> bool:
_FORCED_UNUSABLE_WARNED = True
try:
version = backend.version()
except Exception: # noqa: BLE001 — a broken probe must not break the warning
except Exception: # a broken probe must not break the warning
version = None
print(
f"warning: forced multiplexer backend {type(backend).__name__} reports "
Expand Down
30 changes: 15 additions & 15 deletions src/bmad_loop/adapters/opencode_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ def _require_httpx():
"""Import httpx lazily — it ships as the ``opencode`` extra, so the
dep-free core never pays for it (the ``_psutil()`` pattern)."""
try:
import httpx # noqa: PLC0415 (intentional lazy import — optional extra)
import httpx # intentional lazy import — optional extra
except ImportError as exc:
raise OpencodeServerError(
"the opencode-http adapter needs httpx; "
Expand Down Expand Up @@ -340,7 +340,7 @@ def _spawn_server(self, spec: SessionSpec) -> _ServerSession:
try:
for _ in range(SPAWN_ATTEMPTS):
port = _free_port()
process = subprocess.Popen( # noqa: S603 - argv built from profile
process = subprocess.Popen( # argv built from profile
self._serve_argv(resolved, port),
cwd=str(spec.cwd),
env=env,
Expand Down Expand Up @@ -387,7 +387,7 @@ def _await_healthy(self, sess: _ServerSession) -> bool:
try:
resp = client.get("/global/health")
healthy = resp.status_code == 200 and resp.json().get("healthy") is True
except Exception: # noqa: BLE001 - not up yet (conn refused, junk)
except Exception: # not up yet (conn refused, junk)
healthy = False
if sess.process.poll() is not None:
return False
Expand Down Expand Up @@ -461,7 +461,7 @@ def send_text(self, handle: SessionHandle, text: str) -> None:
return
try:
self._prompt(sess, text)
except Exception: # noqa: BLE001 # nosec B110 - next tick's poll() settles liveness
except Exception: # nosec B110 - next tick's poll() settles liveness
pass

def _start_sse_reader(self, sess: _ServerSession) -> None:
Expand Down Expand Up @@ -500,7 +500,7 @@ def _sse_loop(self, sess: _ServerSession) -> None:
if sess.sse_stop.is_set():
return
self._dispatch_sse(sess, event)
except Exception: # noqa: BLE001 # nosec B110 - reader must never die silently
except Exception: # nosec B110 - reader must never die silently
pass
if sess.sse_stop.is_set():
return
Expand Down Expand Up @@ -684,7 +684,7 @@ def wait_for_completion(self, handle: SessionHandle, spec: SessionSpec) -> Sessi
)
try:
self.send_text(handle, BUDGET_NUDGE_TEXT)
except Exception: # noqa: BLE001 # nosec B110 - best-effort nudge
except Exception: # nosec B110 - best-effort nudge
# a dead/hung server can't take the nudge; the
# grace still arms — the next tick's process
# poll scores a dead server crashed.
Expand Down Expand Up @@ -881,7 +881,7 @@ def _session_status(self, sess: _ServerSession) -> bool | None:
return None
status = resp.json().get(sess.session_id) or {}
return status.get("type") in ("busy", "retry")
except Exception: # noqa: BLE001 - probe is advisory
except Exception: # probe is advisory
return None

def _probe_completion(self, sess: _ServerSession) -> bool:
Expand All @@ -904,7 +904,7 @@ def _probe_completion(self, sess: _ServerSession) -> bool:
continue
done_ms = (info.get("time") or {}).get("completed") or 0
completed = max(completed, int(done_ms))
except Exception: # noqa: BLE001 - probe is advisory
except Exception: # probe is advisory
return False
if completed > sess.floor_ms:
sess.floor_ms = completed
Expand All @@ -916,7 +916,7 @@ def _abort(self, sess: _ServerSession) -> None:
return
try:
sess.client.post(f"/session/{sess.session_id}/abort")
except Exception: # noqa: BLE001 # nosec B110 - abort is best-effort
except Exception: # nosec B110 - abort is best-effort
pass

# ----------------------------------------------------------------- usage
Expand All @@ -933,7 +933,7 @@ def _sample_weighted_usage(self, sess: _ServerSession, spec: SessionSpec) -> int
if resp.status_code != 200:
return None
usage = _sum_usage(resp.json())
except Exception: # noqa: BLE001 - sampling is advisory
except Exception: # sampling is advisory
return None
return usage.weighted_total(spec.cache_read_weight)

Expand All @@ -953,7 +953,7 @@ def _capture_usage(self, handle: SessionHandle, sess: _ServerSession) -> str | N
path.write_text(json.dumps(messages, ensure_ascii=False, indent=2), encoding="utf-8")
self._usage[sess.session_id] = _sum_usage(messages)
return str(path)
except Exception: # noqa: BLE001 - usage is metadata, never a gate
except Exception: # usage is metadata, never a gate
return None

def read_usage(self, result: SessionResult) -> TokenUsage | None:
Expand All @@ -980,7 +980,7 @@ def _teardown(self, sess: _ServerSession) -> None:
if sess.client is not None:
try:
sess.client.close()
except Exception: # noqa: BLE001 # nosec B110 - closing is best-effort
except Exception: # nosec B110 - closing is best-effort
pass
try:
sess.log_fh.close()
Expand Down Expand Up @@ -1029,7 +1029,7 @@ def _kill_process(self, sess: _ServerSession) -> None:
# wrapper is gone `/T` can never enumerate the child again.
try:
host.force_kill(process.pid)
except Exception: # noqa: BLE001 # nosec B110 - already-gone races are fine
except Exception: # nosec B110 - already-gone races are fine
pass
else:
try:
Expand All @@ -1041,7 +1041,7 @@ def _kill_process(self, sess: _ServerSession) -> None:
except subprocess.TimeoutExpired:
try:
host.force_kill(process.pid)
except Exception: # noqa: BLE001 # nosec B110 - already-gone races are fine
except Exception: # nosec B110 - already-gone races are fine
pass
try:
process.wait(timeout=self.kill_wait_s)
Expand Down Expand Up @@ -1082,7 +1082,7 @@ def _survivors() -> list[int]:
for pid in survivors:
try:
host.force_kill(pid)
except Exception: # noqa: BLE001 # nosec B110 - already-gone races are fine
except Exception: # nosec B110 - already-gone races are fine
pass

def _atexit_sweep(self) -> None:
Expand Down
4 changes: 2 additions & 2 deletions src/bmad_loop/data/plugins/tea/tea_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def _review_token(line: str) -> str | None:
return None


def _scan_markdown(text: str, labels: tuple[str, ...], normalize) -> str | None: # noqa: ANN001
def _scan_markdown(text: str, labels: tuple[str, ...], normalize) -> str | None:
"""First *concrete* labeled line that yields a verdict. Lines carrying ``{}``
placeholders or ``|`` table syntax are skipped — they are template scaffolding,
not a generated decision — so a real artifact's value line is what's read."""
Expand Down Expand Up @@ -237,7 +237,7 @@ def _gate_verdict(self, gate: str, artifacts_dir: Path) -> str | None:
continue
try:
verdict = self._parse_artifact(kind, path)
except Exception: # noqa: BLE001 # nosec B112 - fail-open: a parse error never blocks
except Exception: # nosec B112 - fail-open: a parse error never blocks
continue
if verdict is not None:
return verdict
Expand Down
2 changes: 1 addition & 1 deletion src/bmad_loop/data/plugins/unity/unity_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ def _reap_dialog_probe(self, ctx) -> None:
path = self._probe_pid_path(ctx)
if path is None:
return
from bmad_loop import runs # noqa: PLC0415 - lazy: keep import cost off the hot path
from bmad_loop import runs # lazy: keep import cost off the hot path

pid, identity = runs.read_named_pid_identity(path)
if pid is not None:
Expand Down
4 changes: 2 additions & 2 deletions src/bmad_loop/data/plugins/unity/unity_teardown.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def _psutil():
dep-free core never imports it; raise a clear, actionable error if it's missing
on a platform that needs it."""
try:
import psutil # noqa: PLC0415 (intentional lazy import — keeps the core dep-free)
import psutil # intentional lazy import — keeps the core dep-free
except ImportError as exc: # pragma: no cover - exercised only off Linux
raise RuntimeError(
f"unity_teardown: process discovery on {sys.platform!r} needs psutil; "
Expand Down Expand Up @@ -251,7 +251,7 @@ def _probe_pidfile_pids(run_dir: Path | None) -> list[int]:
still alive AND still our probe (identity-checked). The primary reap handle."""
if run_dir is None:
return []
from bmad_loop.runs import read_named_pid_identity # noqa: PLC0415 - lazy import
from bmad_loop.runs import read_named_pid_identity # lazy import

pid, identity = read_named_pid_identity(run_dir / _DIALOG_PROBE_PID_FILE)
if pid is None:
Expand Down
4 changes: 2 additions & 2 deletions src/bmad_loop/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ def collect_env() -> EnvInfo:
mux = type(backend).__name__
raw = backend.version()
tmux_v = sanitize.scrub_text(raw, max_lines=1) if raw else None
except Exception: # noqa: BLE001 # nosec B110 - env probe is best-effort; absent mux is fine
except Exception: # nosec B110 - env probe is best-effort; absent mux is fine
pass
return EnvInfo(
os=platform.system(),
Expand Down Expand Up @@ -475,7 +475,7 @@ def collect(
for run_dir in run_dirs:
try:
runs.append(collect_run(run_dir, pseudo=pseudo, cap=cap))
except Exception as e: # noqa: BLE001 — one bad run never sinks the dump
except Exception as e: # one bad run never sinks the dump
runs.append(_unreadable_run(run_dir, e))
return Diagnostics(
schema_version=SCHEMA_VERSION,
Expand Down
16 changes: 8 additions & 8 deletions src/bmad_loop/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ def run(self) -> RunSummary:
# rather than let it mask the stop.
self._gc_run_worktrees()
self._emit("post_run")
except Exception as finalize_exc: # noqa: BLE001 - see comment above
except Exception as finalize_exc: # see comment above
self.journal.append("run-stop-finalize-error", error=str(finalize_exc))
remaining = self._remaining_estimate()
self._graceful_remaining = remaining
Expand Down Expand Up @@ -379,7 +379,7 @@ def run(self) -> RunSummary:
kill_session(self.state.run_id)
except (
BaseException
): # noqa: BLE001 # nosec B110 - best-effort teardown; the stop must still record
): # nosec B110 - best-effort teardown; the stop must still record
pass
if self._is_nested:
raise
Expand All @@ -405,7 +405,7 @@ def run(self) -> RunSummary:
kill_session(self.state.run_id)
except (
Exception
): # noqa: BLE001 # nosec B110 - best-effort teardown; a crashing run must still record
): # nosec B110 - best-effort teardown; a crashing run must still record
pass
if self._is_nested:
raise # nested auto-sweep: let the owner record the failure
Expand All @@ -424,7 +424,7 @@ def run(self) -> RunSummary:
)
except (
Exception
): # noqa: BLE001 # nosec B110 - journal write is best-effort; crash.txt + state flag already persisted
): # nosec B110 - journal write is best-effort; crash.txt + state flag already persisted
pass
finally:
# Any pending stop-request control file that outlived this run
Expand Down Expand Up @@ -473,7 +473,7 @@ def _install_stop_signals(self) -> None:
if sigbreak is not None:
windows_ctrl_signals.add(sigbreak)

def handler(signum, frame): # noqa: ANN001 - stdlib signal signature
def handler(signum, frame): # stdlib signal signature
if sys.platform == "win32" and signum in windows_ctrl_signals:
# best-effort: a journal error must never escape a signal handler.
with contextlib.suppress(Exception):
Expand Down Expand Up @@ -601,7 +601,7 @@ def _remaining_estimate(self) -> int | None:
for s in ss.stories
if s.status in ACTIONABLE_STATUSES and s.key not in self.state.tasks
)
except Exception: # noqa: BLE001 - a hint must never break the stop
except Exception: # a hint must never break the stop
return None

def _check_graceful_stop(self) -> None:
Expand Down Expand Up @@ -2083,7 +2083,7 @@ def _run_session(
status="aborted",
error=type(exc).__name__ if exc is not None else None,
)
except Exception: # noqa: BLE001 # nosec B110
except Exception: # nosec B110
pass
self._save()
self._emit(
Expand Down Expand Up @@ -2460,7 +2460,7 @@ def _maybe_auto_sweep(self, kind: str, trigger: str) -> None:
try:
self.sweep_factory(trigger)
self.journal.append("sweep-auto-finished", trigger=trigger)
except Exception as e: # noqa: BLE001 — child must never break the parent
except Exception as e: # child must never break the parent
self.journal.append("sweep-auto-failed", trigger=trigger, error=str(e))
gates.notify(self.policy, self.run_dir, "auto sweep failed", f"{trigger}: {e}")

Expand Down
2 changes: 1 addition & 1 deletion src/bmad_loop/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ def _warn_if_policy_tracked(project: Path) -> None:
means nothing to warn about."""
try:
tracked = (
subprocess.run( # noqa: S603, S607 — fixed argv, no shell
subprocess.run( # fixed argv, no shell
["git", "ls-files", "--error-unmatch", ".bmad-loop/policy.toml"],
cwd=project,
capture_output=True,
Expand Down
2 changes: 1 addition & 1 deletion src/bmad_loop/plugins/bus.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ def _dispatch_python(self, lp: LoadedPlugin, stage: str, ctx: HookContext) -> No
ctx._current_plugin = lp.name
try:
instance.hook(stage, ctx) # type: ignore[union-attr]
except Exception as e: # noqa: BLE001 - isolate plugin failures; never BaseException
except Exception as e: # isolate plugin failures; never BaseException
self._log("plugin-error", plugin=lp.name, stage=stage, error=f"{type(e).__name__}: {e}")
# disable the misbehaving instance for the rest of the run; its
# declarative hooks (if any) keep working — they are out-of-process.
Expand Down
2 changes: 1 addition & 1 deletion src/bmad_loop/plugins/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def _resolve(manifest: PluginManifest, policy, journal) -> LoadedPlugin:

try:
instance = _instantiate(manifest, settings)
except Exception as e: # noqa: BLE001 - isolate plugin failures; never BaseException
except Exception as e: # isolate plugin failures; never BaseException
if journal is not None:
journal.append("plugin-error", plugin=manifest.name, error=f"{type(e).__name__}: {e}")
return LoadedPlugin(manifest=manifest, disabled=True, error=str(e), settings=settings)
Expand Down
2 changes: 1 addition & 1 deletion src/bmad_loop/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,7 @@ def probe(
mux = get_multiplexer()
try:
mux_ready = bool(mux.available())
except Exception: # noqa: BLE001 — a raising host probe means "cannot probe", not a crash
except Exception: # a raising host probe means "cannot probe", not a crash
mux_ready = False
if not mux_ready or not shutil.which(binary):
# finding.binary, not the raw local — see the identical note in scan()
Expand Down
Loading
Loading