diff --git a/src/omind/cli.py b/src/omind/cli.py index 75fc163..a3446b4 100644 --- a/src/omind/cli.py +++ b/src/omind/cli.py @@ -11,6 +11,8 @@ * ``omind export`` — write the entire OMI dataset to a json or tar.gz bundle. * ``omind import`` — load an OMI dataset bundle back into a folder. * ``omind reindex`` — regenerate index.md under the inter-process write lock. + * ``omind maintain`` — sleep-time janitor: propose merges, refresh the index, + opt-in journal rollup / mesh sync. Safe by default (dry run). * ``omind quickstart`` — print the manual-wiring steps `setup` automates. * ``omind graph`` — query the [[wikilink]] knowledge graph (neighbors, path, orphans, dangling links, stats, export). @@ -67,7 +69,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--version", action="version", version=f"omind {__version__}") sub = parser.add_subparsers( dest="command", - metavar="{help,setup,quickstart,serve,doctor,self-update,backup,ai,export,import,reindex,note,rollup,recover,hook}", + metavar="{help,setup,quickstart,serve,doctor,self-update,backup,ai,export,import,reindex,maintain,note,rollup,recover,hook}", ) help_p = sub.add_parser( @@ -313,6 +315,37 @@ def build_parser() -> argparse.ArgumentParser: ) _add_vault_args(reindex) + maintain = sub.add_parser( + "maintain", + help="sleep-time janitor: propose merges, refresh the index, and " + "(opt-in) roll up journals or sync the mesh — safe by default", + ) + maintain.add_argument( + "--apply", + action="store_true", + help="perform the safe maintenance (index refresh); consolidations stay " + "propose-only regardless", + ) + maintain.add_argument( + "--sync", + action="store_true", + help="also run a mesh sync — the only fleet-propagating, irreversible " + "step, so it is opt-in and never in --apply", + ) + maintain.add_argument( + "--rollup", + action="store_true", + help="also roll up eligible weeks of daily journals (a lossy history " + "squash — opt-in, never default)", + ) + maintain.add_argument( + "--report-note", + action="store_true", + help="write the run report as a vault note (default: stdout + state dir " + "only, so the janitor doesn't litter the vault)", + ) + _add_vault_args(maintain) + convert = sub.add_parser( "convert", help="migrate an OMI vault to the Open Knowledge Format (OKF): give every " @@ -1122,6 +1155,35 @@ def _run_import(args: argparse.Namespace) -> int: return 1 if (result.conflicts and not args.force) else 0 +def _run_maintain(args: argparse.Namespace) -> int: + from omind import maintain, mesh + + omi_dir = (args.vault / args.folder).expanduser() + node_id = "" + if args.sync: + cfg = mesh.load_node_config(omi_dir) + if cfg is None: + print( + "--sync needs a mesh node — run `omind mesh init` first, or drop --sync", + file=sys.stderr, + ) + return 1 + node_id = cfg.node_id + report = maintain.run( + omi_dir, + node_id=node_id, + apply=args.apply, + sync=args.sync, + rollup=args.rollup, + report_note=args.report_note, + ) + if report.refused: + return 1 + if not args.apply and not args.sync and not args.rollup: + print("(dry run — pass --apply to act; nothing in the vault was changed)") + return 1 if report.aborted else 0 + + def _run_reindex(args: argparse.Namespace) -> int: from omind import searchindex from omind.journal import migrate_journals @@ -1729,6 +1791,8 @@ def main(argv: list[str] | None = None) -> int: return _run_ai(args) if args.command == "reindex": return _run_reindex(args) + if args.command == "maintain": + return _run_maintain(args) if args.command == "convert": return _run_convert(args) if args.command == "note": diff --git a/src/omind/filelock.py b/src/omind/filelock.py index 2db9eb4..187e47e 100644 --- a/src/omind/filelock.py +++ b/src/omind/filelock.py @@ -31,6 +31,15 @@ def lock_fd(fd: int) -> None: os.lseek(fd, 0, os.SEEK_SET) msvcrt.locking(fd, msvcrt.LK_LOCK, _REGION_BYTES) + def try_lock_fd(fd: int) -> bool: + """Take the exclusive lock without blocking; ``False`` if held elsewhere.""" + os.lseek(fd, 0, os.SEEK_SET) + try: + msvcrt.locking(fd, msvcrt.LK_NBLCK, _REGION_BYTES) + except OSError: + return False + return True + def unlock_fd(fd: int) -> None: """Release the lock taken by :func:`lock_fd`.""" os.lseek(fd, 0, os.SEEK_SET) @@ -43,6 +52,14 @@ def lock_fd(fd: int) -> None: """Block until this process holds the exclusive lock on ``fd``.""" fcntl.flock(fd, fcntl.LOCK_EX) + def try_lock_fd(fd: int) -> bool: + """Take the exclusive lock without blocking; ``False`` if held elsewhere.""" + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + return False + return True + def unlock_fd(fd: int) -> None: """Release the lock taken by :func:`lock_fd`.""" fcntl.flock(fd, fcntl.LOCK_UN) @@ -89,6 +106,27 @@ def append_locked(path: Path, *, mode: int = 0o600) -> Iterator[int]: os.close(fd) +@contextlib.contextmanager +def try_exclusive(path: Path, *, mode: int = 0o600) -> Iterator[bool]: + """Try to take the exclusive lock on ``path`` WITHOUT blocking. + + Yields ``True`` when this process took the lock (held for the block) and + ``False`` when another process already holds it — the primitive behind the + janitor's single-instance mutex and its "is a sync in flight?" probe, where + waiting is exactly the wrong behaviour. Same sibling-``.lock`` discipline as + :func:`exclusive`. + """ + fd = os.open(path, os.O_RDWR | os.O_CREAT | _BINARY | _NOFOLLOW, mode) + acquired = try_lock_fd(fd) + try: + yield acquired + finally: + if acquired: + with contextlib.suppress(OSError): + unlock_fd(fd) + os.close(fd) + + @contextlib.contextmanager def exclusive(path: Path, *, mode: int = 0o600) -> Iterator[int]: """Open (creating) ``path`` read-write and hold the exclusive lock on it. diff --git a/src/omind/maintain.py b/src/omind/maintain.py new file mode 100644 index 0000000..f05dd2a --- /dev/null +++ b/src/omind/maintain.py @@ -0,0 +1,185 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Aaron K. Clark +"""Sleep-time janitor — ``omind maintain`` (2026-08-27 roundtable, item #3). + +One opt-in maintenance pass over a vault, built to the ratified invariants: + +* **Consolidation is propose-only, always.** ``maintain`` may surface merge + candidates but never applies them — propose-and-review is permanent, so even + ``--apply`` only proposes. +* **Single janitor per vault.** A non-blocking flock mutex refuses a second + concurrent run rather than double-maintaining. +* **Refuse while a mesh sync (or any vault write) is in flight** — an explicit + probe of the vault write-lock, not a guess. +* **Fail-closed pipeline.** A failed step aborts the rest; in particular the + fleet-propagating ``--sync`` never runs after an earlier failure. +* **GC / mesh-sync stays out of the default apply set** — it is the only step + that propagates fleet-wide and is irreversible, so it is opt-in via ``--sync``. +* **Rollups are opt-in** (``--rollup``), never in the default set — a lossy + history squash is a deliberate choice, not a default. +* **No per-run report notes in the vault.** The report goes to stdout and the + state dir; ``--report-note`` is the only way one lands in the vault. +""" + +from __future__ import annotations + +import contextlib +import json +import time +from collections.abc import Callable +from dataclasses import asdict, dataclass, field +from pathlib import Path + +from omind import consolidate, filelock, journal, mesh, paths, searchindex +from omind.store import LOCK_FILENAME, SCRATCH_SUFFIX, NoteFields, OmiStore + +#: Scratch-tier TTL (item #5 part 2): 7 days from LAST MODIFICATION (HAL9000's +#: clock refinement — not creation). Expiry ARCHIVES, never deletes. +_SCRATCH_TTL_DAYS = 7 + + +@dataclass +class StepResult: + name: str + ok: bool + detail: str + + +@dataclass +class MaintainReport: + ran: bool = False + #: Non-empty when the janitor declined to run (mutex held, or sync in flight). + refused: str = "" + apply: bool = False + aborted: bool = False + steps: list[StepResult] = field(default_factory=list) + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +def _mesh_sync_in_flight(omi_dir: Path) -> bool: + """Whether the vault write-lock is held right now (a sync or a note write).""" + with filelock.try_exclusive(omi_dir / LOCK_FILENAME) as acquired: + return not acquired + + +def _reindex_detail(omi_dir: Path) -> str: + """Refresh the derived index. ``refresh`` is incremental and live — it never + empties the index in place, so a concurrent searcher never sees a hole (the + build-and-swap invariant).""" + index = searchindex.shared(omi_dir) + if index is None: + return "index unavailable on this machine — skipped" + result = index.refresh() + if result is None: + return "index busy — skipped" + return f"{result.reindexed} reindexed, {result.removed} reaped, {result.notes} notes" + + +def _expire_scratch(omi_dir: Path, *, apply: bool) -> str: + """Archive scratch notes untouched for the TTL. Machine-local, so this is + safe outside ``--sync``; archives (never deletes) via the soft-delete path.""" + store = OmiStore(omi_dir) + cutoff = time.time() - _SCRATCH_TTL_DAYS * 86_400 + expired: list[str] = [] + for path in sorted(omi_dir.glob(f"*{SCRATCH_SUFFIX}")): + with contextlib.suppress(Exception): + if store.read_fields(path.name).disabled: + continue # already archived + if path.stat().st_mtime >= cutoff: + continue + expired.append(path.name) + if apply: + store.disable_note(path.name) + verb = "archived" if apply else "would expire" + return f"{len(expired)} scratch note(s) {verb} (>{_SCRATCH_TTL_DAYS}d since last change)" + + +def _sync_detail(omi_dir: Path, node_id: str) -> str: + report = mesh.sync(omi_dir, node_id, log=lambda *_: None) + return f"synced against {len(report.peers)} peer(s)" + + +def _persist(omi_dir: Path, report: MaintainReport) -> None: + path = paths.maintain_state_path(omi_dir) + with contextlib.suppress(OSError): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(report.to_dict(), indent=2), encoding="utf-8") + + +def _write_report_note(omi_dir: Path, report: MaintainReport) -> None: + """Opt-in only: a vault note summarising the run. Off by default because a + report note is itself lint/dedup fodder that then replicates fleet-wide.""" + lines = [f"- {s.name}: {'ok' if s.ok else 'FAILED'} — {s.detail}" for s in report.steps] + OmiStore(omi_dir).create_note( + NoteFields( + title="Maintenance Report", + summary="Latest `omind maintain` run.", + details="\n".join(lines) or "no steps ran", + tags=["omi", "maintenance"], + ) + ) + + +def run( + omi_dir: Path | str, + *, + node_id: str = "", + apply: bool = False, + sync: bool = False, + rollup: bool = False, + report_note: bool = False, + log: Callable[[str], None] = print, +) -> MaintainReport: + """Run one maintenance pass. Safe by default: with no flags it proposes and + reports, changing nothing in the vault.""" + omi = Path(omi_dir).expanduser() + report = MaintainReport(apply=apply) + + with filelock.try_exclusive(paths.maintain_lock_path(omi)) as got_mutex: + if not got_mutex: + report.refused = "another `omind maintain` is already running for this vault" + log(report.refused) + return report + if _mesh_sync_in_flight(omi): + report.refused = "a mesh sync or vault write is in flight — try again shortly" + log(report.refused) + return report + report.ran = True + + def step(name: str, action: Callable[[], str]) -> None: + if report.aborted: + return + try: + detail = action() + report.steps.append(StepResult(name, True, detail)) + log(f"[maintain] {name}: {detail}") + except Exception as exc: # noqa: BLE001 — fail-closed: record and stop + report.steps.append(StepResult(name, False, f"aborted: {exc}")) + report.aborted = True + log(f"[maintain] {name} FAILED, aborting: {exc}") + + # Always propose-only, in every mode (propose-and-review is permanent). + step( + "propose-consolidations", + lambda: f"{len(consolidate.propose(omi))} merge review plan(s) proposed" + " (propose-only; never auto-applied)", + ) + # Scratch expiry runs in every mode: reports what would expire on a dry + # run, archives on --apply. Machine-local, so it is safe outside --sync. + step("expire-scratch", lambda: _expire_scratch(omi, apply=apply)) + if apply: + step("reindex", lambda: _reindex_detail(omi)) + if rollup: + step("rollup", lambda: f"{len(journal.rollup_journals(omi))} week(s) rolled up") + # The only fleet-propagating, irreversible step: opt-in, and last, so a + # failure above (report.aborted) keeps it from ever running. + if sync: + step("mesh-sync", lambda: _sync_detail(omi, node_id)) + + _persist(omi, report) + if report_note and not report.aborted: + with contextlib.suppress(Exception): + _write_report_note(omi, report) + return report diff --git a/src/omind/mesh.py b/src/omind/mesh.py index 82e591a..6149eb1 100644 --- a/src/omind/mesh.py +++ b/src/omind/mesh.py @@ -79,6 +79,7 @@ class MeshError(Exception): GITIGNORE = """\ .omi.lock .tmp-* +*.scratch.md .obsidian/workspace.json .obsidian/workspace-mobile.json .obsidian/workspace diff --git a/src/omind/paths.py b/src/omind/paths.py index b3378b1..c662379 100644 --- a/src/omind/paths.py +++ b/src/omind/paths.py @@ -125,3 +125,13 @@ def transaction_dir(omi_dir: Path) -> Path: doing, is meaningless on another peer, and must never be mesh-synced. """ return state_dir() / f"txn-{_omi_dir_digest(omi_dir)}" + + +def maintain_lock_path(omi_dir: Path) -> Path: + """Single-instance mutex for ``omind maintain`` (one janitor per vault).""" + return state_dir() / f"maintain-{_omi_dir_digest(omi_dir)}.lock" + + +def maintain_state_path(omi_dir: Path) -> Path: + """Last ``omind maintain`` run report — state-dir, never a vault note.""" + return state_dir() / f"maintain-{_omi_dir_digest(omi_dir)}.json" diff --git a/src/omind/server.py b/src/omind/server.py index b2e0458..3e353ef 100644 --- a/src/omind/server.py +++ b/src/omind/server.py @@ -364,7 +364,9 @@ def _resolve_agent(explicit: str | None) -> str: "confidence: high|medium|low, omit if unknown. conflicts_with: a " "[[wikilink]] to a memory this one DISAGREES with (use supersedes " "instead when this cleanly replaces the older fact). agent: your " - "self-declared identity (advisory attribution only)." + "self-declared identity (advisory attribution only). scratch: mark a " + "machine-local, auto-expiring note (never mesh-synced; archived after " + "7 idle days by `omind maintain`)." ), ) def create_note( @@ -382,6 +384,7 @@ def create_note( references: list[str] | None = None, agent: str = "", scope: str = "", + scratch: bool = False, ) -> dict[str, object]: # Scoped-write interlock (item #5): deny an out-of-scope write BEFORE it # lands. Raises (rendered as a tool error) in deny mode; returns a @@ -406,7 +409,9 @@ def create_note( references=references or [], scope=note_scope, ) - filename = store.create_note(fields) + # scratch=True marks this a machine-local, auto-expiring scratch note + # (item #5 part 2): never mesh-replicated, TTL-expired by `omind maintain`. + filename = store.create_note(fields, scratch=scratch) result: dict[str, object] = {"filename": filename, "agent": fields.agent} if scope_warning: result["scope_warning"] = scope_warning diff --git a/src/omind/store.py b/src/omind/store.py index 9b6c51a..8984ac6 100644 --- a/src/omind/store.py +++ b/src/omind/store.py @@ -43,6 +43,16 @@ # renames (see :func:`_atomic_write`) keep every read consistent. LOCK_FILENAME = ".omi.lock" +#: Scratch-tier marker (item #5 part 2). A note whose filename ends in this is +#: machine-local (mesh gitignores ``*.scratch.md``), still a top-level ``*.md`` +#: so search and listings find it, and TTL-expired by ``omind maintain``. +SCRATCH_SUFFIX = ".scratch.md" + + +def is_scratch(name: str | Path) -> bool: + """Whether ``name`` (a filename or path) is a scratch-tier note.""" + return str(name).endswith(SCRATCH_SUFFIX) + # index.md is the primary SessionStart priming payload (16k char cap in # omind.hooks), so the Recent Memories list is capped rather than unbounded. RECENT_LIMIT = 25 @@ -1040,7 +1050,7 @@ def _validated_name(self, name: str) -> Path: raise NoteError(f"note name escapes the OMI directory: {name!r}") return target - def filename_for_title(self, title: str) -> str: + def filename_for_title(self, title: str, *, suffix: str = ".md") -> str: cleaned = _ILLEGAL_FILENAME_CHARS.sub(" ", title).strip() cleaned = re.sub(r"\s+", " ", cleaned) # Strip leading dots so a title like ".NET notes" doesn't become an @@ -1049,13 +1059,13 @@ def filename_for_title(self, title: str) -> str: if not cleaned: raise NoteError("title produces an empty filename") # Truncate on a char boundary so the encoded filename stays under the - # OS byte limit (leaving room for the ".md" suffix). - budget = _MAX_FILENAME_BYTES - len(".md") + # OS byte limit (leaving room for the suffix — ".md" or ".scratch.md"). + budget = _MAX_FILENAME_BYTES - len(suffix) while len(cleaned.encode("utf-8")) > budget: cleaned = cleaned[:-1].rstrip() if not cleaned: raise NoteError("title produces an empty filename") - return f"{cleaned}.md" + return f"{cleaned}{suffix}" # -- reads -------------------------------------------------------------- @@ -1419,12 +1429,16 @@ def _stamped(self, path: Path, content: str) -> str: current = incoming return _with_rev(content, str(next_rev(current, self.node_id))) - def create_note(self, fields: NoteFields) -> str: + def create_note(self, fields: NoteFields, *, scratch: bool = False) -> str: if not fields.title.strip(): raise NoteError("a note requires a title") if not fields.created: fields.created = today() - filename = self.filename_for_title(fields.title) + # Scratch tier (item #5 part 2): the ".scratch.md" suffix IS the mark — + # machine-local (mesh gitignores it), still a top-level *.md so search + # finds it, TTL-expired by `omind maintain`. See SCRATCH_SUFFIX. + suffix = SCRATCH_SUFFIX if scratch else ".md" + filename = self.filename_for_title(fields.title, suffix=suffix) _hoist_field_headings(fields) # canonicalize ## H2-in-body -> extras # Existence is re-checked under the write lock (must_create) to close the # concurrent-create race, not here. diff --git a/tests/test_maintain.py b/tests/test_maintain.py new file mode 100644 index 0000000..89d92f1 --- /dev/null +++ b/tests/test_maintain.py @@ -0,0 +1,117 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Aaron K. Clark +"""Tests for the sleep-time janitor (omind maintain), item #3.""" + +from __future__ import annotations + +import time +from pathlib import Path + +import pytest + +from omind import consolidate, filelock, maintain, paths +from omind.store import NoteFields, OmiStore + + +@pytest.fixture +def omi(tmp_path: Path) -> Path: + d = tmp_path / "OMI" + d.mkdir() + store = OmiStore(d) + store.create_note(NoteFields(title="One", summary="a note about widgets", tags=["x"])) + store.create_note(NoteFields(title="Two", summary="another note on gadgets", tags=["x"])) + return d + + +def test_default_run_is_a_dry_run_that_changes_nothing(omi: Path) -> None: + before = {p.name for p in omi.glob("*.md")} + report = maintain.run(omi, log=lambda _msg: None) + assert report.ran and not report.refused and not report.aborted + # Consolidation is proposed, never applied; scratch is only *reported* on a + # dry run; no reindex/rollup/sync step. + assert [s.name for s in report.steps] == ["propose-consolidations", "expire-scratch"] + assert {p.name for p in omi.glob("*.md")} == before # vault untouched + assert not (omi / "Maintenance Report.md").exists() # no vault report note + + +def test_apply_refreshes_the_index(omi: Path) -> None: + report = maintain.run(omi, apply=True, log=lambda _msg: None) + names = [s.name for s in report.steps] + assert "reindex" in names + assert all(s.ok for s in report.steps) + + +def test_a_second_janitor_is_refused(omi: Path) -> None: + # Hold the single-instance mutex, then a run must decline rather than double. + with filelock.try_exclusive(paths.maintain_lock_path(omi)) as got: + assert got + report = maintain.run(omi, log=lambda _msg: None) + assert not report.ran + assert "already running" in report.refused + + +def test_refuses_while_a_vault_write_or_sync_is_in_flight(omi: Path) -> None: + # Holding the vault write-lock stands in for an in-flight mesh sync. + with filelock.exclusive(omi / ".omi.lock"): + report = maintain.run(omi, log=lambda _msg: None) + assert not report.ran + assert "in flight" in report.refused + + +def test_pipeline_is_fail_closed_sync_never_runs_after_a_failure( + omi: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def boom(*_args: object, **_kwargs: object) -> list[object]: + raise RuntimeError("propose exploded") + + monkeypatch.setattr(consolidate, "propose", boom) + report = maintain.run(omi, sync=True, node_id="node-x", log=lambda _msg: None) + assert report.aborted + step_names = [s.name for s in report.steps] + assert "propose-consolidations" in step_names + # The fleet-propagating, irreversible sync must not run after the failure. + assert "mesh-sync" not in step_names + + +def test_apply_expires_stale_scratch_notes_by_archiving_them(omi: Path) -> None: + import os + + store = OmiStore(omi) + stale = store.create_note(NoteFields(title="Old Scratch", summary="temp"), scratch=True) + fresh = store.create_note(NoteFields(title="New Scratch", summary="temp"), scratch=True) + assert stale.endswith(".scratch.md") and fresh.endswith(".scratch.md") + old = time.time() - 8 * 86_400 # past the 7-day TTL, by last-modified time + os.utime(omi / stale, (old, old)) + + report = maintain.run(omi, apply=True, log=lambda _msg: None) + detail = next(s.detail for s in report.steps if s.name == "expire-scratch") + assert "1 scratch note(s) archived" in detail + # Expiry ARCHIVES (soft-delete), never deletes — both files still on disk. + assert (omi / stale).is_file() and (omi / fresh).is_file() + assert store.read_fields(stale).disabled # the stale one is archived + assert not store.read_fields(fresh).disabled # the fresh one is untouched + + +def test_dry_run_only_reports_scratch_expiry_never_archives(omi: Path) -> None: + import os + + store = OmiStore(omi) + stale = store.create_note(NoteFields(title="Old Scratch", summary="temp"), scratch=True) + old = time.time() - 8 * 86_400 + os.utime(omi / stale, (old, old)) + + report = maintain.run(omi, log=lambda _msg: None) # no --apply + detail = next(s.detail for s in report.steps if s.name == "expire-scratch") + assert "1 scratch note(s) would expire" in detail + assert not store.read_fields(stale).disabled # dry run changed nothing + + +def test_report_note_is_opt_in(omi: Path) -> None: + maintain.run(omi, report_note=True, log=lambda _msg: None) + assert (omi / "Maintenance Report.md").is_file() + + +def test_run_persists_a_state_file_outside_the_vault(omi: Path) -> None: + maintain.run(omi, log=lambda _msg: None) + assert paths.maintain_state_path(omi).is_file() + assert not (omi / "maintain.json").exists() # never inside the vault diff --git a/tests/test_store.py b/tests/test_store.py index a6a263d..9cc2c1b 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -154,6 +154,20 @@ def test_create_note_writes_file_and_index(store: OmiStore) -> None: assert seeds.INDEX_RECENT_HEADING in index +def test_scratch_note_gets_the_scratch_suffix_and_is_findable(store: OmiStore) -> None: + from omind import mesh + from omind.store import is_scratch + + name = store.create_note(NoteFields(title="Temp Buffer", summary="ephemeral"), scratch=True) + assert name == "Temp Buffer.scratch.md" # the suffix IS the mark + assert is_scratch(name) and not is_scratch("Temp Buffer.md") + assert (store.omi_dir / name).is_file() + # Still a top-level *.md, so search/listings find it like any other note... + assert name in [s.filename for s in store.list_notes()] + # ...but the mesh never replicates it. + assert "*.scratch.md" in mesh.GITIGNORE + + def test_list_excludes_reserved_files(store: OmiStore) -> None: (store.omi_dir / paths.MEMORY_TEMPLATE_FILENAME).write_text(seeds.MEMORY_TEMPLATE) (store.omi_dir / paths.INDEX_FILENAME).write_text("# index")