diff --git a/BACKLOG.md b/BACKLOG.md index 43bf06a..df583fa 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -82,6 +82,13 @@ _Closed as not planned or rejected. Kept for the record; nothing here is pending ## Done +### Shipped — 2026-10-14 (v10.3.2) + +- [x] **guard: git-rules gate forces a full re-read of an unchanged note every turn** ([#478](https://github.com/CryptoJones/omind/issues/478)) — **v10.3.2** — _bug_ — + one full read per session per note version: the read is remembered with the note's version + token and carries across turns until the note changes, the context is compacted or cleared, or + a new session starts. Truncated, section-only and offset/limit reads still never count. + ### Shipped — 2026-10-09 (v10.3.1) - [x] **Grok Claude-hook fast path does not count a `use_tool` recall as a consult** ([#472](https://github.com/CryptoJones/omind/issues/472)) — **v10.3.1** — _bug_ — diff --git a/CHANGELOG.md b/CHANGELOG.md index 955f37d..355c418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [10.3.2] - 2026-10-14 + +### Fixed +- **The git-rules gate asks for one full read per session, not one per turn + ([#478](https://github.com/CryptoJones/omind/issues/478)).** The demanded + `read-note` of "Operational Rules - Git Repos and Secrets" (~11.5k chars, + ~2.9k tokens) reset on every user message, so one session re-read the same + unchanged note 8 times (~23k tokens). A full read is now also recorded for + the session (`gitrules-ack-.json` in the state dir) with the note's + version token (`read-note`'s `version`, or the on-disk version for a read + that carries none). Later turns pass the gate while the vault still holds + that version. A changed note, a new session, or a SessionStart with + `source=compact|clear` (the note text has left the context) needs a new + read; `resume` keeps it. `omind guard reset` with no session reaps the + record. Only a read of the note itself is remembered, not of a note whose + name merely contains its title. The block message says one full read per + session is enough. +- **A section-only or offset/limit read of the git-rules note no longer counts + as full because it asked for 65536 chars.** The hard-cap waiver was meant + for truncation of a note larger than any read; it now covers only that. + ## [10.3.1] - 2026-10-09 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index c5cb632..e0291f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "omind" -version = "10.3.1" +version = "10.3.2" 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 452f359..58a6de8 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__ = "10.3.1" +__version__ = "10.3.2" diff --git a/src/omind/guard.py b/src/omind/guard.py index bb22c10..6e80900 100644 --- a/src/omind/guard.py +++ b/src/omind/guard.py @@ -157,9 +157,11 @@ GIT_RULES_MESSAGE = ( "ACTION BLOCKED. Next call OMI MCP `read-note` with " '`{"name":"Operational Rules - Git Repos and Secrets","representation":"raw",' - '"max_chars":65536}`, then retry. Repo work requires that specific memory this ' - "turn — read it in full: a truncated or section-only read does not clear this " - "gate (recall-note stops at 8000 chars, so it cannot return a longer note whole)." + '"max_chars":65536}`, then retry. Repo work requires that specific memory — read ' + "it in full: a truncated or section-only read does not clear this gate " + "(recall-note stops at 8000 chars, so it cannot return a longer note whole). One " + "full read per session is enough: it stays valid until the note changes or the " + "context is compacted or cleared." ) #: Value of the per-turn demanded-note marker once the git-rules note is known #: to be absent (#358). Never a substring of a real consult target, so the @@ -402,6 +404,68 @@ def incomplete_consult(session: str) -> str: return "" if text.startswith(_FULL_MARK) else text +def _git_rules_ack_path(session: str) -> Path: + """The git-rules note version this SESSION has read in full (#478). + + Unlike the per-turn files above, :func:`begin_turn` and the bash turn-start + reset leave it alone: one full read per session per note version is + enough, so an unchanged note is not re-read every turn. A changed note, a + new session, or a compaction/``/clear`` (``hooks`` clears it on SessionStart) + needs a new read.""" + return paths.state_dir() / f"gitrules-ack-{_safe_sid(session)}.json" + + +def git_rules_note_version(omi_dir: Path | str) -> str: + """The git-rules note's current version token (``OmiStore.note_version``, + the token ``read-note`` returns), or ``""`` when it is absent or unreadable. + Never raises.""" + try: + from omind.store import OmiStore + + return OmiStore(Path(omi_dir)).note_version(GIT_RULES_NOTE) + except Exception: + return "" + + +def record_git_rules_ack(session: str, version: str) -> None: + """Remember that ``session`` read the git-rules note in full at ``version`` + (#478). A blank session or version records nothing. Best-effort, never + raises.""" + if not session or not version: + return + with contextlib.suppress(OSError): + path = _git_rules_ack_path(session) + path.parent.mkdir(parents=True, exist_ok=True) + with filelock.exclusive(_sibling_lock(path)): + payload = json.dumps({"note": GIT_RULES_NOTE.lower(), "version": version}) + paths.atomic_write_text(path, payload, mode=0o600) + + +def clear_git_rules_ack(session: str) -> None: + """Forget this session's full read of the git-rules note. Never raises.""" + with contextlib.suppress(OSError): + path = _git_rules_ack_path(session) + with filelock.exclusive(_sibling_lock(path)): + path.unlink() + + +def git_rules_acked(session: str, omi_dir: Path | str | None) -> bool: + """True when ``session`` already read the git-rules note in full at the + version the vault holds now (#478). Strict on any doubt: no vault, no + record, an absent note or a different version all answer ``False``, and + the gate falls back to demanding the read. Never raises.""" + if not session or omi_dir is None: + return False + try: + data = json.loads(_git_rules_ack_path(session).read_text(encoding="utf-8")) + except (OSError, ValueError): + return False + if not isinstance(data, dict) or data.get("note") != GIT_RULES_NOTE.lower(): + return False + recorded = str(data.get("version") or "") + return bool(recorded) and recorded == git_rules_note_version(omi_dir) + + #: Tool-name prefixes of the OMI MCP server across harness spellings (kept in #: sync with ``adapters._OMI_CONSULT_PREFIXES``; not imported, adapters imports #: this module). @@ -1298,6 +1362,7 @@ def clear_all_gates() -> None: "pending-*", "offtopic-*", "git-fresh-*", + "gitrules-ack-*", "turn-*", "injected-*", "namehints-*", @@ -2672,8 +2737,13 @@ def _repo_has_remote(repo: Path) -> bool: return bool(re.search(r"(?m)^[ \t]*\[remote[ \t]", text)) -def _has_consulted_git_rules(session: str) -> bool: +def _has_consulted_git_rules(session: str, omi_dir: Path | str | None = None) -> bool: needle = GIT_RULES_NOTE.lower() + # #478: a full read earlier in this session, of the version the vault still + # holds, carries across turns. Needs the vault to compare versions, so a + # caller without one gets the per-turn rule below. + if git_rules_acked(session, omi_dir): + return True for consult in consults(session): if consult.get("failed"): # The read errored (not-found, locked vault…): the agent has read @@ -4661,11 +4731,19 @@ def _judge_hard_rules(command: str, session: str) -> Verdict | None: return None -def decide(action: dict[str, Any], *, git_rules_missing: bool = False) -> Verdict: +def decide( + action: dict[str, Any], + *, + git_rules_missing: bool = False, + omi_dir: Path | str | None = None, +) -> Verdict: """The harness-agnostic policy. See the module docstring for the schema. ``git_rules_missing`` (#358) waives ONLY the git-rules read demand, for a vault that positively lacks the note; every other gate still runs. + ``omi_dir`` (#478) lets the git-rules gate honour a full read made earlier + in the session of the note's current version; without it the read is + demanded once per turn, as before. Judging runs under the action's wall-clock deadline (#460, see :mod:`omind.deadline`), so a verdict comes back before the hook times @@ -4684,14 +4762,20 @@ def decide(action: dict[str, Any], *, git_rules_missing: bool = False) -> Verdic with deadline.scope(): try: - return _decide(action, session, command, git_rules_missing) + return _decide(action, session, command, git_rules_missing, omi_dir) except deadline.DeadlineExceededError: return _deadline_deny( command or _action_path(action), session, str(action.get("tool") or "") ) -def _decide(action: dict[str, Any], session: str, command: str, git_rules_missing: bool) -> Verdict: +def _decide( + action: dict[str, Any], + session: str, + command: str, + git_rules_missing: bool, + omi_dir: Path | str | None = None, +) -> Verdict: """:func:`decide` inside its deadline scope. Raises :class:`deadline.DeadlineExceededError` when the budget runs out.""" # 1) Consulting OMI sets the per-turn sentinel and is always allowed. When @@ -4716,11 +4800,15 @@ def _decide(action: dict[str, Any], session: str, command: str, git_rules_missin return hard deadline.check() - return _soft_verdict(action, session, command, git_rules_missing) + return _soft_verdict(action, session, command, git_rules_missing, omi_dir) def _soft_verdict( - action: dict[str, Any], session: str, command: str, git_rules_missing: bool + action: dict[str, Any], + session: str, + command: str, + git_rules_missing: bool, + omi_dir: Path | str | None = None, ) -> Verdict: """:func:`decide` past the hard rules: the repo, capability, global-config and consult gates. Raises :class:`deadline.DeadlineExceededError` when the @@ -4776,7 +4864,7 @@ def _soft_verdict( if gate_repo is None and str(action.get("tool") or "") == "Bash": gate_repo = _bash_write_repo(action) if gate_repo is not None and _is_repo_sensitive_action(action): - if not git_rules_missing and not _has_consulted_git_rules(session): + if not git_rules_missing and not _has_consulted_git_rules(session, omi_dir): record_pending(session, command or _action_path(action)) # Name the demanded note so the verifier credits the obeying read # as relevant instead of re-closing the gate over it (#148). @@ -5032,7 +5120,7 @@ def _check_action(action: dict[str, Any], omi_dir: Path | None) -> Verdict: # marker waives the demand for the rest of it — one log line per turn, # not one per tool call. known_missing = demanded_note(session) == _GIT_RULES_MISSING_MARK - verdict = decide(action, git_rules_missing=known_missing) + verdict = decide(action, git_rules_missing=known_missing, omi_dir=omi_dir) if ( not verdict.allow and verdict.rule_id == "repo-work-read-git-rules" @@ -5055,7 +5143,7 @@ def _check_action(action: dict[str, Any], omi_dir: Path | None) -> Verdict: detail=f"missing note: {GIT_RULES_NOTE}", ) print(GIT_RULES_MISSING_MESSAGE, file=sys.stderr) - verdict = decide(action, git_rules_missing=True) + verdict = decide(action, git_rules_missing=True, omi_dir=omi_dir) # The verdict is reached: what follows (the budget re-arm, the deny's # note excerpt and retrieval suggestion, the log) has its own bounds, # and a deadline there is not a judging failure (#460 review). diff --git a/src/omind/hooks.py b/src/omind/hooks.py index 8de2f6b..f1e6d72 100644 --- a/src/omind/hooks.py +++ b/src/omind/hooks.py @@ -934,6 +934,13 @@ def run_hook( # and compact (the summary dropped it) always prime, and mark. source = str(event.get("source") or "").strip().lower() session = str(event.get("session_id") or "") + if session and source in ("compact", "clear"): + # #478: the session's full read of the git-rules note carries + # across turns only while its text is still in context. A + # compaction summary or a /clear drops it, so demand it again. + from omind import guard + + guard.clear_git_rules_ack(session) primed_before = _already_primed(session) if session else False if source == "resume" and primed_before: return 0 diff --git a/src/omind/verify.py b/src/omind/verify.py index dba7f6d..e307752 100644 --- a/src/omind/verify.py +++ b/src/omind/verify.py @@ -180,7 +180,54 @@ def _is_git_rules_read(event: dict[str, Any], target: str) -> bool: ) -def _update_git_rules_completeness(event: dict[str, Any], session: str, out: Any = None) -> None: +def _response_version(value: Any, depth: int = 0) -> str: + """The ``version`` token a read-note result carries, at any depth (inside + MCP text blocks whose text is JSON too), or ``""``. Never raises.""" + if depth > _TRUNCATION_WALK_DEPTH: + return "" + if isinstance(value, dict): + version = value.get("version") + if isinstance(version, str) and version.strip(): + return version.strip() + for v in value.values(): + found = _response_version(v, depth + 1) + if found: + return found + return "" + if isinstance(value, list): + for v in value: + found = _response_version(v, depth + 1) + if found: + return found + return "" + if isinstance(value, str): + stripped = value.strip() + if stripped[:1] in ("{", "["): + try: + return _response_version(json.loads(stripped), depth + 1) + except ValueError: + return "" + return "" + + +def _is_exact_git_rules_target(target: str) -> bool: + """True when ``target`` names the git-rules note itself (its bare name or + a path whose filename is it), not a note that merely contains its title, + such as ``... Secrets Backup.md`` (#478). Only an exact read may carry the + session acknowledgement.""" + name = target.strip().replace("\\", "/").rsplit("/", 1)[-1] + if name.lower().endswith(".md"): + name = name[:-3] + return name.strip().casefold() == guard.GIT_RULES_NOTE.casefold() + + +def _update_git_rules_completeness( + event: dict[str, Any], + session: str, + out: Any = None, + omi_dir: Path | str | None = None, + target: str = "", +) -> None: """Record whether a read of the git-rules note was complete (#239, #392). The recurrence log on the git-rules note records three real violations @@ -201,6 +248,11 @@ def _update_git_rules_completeness(event: dict[str, Any], session: str, out: Any credited, so the gate never fired. Once a full read lands, a later partial read this turn does not re-arm the gate. Best-effort: any parsing surprise counts the read as complete (fail open). + + #478: a full read is also remembered for the rest of the SESSION, keyed + to the note version it returned (``read-note``'s ``version``; the on-disk + version for a read that carries none), so a later turn need not re-read + an unchanged note. The fail-open branch records no such carry-over. """ try: from omind import recall @@ -220,8 +272,11 @@ def _update_git_rules_completeness(event: dict[str, Any], session: str, out: Any except (TypeError, ValueError): asked = default best_possible = is_read_note and asked >= recall.READ_NOTE_HARD_CAP - partial = bool(section) or native_partial or _response_truncated(event.get("tool_response")) - if partial and not best_possible: + # The hard-cap waiver covers truncation only: a section drill-down or an + # offset/limit Read is partial however large ``max_chars`` is (#478). + truncated = _response_truncated(event.get("tool_response")) and not best_possible + partial = bool(section) or native_partial or truncated + if partial: if not guard.record_partial_consult(session, note): return # already read in full this turn; a drill-down adds to it if section: @@ -241,6 +296,11 @@ def _update_git_rules_completeness(event: dict[str, Any], session: str, out: Any ) else: guard.record_full_consult(session, note) + if omi_dir is not None and _is_exact_git_rules_target(target): + version = _response_version( + event.get("tool_response") + ) or guard.git_rules_note_version(omi_dir) + guard.record_git_rules_ack(session, version) except Exception: guard.clear_incomplete_consult(session) @@ -626,7 +686,7 @@ def verify_consult( guard.reset_offtopic(session) return "relevant" if kind == "read" and _is_git_rules_read(event, target): - _update_git_rules_completeness(event, session, out=out) + _update_git_rules_completeness(event, session, out=out, omi_dir=omi_dir, target=target) if ( _always_relevant(target) or _guard_demanded(session, target) diff --git a/tests/test_guard.py b/tests/test_guard.py index adc255d..ead5cff 100644 --- a/tests/test_guard.py +++ b/tests/test_guard.py @@ -6034,3 +6034,196 @@ def test_the_issue_460_shapes_are_judged_well_inside_the_budget(tmp_path: Path, verdict = judge() assert verdict.rule_id != guard.DEADLINE_RULE assert _deadline_events() == [] + + +# -- #478: one full read of the git-rules note per session per note version --- + + +def _rules_vault(tmp_path: Path) -> Path: + from omind.store import NoteFields, OmiStore + + omi = tmp_path / "OMI" + omi.mkdir() + OmiStore(omi).create_note(NoteFields(title=guard.GIT_RULES_NOTE, summary="rules v1")) + return omi + + +def _new_turn(session: str) -> None: + """What a user message does: the turn-start reset clears every per-turn + file. The ordinary consult gate is not under test, so satisfy it.""" + guard.clear_gate(session) + guard.begin_turn(session, "run the tests") + guard.mark_consulted(session) + + +def _full_read(session: str, omi: Path, **tool_input: Any) -> None: + from omind import verify + + version = guard.git_rules_note_version(omi) + result = {"filename": f"{guard.GIT_RULES_NOTE}.md", "version": version, "raw": "all rules"} + verify.verify_consult( + { + "session_id": session, + "tool_name": "mcp__omi__read-note", + "tool_input": { + "name": guard.GIT_RULES_NOTE, + "representation": "raw", + "max_chars": 65536, + **tool_input, + }, + "tool_response": {"content": [{"type": "text", "text": json.dumps(result)}]}, + }, + omi, + require=True, + out=io.StringIO(), + ) + + +def _repo_verdict(session: str, omi: Path) -> guard.Verdict: + return guard.check_action({"tool": "Bash", "command": "pytest", "session": session}, omi) + + +def test_full_read_clears_and_carries_to_the_next_turn(tmp_path: Path) -> None: + omi = _rules_vault(tmp_path) + session = "ack478-carry" + _new_turn(session) + assert _repo_verdict(session, omi).rule_id == "repo-work-read-git-rules" + _full_read(session, omi) + assert _repo_verdict(session, omi).allow # the first read clears + + # Next user message, same note version: no re-read demanded. + _new_turn(session) + assert _repo_verdict(session, omi).allow + _new_turn(session) + assert guard.decide( + {"tool": "Bash", "command": "pytest", "session": session}, omi_dir=omi + ).allow + # Without the vault the version cannot be compared: per-turn rule, strict. + assert not guard._has_consulted_git_rules(session) + guard.clear_gate(session) + guard.clear_git_rules_ack(session) + + +def test_changed_note_requires_a_re_read(tmp_path: Path) -> None: + from omind.store import OmiStore + + omi = _rules_vault(tmp_path) + session = "ack478-changed" + _new_turn(session) + _full_read(session, omi) + assert _repo_verdict(session, omi).allow + + store = OmiStore(omi) + fields = store.read_fields(guard.GIT_RULES_NOTE) + fields.summary = "rules v2" + store.update_note(guard.GIT_RULES_NOTE, fields) + _new_turn(session) + assert _repo_verdict(session, omi).rule_id == "repo-work-read-git-rules" + _full_read(session, omi) + assert _repo_verdict(session, omi).allow + guard.clear_gate(session) + guard.clear_git_rules_ack(session) + + +def test_truncated_or_section_read_records_no_session_ack(tmp_path: Path) -> None: + from omind import verify + + omi = _rules_vault(tmp_path) + session = "ack478-trunc" + _new_turn(session) + verify.verify_consult( + { + "session_id": session, + "tool_name": "mcp__omi__read-note", + "tool_input": {"name": guard.GIT_RULES_NOTE, "max_chars": 200}, + "tool_response": { + "content": [ + { + "type": "text", + "text": json.dumps( + { + "version": guard.git_rules_note_version(omi), + "raw": "x\n\n[truncated: showing 200 of 13000 chars]", + } + ), + } + ] + }, + }, + omi, + require=True, + out=io.StringIO(), + ) + assert _repo_verdict(session, omi).rule_id == "repo-work-read-git-rules" + assert not guard.git_rules_acked(session, omi) + + _full_read(session, omi, section="Secrets") # section-only: not a full read + assert not guard.git_rules_acked(session, omi) + _new_turn(session) + assert _repo_verdict(session, omi).rule_id == "repo-work-read-git-rules" + guard.clear_gate(session) + + +def test_new_session_or_compaction_requires_a_read(tmp_path: Path) -> None: + from omind import hooks + + omi = _rules_vault(tmp_path) + _new_turn("ack478-a") + _full_read("ack478-a", omi) + assert _repo_verdict("ack478-a", omi).allow + + # A different session has read nothing. + _new_turn("ack478-b") + assert _repo_verdict("ack478-b", omi).rule_id == "repo-work-read-git-rules" + + # A resume keeps the read (the transcript still holds it); a compaction drops it. + for source, kept in (("resume", True), ("compact", False)): + event = {"hook_event_name": "SessionStart", "source": source, "session_id": "ack478-a"} + stdin = io.StringIO(json.dumps(event)) + hooks.run_hook("SessionStart", omi, stdin=stdin, stdout=io.StringIO()) + _new_turn("ack478-a") + assert _repo_verdict("ack478-a", omi).allow is kept + guard.clear_gate("ack478-a") + guard.clear_gate("ack478-b") + + +def test_a_read_of_a_lookalike_note_records_no_session_ack(tmp_path: Path) -> None: + """A native Read of `` Backup.md`` matches the title as a substring + and carries no version; it must not acknowledge the real note (#478).""" + from omind import verify + + omi = _rules_vault(tmp_path) + lookalike = omi / f"{guard.GIT_RULES_NOTE} Backup.md" + lookalike.write_text("old rules", encoding="utf-8") + session = "ack478-lookalike" + _new_turn(session) + verify.verify_consult( + { + "session_id": session, + "tool_name": "Read", + "tool_input": {"file_path": str(lookalike)}, + "tool_response": {"type": "text", "file": {"content": "old rules"}}, + }, + omi, + require=True, + out=io.StringIO(), + ) + assert not guard.git_rules_acked(session, omi) + assert verify._is_exact_git_rules_target(str(omi / f"{guard.GIT_RULES_NOTE}.md")) + assert verify._is_exact_git_rules_target(guard.GIT_RULES_NOTE) + guard.clear_gate(session) + + +def test_by_hand_reset_reaps_session_acks(tmp_path: Path) -> None: + omi = _rules_vault(tmp_path) + session = "ack478-reap" + _new_turn(session) + _full_read(session, omi) + assert guard.git_rules_acked(session, omi) + guard.clear_all_gates() + assert not guard.git_rules_acked(session, omi) + + +def test_git_rules_message_says_one_read_per_session() -> None: + assert "One full read per session" in guard.GIT_RULES_MESSAGE + assert "truncated or section-only read does not clear" in guard.GIT_RULES_MESSAGE diff --git a/uv.lock b/uv.lock index 62e0bef..3600e53 100644 --- a/uv.lock +++ b/uv.lock @@ -2364,7 +2364,7 @@ wheels = [ [[package]] name = "omind" -version = "10.3.1" +version = "10.3.2" source = { editable = "." } dependencies = [ { name = "cryptography" },