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
74 changes: 74 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [3.7.6] - 2026-07-01

Hardening release from an adversarial code review — data-integrity, guard
false-positives, enforcement fail-open holes, and crash/availability fixes. No
API breaks; `NoteFields` gains backward-compatible `frontmatter`/`lead` fields.

### Fixed

- **Note data loss:** the parse/render round-trip no longer drops YAML
frontmatter (Obsidian Properties) or lead prose before the first `##`; section
splitting is now fence-aware so a `##` inside a code block is body text, not a
new section. Applies to the local edit path and the mesh merge driver.
- **Mesh convergence:** equal-rev/different-content now resolves by a symmetric
content tiebreak, so `merge(A,B)` == `merge(B,A)` and the fleet converges
instead of ping-ponging; the index-description migration stamps a rev on mesh
nodes so it no longer creates equal-rev divergence.
- **Mesh replication stalls silently:** `.obsidian/workspace.json` (and friends)
are now gitignored so their per-machine churn can't abort every peer merge;
`omind doctor` surfaces recorded per-peer sync errors instead of always
reporting "ok"; a push timeout records the error and continues to the next
peer (and still writes sync state) instead of aborting the whole pass; network
git runs with `GIT_TERMINAL_PROMPT=0` + ssh `BatchMode` so a prompt can't hang.
- **One bad byte no longer downs the vault:** all note/index/log reads decode
with `errors="replace"` (and strip a BOM), and `search`/`backlinks` skip a note
deleted mid-scan — a single non-UTF-8 note can't break listing/search/writes.
- **Guard false-positive interruptions:**
- Freshness now recognizes `git -C <repo> fetch` and compound read forms
(`git fetch && git status`) — the exact remediation the block message tells
you to run — instead of only a bare `git fetch`.
- Forge/destructive seed rules (`gh repo delete`, `gh auth setup-git`, …) are
command-anchored, so a grep pattern or commit message no longer blocks.
- A bare `>` (as in `pytest 2>&1`) is no longer treated as a file-writing side
effect; only real stdout redirects count.
- The global-config-mutation gate resolves the path against `$HOME`, so a
project-local `<repo>/.claude/settings.json` is not treated as global config.
- Global-authorization detection is negation-aware ("don't change" no longer
authorizes) and covers more imperatives (fix/add/create/…).
- **Guard crash-hardening:** a learned rule whose regex fails to compile or
matches the empty string is rejected at load and skipped at match time, so one
bad rule can no longer brick every tool call on the machine. The command-
position anchor now covers shell keywords/wrappers (`then`, `exec`, `xargs`,
absolute paths) and `sudoedit`, closing sudo-rule bypasses.
- **Enforcement fail-open holes:** the guard adapter fails **closed** on an
unparseable event and accepts array-shaped `args`; a contentless
`list-notes`/`graph-*` call no longer clears the gate or auto-scores relevant;
the secret-output guard no longer treats `pass show X 2>/dev/null | head` as
safe (a real leak) and no longer false-blocks `pass` inside another word / a
grep pattern; the turn-reset clears pending-intent and git-freshness (freshness
is per-turn again, not per-session).
- **Cron/timer safety:** `checkpoint run` degrades cleanly instead of raising a
traceback into the timer; `install-timer` writes an absolute `ExecStart` (or
fails loudly) instead of a silently-broken unit; a malformed journal bullet or
tz-aware log timestamp no longer crashes a checkpoint, and boundary-minute
actions are no longer dropped from every window.
- **Self-update:** `OMIND_NO_UPDATE_CHECK` no longer disables an explicit
`omind self-update`; the install subprocess and the version check have
timeouts sized for a user-invoked update.
- **`omind lint` false failures:** a dated note series (daily Worklogs) is no
longer flagged as near-duplicates; links to archived or `Journal/`-subfolder
notes and `[[wikilinks]]` quoted in code fences are no longer broken-link
errors — so `lint --strict` passes on a healthy vault.
- **Config/hook write corruption:** every managed settings/config/hook/backup
write is now atomic (temp file + `os.replace` + directory fsync) so a crash
mid-write can't brick a harness config or the guard hook; store atomic writes
fsync the directory too.
- **Filenames:** the reserved-name check is case-insensitive (so a note titled
"Index" can't destroy `index.md` on a case-insensitive filesystem); dot-prefixed
and over-long titles raise a clean `NoteError` instead of creating an invisible
note or an `ENAMETOOLONG` crash; `create_note` closes a concurrent-create race.
- **Enforcement migrate hook:** no longer deletes a memory file on a fuzzy
filename match or a missing `name:` slug — it migrates (with a timeout and
permission-safe unlinks) before deleting, and leaves the file if migration
fails.

## [3.7.5] - 2026-07-01

### Changed
Expand Down
80 changes: 46 additions & 34 deletions extras/omi_enforce.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
3. If NOT found → migrate via `omind note` first
4. Delete the Claude memory file either way
"""
# Lazy annotations so the builtin-generic hints (dict[str, str]) don't need
# evaluation at import — this ships as a hook run under the host's system
# python3, whose version `requires-python` does not govern.
from __future__ import annotations

import contextlib
import glob
import pathlib
import re
Expand All @@ -20,15 +26,15 @@
CLAUDE_PROJECTS = HOME / ".claude/projects"


def parse_frontmatter(text: str) -> tuple[dict, str]:
def parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
"""Return (fields_dict, body_text). Handles simple key: value and nested metadata.type."""
if not text.startswith("---"):
return {}, text
parts = text.split("---", 2)
if len(parts) < 3:
return {}, text
fm_raw, body = parts[1], parts[2].strip()
fields: dict = {}
fields: dict[str, str] = {}
in_metadata = False
for line in fm_raw.splitlines():
if line.strip() == "metadata:":
Expand All @@ -51,25 +57,18 @@ def slug_to_title(slug: str) -> str:
return " ".join(w.capitalize() for w in re.split(r"[-_]", slug) if w)


def omi_exists(title: str, slug: str) -> bool:
"""True if the OMI vault already has a note covering this memory."""
def omi_exists(title: str) -> bool:
"""True only if the OMI vault already has a note with this EXACT filename.

A fuzzy "≥2 of 3 slug words appear in some filename" match used to declare a
memory already-covered and DELETE it without migrating its content — so
``docker-compose-tips`` was destroyed because a "Docker Compose Setup" note
existed. Exact match only: anything else is migrated before deletion.
"""
if not OMI_DIR.exists():
return False

# Exact filename match (omind derives filename directly from title)
safe_title = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", title).strip()
if (OMI_DIR / f"{safe_title}.md").exists():
return True

# Fuzzy: ≥2 of the first 3 meaningful words appear in some filename
words = [w.lower() for w in re.split(r"[-_]", slug) if len(w) > 3]
if len(words) >= 2:
for f in OMI_DIR.glob("*.md"):
fname = f.stem.lower()
if sum(1 for w in words[:3] if w in fname) >= 2:
return True

return False
return bool(safe_title) and (OMI_DIR / f"{safe_title}.md").exists()


def migrate(title: str, summary: str, body: str, mem_type: str) -> bool:
Expand All @@ -85,42 +84,55 @@ def migrate(title: str, summary: str, body: str, mem_type: str) -> bool:
"--summary", summary or title,
"--tags", tags,
]
result = subprocess.run(cmd, input=body, text=True, capture_output=True)
try:
# Timeout so a hung `omind note` (vault lock held by mesh sync, gpg
# pinentry, NFS stall) can't hang this PostToolUse hook — and therefore
# every agent turn — indefinitely.
result = subprocess.run(
cmd, input=body, text=True, capture_output=True, timeout=30
)
except (subprocess.SubprocessError, OSError):
return False
return result.returncode == 0


def _safe_unlink(path: pathlib.Path) -> None:
"""Delete a migrated memory file; a permission/read-only-FS error must not
crash a hook that fires on every tool call."""
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)


def main() -> None:
pattern = str(CLAUDE_PROJECTS / "*/memory/*.md")
for filepath in glob.glob(pattern):
path = pathlib.Path(filepath)

# Always nuke stale MEMORY.md index files
# Always nuke stale MEMORY.md index files (a generated pointer, no content)
if path.name == "MEMORY.md":
path.unlink(missing_ok=True)
_safe_unlink(path)
continue

try:
content = path.read_text(encoding="utf-8")
except Exception:
content = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue

fm, body = parse_frontmatter(content)
slug = fm.get("name", "").strip()
description = fm.get("description", "").strip()
mem_type = fm.get("type", "").strip()

if not slug:
path.unlink(missing_ok=True)
continue

title = slug_to_title(slug)
# A file with no `name:` slug still holds memory content — derive a title
# from its filename and MIGRATE it before deleting (never unlink blind).
title = slug_to_title(slug) if slug else slug_to_title(path.stem)

if omi_exists(title, slug):
path.unlink(missing_ok=True)
else:
if migrate(title, description, body, mem_type):
path.unlink(missing_ok=True)
# If migration fails, leave the file — don't lose data
if omi_exists(title):
_safe_unlink(path) # content already in OMI under this exact title
elif migrate(title, description, body, mem_type):
_safe_unlink(path)
# If migration fails (or omind is unavailable), LEAVE the file — the whole
# point is to never lose memory content.


if __name__ == "__main__":
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "omind"
version = "3.7.5"
version = "3.7.6"
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"
Expand Down
2 changes: 1 addition & 1 deletion src/omind/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
# Copyright 2026 Aaron K. Clark
"""omind — OMI/Obsidian memory tooling for AI agents."""

__version__ = "3.7.5"
__version__ = "3.7.6"
76 changes: 44 additions & 32 deletions src/omind/_omi_enforce.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
3. If NOT found → migrate via `omind note` first
4. Delete the Claude memory file either way
"""
# Lazy annotations so the builtin-generic hints (dict[str, str]) don't need
# evaluation at import — this ships as a hook run under the host's system
# python3, whose version `requires-python` does not govern.
from __future__ import annotations

import contextlib
import glob
import pathlib
import re
Expand Down Expand Up @@ -51,25 +57,18 @@ def slug_to_title(slug: str) -> str:
return " ".join(w.capitalize() for w in re.split(r"[-_]", slug) if w)


def omi_exists(title: str, slug: str) -> bool:
"""True if the OMI vault already has a note covering this memory."""
def omi_exists(title: str) -> bool:
"""True only if the OMI vault already has a note with this EXACT filename.

A fuzzy "≥2 of 3 slug words appear in some filename" match used to declare a
memory already-covered and DELETE it without migrating its content — so
``docker-compose-tips`` was destroyed because a "Docker Compose Setup" note
existed. Exact match only: anything else is migrated before deletion.
"""
if not OMI_DIR.exists():
return False

# Exact filename match (omind derives filename directly from title)
safe_title = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", title).strip()
if (OMI_DIR / f"{safe_title}.md").exists():
return True

# Fuzzy: ≥2 of the first 3 meaningful words appear in some filename
words = [w.lower() for w in re.split(r"[-_]", slug) if len(w) > 3]
if len(words) >= 2:
for f in OMI_DIR.glob("*.md"):
fname = f.stem.lower()
if sum(1 for w in words[:3] if w in fname) >= 2:
return True

return False
return bool(safe_title) and (OMI_DIR / f"{safe_title}.md").exists()


def migrate(title: str, summary: str, body: str, mem_type: str) -> bool:
Expand All @@ -85,42 +84,55 @@ def migrate(title: str, summary: str, body: str, mem_type: str) -> bool:
"--summary", summary or title,
"--tags", tags,
]
result = subprocess.run(cmd, input=body, text=True, capture_output=True)
try:
# Timeout so a hung `omind note` (vault lock held by mesh sync, gpg
# pinentry, NFS stall) can't hang this PostToolUse hook — and therefore
# every agent turn — indefinitely.
result = subprocess.run(
cmd, input=body, text=True, capture_output=True, timeout=30
)
except (subprocess.SubprocessError, OSError):
return False
return result.returncode == 0


