diff --git a/CHANGELOG.md b/CHANGELOG.md index bef3b1f..a7da956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [8.6.0] - 2026-08-14 + +### Added +- **Machine-readable note rules compile into deterministic PreToolUse checks** + (#240). Fenced ```` ```omind-rule ```` YAML blocks in vault notes (`id`, + `tool`, `match` glob, `when: {repo_visibility, branch}`, `except_repos`, + `action: deny|warn`, `message`) are parsed (cached per file mtime/size, + invalid blocks skipped with a breadcrumb) and evaluated in + `guard.check_action` before everything else — every rule a hook can decide + never depends on model attention. The cryptojones.github.io exception was + violated three times *while the governing note was force-recalled*; a + ten-line deterministic check makes that class of recurrence impossible. + Repo visibility via `gh repo view` (24h on-disk cache) **fails open** to + unknown; `warn` and unknown-visibility hits log compliance decisions without + blocking; every deny logs like any other hard rule. `omind rules list` + prints the compiled table including skipped blocks. Ships one seed rule + (deny direct `git push` on a public repo's checked-out main/master) that a + vault note with the same `id` replaces to add exceptions; the repo-deletion + incident class was already covered by `policy.SEED_RULES`. + ## [8.5.0] - 2026-08-14 ### Added diff --git a/README.md b/README.md index 363cb50..bf3ab4d 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,16 @@ rule in `omind.policy`. The Playbook is the guard's priming made explicit: *don' ask a fresh instance to remember — put the rule in front of it, and block the wrong action.* +Operators can also declare **deterministic rules inside ordinary vault notes** +with fenced ```` ```omind-rule ```` blocks (YAML: `id`, `tool`, `match` glob, +optional `when: {repo_visibility, branch}` and `except_repos`, `action: +deny|warn`, `message`). They compile into PreToolUse checks evaluated before +everything else — every rule a hook can decide never depends on model +attention. Repo visibility comes from `gh` (cached a day) and **fails open** +when unknown; invalid blocks are skipped with a breadcrumb. Inspect the +compiled table with `omind rules list`. A note rule with a seed rule's `id` +replaces the seed, so per-repo exceptions stay operator-editable in the vault. + ## Activity checkpoints You can't reliably *force* a running agent to do something on a wall clock — diff --git a/pyproject.toml b/pyproject.toml index 4b35c46..bba9143 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "omind" -version = "8.5.0" +version = "8.6.0" description = "Reproduce the OMI/Obsidian memory integration for AI agents, plus a local web app to view, edit, and add memory entries." readme = "README.md" requires-python = ">=3.10" diff --git a/src/omind/__init__.py b/src/omind/__init__.py index b6ce20d..c457852 100644 --- a/src/omind/__init__.py +++ b/src/omind/__init__.py @@ -2,4 +2,4 @@ # Copyright 2026 Aaron K. Clark """omind — OMI/Obsidian memory tooling for AI agents.""" -__version__ = "8.5.0" +__version__ = "8.6.0" diff --git a/src/omind/cli.py b/src/omind/cli.py index 30b023d..05b7bd8 100644 --- a/src/omind/cli.py +++ b/src/omind/cli.py @@ -411,6 +411,13 @@ def build_parser() -> argparse.ArgumentParser: bench.add_argument("--json", action="store_true", help="emit measurements as JSON") _add_vault_args(bench) + rules = sub.add_parser( + "rules", + help="deterministic note rules compiled into PreToolUse checks (#240)", + ) + rules.add_argument("action", choices=["list"], help="list compiled rules") + _add_vault_args(rules) + lint = sub.add_parser( "lint", help="check the vault for broken wikilinks, isolated/orphaned notes, " @@ -1603,6 +1610,11 @@ def main(argv: list[str] | None = None) -> int: return _run_bench(args) if args.command == "lint": return _run_lint(args) + if args.command == "rules": + from omind import rules as _rules + + print(_rules.format_rules((args.vault / args.folder).expanduser())) + return 0 if args.command == "recover": return _run_recover(args) if args.command == "graph": diff --git a/src/omind/guard.py b/src/omind/guard.py index a04fe49..1034ec6 100644 --- a/src/omind/guard.py +++ b/src/omind/guard.py @@ -1224,6 +1224,48 @@ def decide(action: dict[str, Any]) -> Verdict: return Verdict(allow=False, reason=f"omi-gate: {GATE_MESSAGE}", rule_id="omi-gate") +def _note_rules_verdict(action: dict[str, Any], omi_dir: Path | None) -> Verdict | None: + """Deterministic operator note rules (#240), evaluated before everything. + + Rules a hook can decide must never depend on model attention. A ``deny`` + hit blocks with the rule's message (compliance-logged by the caller like + any other hard deny); a ``warn`` or an unknown-visibility miss logs a + decision event and falls through. Never raises — a broken rule table must + never brick the guard (fail-open like every other layer). + """ + if omi_dir is None: + return None + try: + from omind import rules + + hit = rules.evaluate(action, omi_dir, _repo_root_for_action(action)) + if hit is None: + return None + session = str(action.get("session") or "") + if hit.outcome == rules.ACTION_DENY: + return Verdict( + allow=False, + reason=( + f"omi-guard (hard): note rule '{hit.rule.id}' " + f"[{hit.rule.note}]: {hit.rule.message}" + ), + rule_id=f"note-rule:{hit.rule.id}", + ) + compliance.log_event( + compliance.KIND_DECISION, + session=session, + tool=str(action.get("tool") or ""), + command=str(action.get("command") or ""), + rule_id=f"note-rule:{hit.rule.id}", + severity="soft", + outcome=hit.outcome, + detail=(hit.detail or hit.rule.message)[:200], + ) + return None + except Exception: + return None + + #: Hard ceiling for the embedded excerpt so a huge note can't bloat every deny. _EXCERPT_CAP = 1_600 @@ -1251,7 +1293,9 @@ def check_action(action: dict[str, Any], omi_dir: Path | None = None) -> Verdict (:mod:`omind.adapters`), so every harness logs + decides identically. The routine ``omi-gate`` "you didn't consult" deny is friction, not logged. """ - verdict = decide(action) + verdict = _note_rules_verdict(action, omi_dir) + if verdict is None: + verdict = decide(action) if ( not verdict.allow and verdict.rule_id == "repo-work-read-git-rules" diff --git a/src/omind/rules.py b/src/omind/rules.py new file mode 100644 index 0000000..3027ca4 --- /dev/null +++ b/src/omind/rules.py @@ -0,0 +1,347 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Aaron K. Clark +"""Machine-readable note rules compiled into deterministic PreToolUse checks. + +Every rule a hook can decide must never depend on model attention (#240): the +cryptojones.github.io exception was violated three times even though the +governing note was force-recalled each time. Operators declare rules in fenced +``omind-rule`` blocks inside ordinary vault notes:: + + ```omind-rule + id: no-direct-push-public-main + tool: Bash + match: "git push*" + when: + repo_visibility: public + branch: [main, master] + except_repos: [cryptojones.github.io] + action: deny + message: "Public repo: branch + PR required." + ``` + +``load_rules`` scans top-level ``*.md`` for these blocks (cached per file +``(mtime_ns, size)``); invalid blocks are skipped with a breadcrumb, never +raised — a broken rule must never brick the guard. A note rule with the same +``id`` as a seed rule replaces it, so exceptions stay operator-editable. + +v1 conditions are ``repo_visibility`` (via ``gh repo view``, cached one day, +**fail-open to UNKNOWN**: a rule conditioned on visibility does not fire when +visibility cannot be determined) and ``branch`` (the repo's checked-out +branch). ``except_repos`` matches the origin remote's repository name. +""" + +from __future__ import annotations + +import fnmatch +import json +import re +import subprocess +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +from omind import paths + +ACTION_DENY = "deny" +ACTION_WARN = "warn" +_ACTIONS = (ACTION_DENY, ACTION_WARN) + +#: Visibility cache TTL. Repo visibility changes rarely; `gh` calls are slow. +_VISIBILITY_TTL_HOURS = 24 +_VISIBILITY_UNKNOWN = "unknown" + +_BLOCK_RE = re.compile(r"```omind-rule\s*\n(.*?)```", re.DOTALL) + +#: Cold-start seed: the incident class that motivated this module. The +#: repo-deletion incident is already covered by ``policy.SEED_RULES``. +#: Operators add per-repo exceptions by declaring a note rule with this same +#: ``id`` (note rules replace seeds by id). +SEED_NOTE_RULES: tuple[NoteRule, ...] = () # populated below the dataclass + + +@dataclass(frozen=True) +class NoteRule: + id: str + tool: str + match: str + action: str + message: str + when_visibility: str = "" + when_branch: tuple[str, ...] = () + except_repos: tuple[str, ...] = () + note: str = "(seed)" + invalid: str = "" # non-empty on a skipped block: the reason, for `rules list` + + def conditioned_on_visibility(self) -> bool: + return bool(self.when_visibility) + + +SEED_NOTE_RULES = ( + NoteRule( + id="no-direct-push-public-main", + tool="Bash", + match="*git push*", + action=ACTION_DENY, + message=( + "Public repo on main/master: feature branch + PR required, never a " + "direct push. Declare an `omind-rule` block with this id in a vault " + "note to add per-repo exceptions." + ), + when_visibility="public", + when_branch=("main", "master"), + ), +) + + +def _breadcrumb(context: str, exc: BaseException | str) -> None: + from omind import hooks + + hooks._record_failure(context, exc if isinstance(exc, BaseException) else RuntimeError(exc)) + + +def _parse_block(text: str, note: str) -> NoteRule: + """One fenced block -> NoteRule; an invalid block returns a stub with + ``invalid`` set (skipped by the matcher, shown by ``rules list``).""" + import yaml + + try: + data = yaml.safe_load(text) + except yaml.YAMLError as exc: + return NoteRule("", "", "", "", "", note=note, invalid=f"YAML error: {exc}") + if not isinstance(data, dict): + return NoteRule("", "", "", "", "", note=note, invalid="not a mapping") + rule_id = str(data.get("id") or "").strip() + tool = str(data.get("tool") or "").strip() + match = str(data.get("match") or "").strip() + action = str(data.get("action") or "").strip().lower() + message = str(data.get("message") or "").strip() + when = data.get("when") if isinstance(data.get("when"), dict) else {} + visibility = str(when.get("repo_visibility") or "").strip().lower() + branches = when.get("branch") + if isinstance(branches, str): + branches = [branches] + branches = tuple(str(b).strip() for b in branches or [] if str(b).strip()) + excepts = data.get("except_repos") + if isinstance(excepts, str): + excepts = [excepts] + excepts = tuple(str(r).strip() for r in excepts or [] if str(r).strip()) + problems = [] + if not rule_id: + problems.append("missing id") + if not tool: + problems.append("missing tool") + if not match: + problems.append("missing match") + if action not in _ACTIONS: + problems.append(f"action must be one of {_ACTIONS}") + if action == ACTION_DENY and not message: + problems.append("deny requires message") + if problems: + return NoteRule( + rule_id, tool, match, action, message, note=note, invalid="; ".join(problems) + ) + return NoteRule( + id=rule_id, + tool=tool, + match=match, + action=action, + message=message, + when_visibility=visibility, + when_branch=branches, + except_repos=excepts, + note=note, + ) + + +#: Per-file parse cache: {path: ((mtime_ns, size), [NoteRule, ...])}. +_file_cache: dict[str, tuple[tuple[int, int], list[NoteRule]]] = {} + + +def _rules_in_file(path: Path) -> list[NoteRule]: + try: + stat = path.stat() + key = (stat.st_mtime_ns, stat.st_size) + except OSError: + return [] + cached = _file_cache.get(str(path)) + if cached is not None and cached[0] == key: + return cached[1] + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return [] + rules: list[NoteRule] = [] + if "```omind-rule" in text: + for block in _BLOCK_RE.findall(text): + rule = _parse_block(block, path.name) + if rule.invalid: + _breadcrumb(f"rules({path.name})", f"skipped invalid rule: {rule.invalid}") + rules.append(rule) + _file_cache[str(path)] = (key, rules) + return rules + + +def load_rules(omi_dir: Path | str, *, include_invalid: bool = False) -> list[NoteRule]: + """Seed rules plus every valid note rule in top-level ``*.md``; a note rule + replaces a seed rule with the same id. Never raises.""" + collected: dict[str, NoteRule] = {r.id: r for r in SEED_NOTE_RULES} + invalid: list[NoteRule] = [] + try: + notes = sorted(Path(omi_dir).glob("*.md")) + except OSError: + notes = [] + for path in notes: + for rule in _rules_in_file(path): + if rule.invalid: + invalid.append(rule) + else: + collected[rule.id] = rule + result = list(collected.values()) + return result + invalid if include_invalid else result + + +def _visibility_cache_path() -> Path: + return paths.state_dir() / "repo-visibility.json" + + +def _repo_visibility(repo: Path, *, now: datetime | None = None) -> str: + """``public`` / ``private`` / ``unknown`` for ``repo``, via ``gh``, cached + on disk for a day. UNKNOWN on any failure — visibility-conditioned rules + then do not fire (fail-open), but the miss is breadcrumbed.""" + now = now or datetime.now() + path = _visibility_cache_path() + cache: dict[str, Any] = {} + try: + cache = json.loads(path.read_text(encoding="utf-8")) + entry = cache.get(str(repo)) + if isinstance(entry, dict): + stamp = datetime.fromisoformat(str(entry.get("ts"))) + if now - stamp < timedelta(hours=_VISIBILITY_TTL_HOURS): + return str(entry.get("visibility") or _VISIBILITY_UNKNOWN) + except (OSError, ValueError, TypeError): + cache = cache if isinstance(cache, dict) else {} + try: + proc = subprocess.run( + ["gh", "repo", "view", "--json", "visibility", "-q", ".visibility"], + cwd=repo, + capture_output=True, + text=True, + timeout=10, + ) + visibility = proc.stdout.strip().lower() if proc.returncode == 0 else "" + except (OSError, subprocess.SubprocessError): + visibility = "" + if visibility not in ("public", "private", "internal"): + _breadcrumb(f"rules_visibility({repo})", "gh visibility lookup failed") + return _VISIBILITY_UNKNOWN + cache[str(repo)] = {"visibility": visibility, "ts": now.isoformat(timespec="seconds")} + try: + path.parent.mkdir(parents=True, exist_ok=True) + paths.atomic_write_text(path, json.dumps(cache) + "\n", mode=0o600) + except OSError: + pass + return visibility + + +def _repo_name(repo: Path) -> str: + try: + proc = subprocess.run( + ["git", "-C", str(repo), "remote", "get-url", "origin"], + capture_output=True, + text=True, + timeout=5, + ) + url = proc.stdout.strip() + except (OSError, subprocess.SubprocessError): + return "" + if not url: + return "" + name = url.rstrip("/").rsplit("/", 1)[-1] + return name[:-4] if name.endswith(".git") else name + + +def _repo_branch(repo: Path) -> str: + try: + proc = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + timeout=5, + ) + return proc.stdout.strip() + except (OSError, subprocess.SubprocessError): + return "" + + +@dataclass(frozen=True) +class RuleHit: + rule: NoteRule + outcome: str # "deny" | "warn" + detail: str = "" + + +def evaluate( + action: dict[str, Any], + omi_dir: Path | str, + repo: Path | None, + *, + rules: list[NoteRule] | None = None, +) -> RuleHit | None: + """First matching rule for ``action``, or ``None``. Deterministic, no model. + + ``repo`` is the enclosing git repo when the guard resolved one; rules with + repo-scoped conditions (visibility/branch/except_repos) require it and do + not fire without one. + """ + tool = str(action.get("tool") or "") + command = str(action.get("command") or "") + target = command or str(action.get("path") or "") + for rule in rules if rules is not None else load_rules(omi_dir): + if rule.invalid: + continue + if rule.tool not in ("*", tool): + continue + if not fnmatch.fnmatch(target, rule.match): + continue + repo_scoped = ( + rule.conditioned_on_visibility() or rule.when_branch or rule.except_repos + ) + if repo_scoped: + if repo is None: + continue + if rule.except_repos and _repo_name(repo) in rule.except_repos: + continue + if rule.when_branch and _repo_branch(repo) not in rule.when_branch: + continue + if rule.conditioned_on_visibility(): + visibility = _repo_visibility(repo) + if visibility == _VISIBILITY_UNKNOWN: + # Fail-open: never deny on a condition we could not check. + return RuleHit(rule, "unknown-visibility", "visibility unknown") + if visibility != rule.when_visibility: + continue + return RuleHit(rule, rule.action) + return None + + +def format_rules(omi_dir: Path | str) -> str: + """Human-readable compiled-rule listing for ``omind rules list``.""" + lines: list[str] = [] + for rule in load_rules(omi_dir, include_invalid=True): + if rule.invalid: + lines.append(f"[skipped] {rule.note}: {rule.invalid}") + continue + conditions = [] + if rule.when_visibility: + conditions.append(f"visibility={rule.when_visibility}") + if rule.when_branch: + conditions.append(f"branch in {list(rule.when_branch)}") + if rule.except_repos: + conditions.append(f"except {list(rule.except_repos)}") + cond = f" when {', '.join(conditions)}" if conditions else "" + lines.append( + f"[{rule.action}] {rule.id}: {rule.tool} {rule.match!r}{cond} " + f"(from {rule.note})" + ) + return "\n".join(lines) if lines else "(no rules)" diff --git a/tests/test_rules.py b/tests/test_rules.py new file mode 100644 index 0000000..d94d216 --- /dev/null +++ b/tests/test_rules.py @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for omind.rules: parsing, matching, fail-open visibility, caching, +and the guard wiring (#240).""" + +from __future__ import annotations + +import subprocess +import time +from pathlib import Path + +import pytest + +from omind import guard, rules + + +def _note_with_rule(omi: Path, name: str = "Guard Rules.md", **overrides: str) -> Path: + block = { + "id": "no-direct-push-public-main", + "tool": "Bash", + "match": '"*git push*"', + "when": "\n repo_visibility: public\n branch: [main, master]", + "except_repos": "[allowed-repo]", + "action": "deny", + "message": '"Public repo: branch + PR required."', + } + block.update(overrides) + omi.mkdir(parents=True, exist_ok=True) + path = omi / name + path.write_text( + "# Guard Rules\n\n```omind-rule\n" + f"id: {block['id']}\n" + f"tool: {block['tool']}\n" + f"match: {block['match']}\n" + f"when:{block['when']}\n" + f"except_repos: {block['except_repos']}\n" + f"action: {block['action']}\n" + f"message: {block['message']}\n" + "```\n", + encoding="utf-8", + ) + return path + + +def test_parse_valid_invalid_and_multiple_blocks(tmp_path: Path) -> None: + omi = tmp_path / "OMI" + omi.mkdir() + (omi / "Multi.md").write_text( + "```omind-rule\nid: a\ntool: Bash\nmatch: '*rm -rf*'\naction: warn\n" + "message: careful\n```\n" + "```omind-rule\nid: b\ntool: '*'\nmatch: '*curl*'\naction: deny\nmessage: 'no'\n```\n" + "```omind-rule\ntool: Bash\nmatch: '*x*'\naction: deny\nmessage: m\n```\n" # no id + "```omind-rule\n[not: yaml\n```\n", # parse error + encoding="utf-8", + ) + loaded = {r.id: r for r in rules.load_rules(omi)} + assert "a" in loaded and loaded["a"].action == "warn" + assert "b" in loaded and loaded["b"].tool == "*" + everything = rules.load_rules(omi, include_invalid=True) + assert sum(1 for r in everything if r.invalid) == 2 # both bad blocks skipped + + +def test_note_rule_replaces_seed_rule_by_id(tmp_path: Path) -> None: + omi = tmp_path / "OMI" + _note_with_rule(omi) + loaded = {r.id: r for r in rules.load_rules(omi)} + rule = loaded["no-direct-push-public-main"] + assert rule.except_repos == ("allowed-repo",) # note version, not the seed + assert rule.note == "Guard Rules.md" + + +def test_cache_invalidates_on_note_edit(tmp_path: Path) -> None: + omi = tmp_path / "OMI" + path = _note_with_rule(omi) + first = {r.id for r in rules.load_rules(omi)} + assert "no-direct-push-public-main" in first + time.sleep(0.01) + path.write_text( + "```omind-rule\nid: replacement\ntool: Bash\nmatch: '*x*'\naction: warn\n" + "message: hi\n```\n", + encoding="utf-8", + ) + second = {r.id for r in rules.load_rules(omi)} + assert "replacement" in second + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", "-b", "main", str(repo)], check=True) + subprocess.run( + ["git", "-C", str(repo), "remote", "add", "origin", + "https://github.com/o/some-repo.git"], + check=True, + ) + return repo + + +def _action(command: str) -> dict: + return {"tool": "Bash", "command": command, "session": "rules-test"} + + +def test_deny_on_public_main_push(tmp_path: Path, repo: Path, monkeypatch) -> None: + omi = tmp_path / "OMI" + _note_with_rule(omi) + monkeypatch.setattr(rules, "_repo_visibility", lambda r, **k: "public") + monkeypatch.setattr(rules, "_repo_branch", lambda r: "main") + hit = rules.evaluate(_action("git push origin main"), omi, repo) + assert hit is not None and hit.outcome == "deny" + # Same command in an excepted repo: allowed. + monkeypatch.setattr(rules, "_repo_name", lambda r: "allowed-repo") + assert rules.evaluate(_action("git push origin main"), omi, repo) is None + + +def test_no_fire_on_private_or_feature_branch(tmp_path: Path, repo: Path, monkeypatch) -> None: + omi = tmp_path / "OMI" + _note_with_rule(omi) + monkeypatch.setattr(rules, "_repo_visibility", lambda r, **k: "private") + monkeypatch.setattr(rules, "_repo_branch", lambda r: "main") + assert rules.evaluate(_action("git push origin main"), omi, repo) is None + monkeypatch.setattr(rules, "_repo_visibility", lambda r, **k: "public") + monkeypatch.setattr(rules, "_repo_branch", lambda r: "feature/x") + assert rules.evaluate(_action("git push origin main"), omi, repo) is None + + +def test_unknown_visibility_fails_open(tmp_path: Path, repo: Path, monkeypatch) -> None: + omi = tmp_path / "OMI" + _note_with_rule(omi) + monkeypatch.setattr(rules, "_repo_visibility", lambda r, **k: "unknown") + monkeypatch.setattr(rules, "_repo_branch", lambda r: "main") + hit = rules.evaluate(_action("git push origin main"), omi, repo) + assert hit is not None and hit.outcome == "unknown-visibility" # logged, never denied + + +def test_repo_scoped_rule_needs_a_repo(tmp_path: Path) -> None: + omi = tmp_path / "OMI" + _note_with_rule(omi) + assert rules.evaluate(_action("git push origin main"), omi, None) is None + + +def test_guard_check_action_denies_via_note_rule( + tmp_path: Path, repo: Path, monkeypatch +) -> None: + omi = tmp_path / "OMI" + _note_with_rule(omi) + monkeypatch.setattr(rules, "_repo_visibility", lambda r, **k: "public") + monkeypatch.setattr(rules, "_repo_branch", lambda r: "main") + monkeypatch.setattr(guard, "_repo_root_for_action", lambda a: repo) + guard.begin_turn("rules-guard", "push it") + verdict = guard.check_action(_action("git push origin main"), omi_dir=omi) + assert not verdict.allow + assert verdict.rule_id == "note-rule:no-direct-push-public-main" + assert "branch + PR required" in verdict.reason + + +def test_format_rules_lists_seeds_and_invalids(tmp_path: Path) -> None: + omi = tmp_path / "OMI" + omi.mkdir() + (omi / "Bad.md").write_text( + "```omind-rule\ntool: Bash\nmatch: '*x*'\naction: deny\nmessage: m\n```\n", + encoding="utf-8", + ) + text = rules.format_rules(omi) + assert "no-direct-push-public-main" in text # seed present on a fresh vault + assert "[skipped] Bad.md" in text diff --git a/uv.lock b/uv.lock index cbebeaf..63c48ce 100644 --- a/uv.lock +++ b/uv.lock @@ -2354,7 +2354,7 @@ wheels = [ [[package]] name = "omind" -version = "8.5.0" +version = "8.6.0" source = { editable = "." } dependencies = [ { name = "cryptography" },