def _safe_unlink(path: pathlib.Path) -> None:
"""Delete a migrated memory file; a permission/read-only-FS error must not
crash a hook that fires on every tool call."""
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)


def main() -> None:
pattern = str(CLAUDE_PROJECTS / "*/memory/*.md")
for filepath in glob.glob(pattern):
path = pathlib.Path(filepath)

# Always nuke stale MEMORY.md index files
# Always nuke stale MEMORY.md index files (a generated pointer, no content)
if path.name == "MEMORY.md":
path.unlink(missing_ok=True)
_safe_unlink(path)
continue

try:
content = path.read_text(encoding="utf-8")
except Exception:
content = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue

fm, body = parse_frontmatter(content)
slug = fm.get("name", "").strip()
description = fm.get("description", "").strip()
mem_type = fm.get("type", "").strip()

if not slug:
path.unlink(missing_ok=True)
continue

title = slug_to_title(slug)
# A file with no `name:` slug still holds memory content — derive a title
# from its filename and MIGRATE it before deleting (never unlink blind).
title = slug_to_title(slug) if slug else slug_to_title(path.stem)

if omi_exists(title, slug):
path.unlink(missing_ok=True)
else:
if migrate(title, description, body, mem_type):
path.unlink(missing_ok=True)
# If migration fails, leave the file — don't lose data
if omi_exists(title):
_safe_unlink(path) # content already in OMI under this exact title
elif migrate(title, description, body, mem_type):
_safe_unlink(path)
# If migration fails (or omind is unavailable), LEAVE the file — the whole
# point is to never lose memory content.


if __name__ == "__main__":
Expand Down
Loading