Skip to content

Commit 8fa8d14

Browse files
Aaron Kingsley Clarkclaude
andcommitted
feat: clear backlog — verifier activity-blend (#95) + Gemini & OpenClaw guards (#90, #88) (#75)
Implements GitHub backlog #95/#90/#88 + regression tests; ships v2.44.0. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent ef8754b commit 8fa8d14

13 files changed

Lines changed: 752 additions & 28 deletions

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.44.0] - 2026-06-22
11+
12+
### Added
13+
14+
- **Cross-harness: Google Gemini CLI guard (#90).** `omind setup --agent gemini`
15+
now wires the OMI guard into the Gemini CLI via its `BeforeTool` hook (the
16+
PreToolUse analog) under the `hooks` key in `~/.gemini/settings.json`, matching
17+
every tool (`matcher: ".*"`). On a deny the `gemini` adapter emits Gemini's
18+
`{"decision":"deny","reason":…}` shape on stdout (exit 0), which the CLI
19+
enforces as a hard tool block. `omind doctor --agent gemini` reports the guard
20+
wiring; the consult-gate recognizes Gemini's single-underscore MCP tool names
21+
(`mcp_<server>_<tool>`). Guard-only — MCP-memory registration is a separate
22+
concern, intentionally not bundled.
23+
- **Cross-harness: OpenClaw guard (#88), detect-only.** `OpenClawProvisioner` now
24+
installs a gateway hook (`omind guard adapter --harness openclaw`) into
25+
`openclaw.json`; the adapter renders an `{allow,reason,rule_id}` verdict the
26+
POST `/hooks/agent` gateway reads. Because OpenClaw's deny-enforcement could not
27+
be verified against a live gateway, the harness is registered **detect-only**
28+
(the verdict is advisory, exit 0) until hard-block is proven — then it can be
29+
promoted to `hard-block` in a follow-up. `omind doctor --agent openclaw` now
30+
also reports the guard hook.
31+
32+
### Changed
33+
34+
- **Verifier scores relevance against what the agent is *doing*, not only the last
35+
user prompt (#95).** The verifier captured `turn_task` from the most recent user
36+
message and scored every consult against it, so when the user delegated
37+
background/parallel work ("build X *while* I do Y") the agent's genuinely
38+
on-topic consults scored off-topic against the stale user line and (under
39+
`OMI_VERIFY_REQUIRE=1`) re-closed the gate. The verifier now also blends in the
40+
agent's recent **non-OMI** journal activity for the same session and judges a
41+
consult relevant if it overlaps *either* signal (`max` of the two overlaps, so
42+
neither dilutes the other). Prior OMI consults are excluded from the activity
43+
blob so an agent can't bootstrap relevance by reading an arbitrary note. This is
44+
a distinct root cause from the 2.43.2 stemming fix (topic mismatch, not word-form
45+
mismatch); the activity window is tunable via `OMI_VERIFY_ACTIVITY` (default 8
46+
bullets). `omind guard verify --explain` now reports `task_score` and
47+
`activity_score` separately.
48+
1049
## [2.43.2] - 2026-06-22
1150

1251
### Fixed

README.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -374,13 +374,17 @@ treatment:
374374
the same `omind hook` payload (recent-memory index + latest session state)
375375
Claude Code injects through its `SessionStart` hook.
376376

377-
**Codex CLI** is wired **guard-only** — just the `~/.codex/hooks.json` hard-block
378-
hooks described above; its MCP-memory registration, skill, and priming are a
379-
separate follow-up. **OpenCode** priming is likewise not wired yet (its MCP server
380-
and skill are). The cross-harness **guard** itself reaches the most agents: Claude
381-
Code, Hermes, OpenCode, and Codex (OpenClaw's guard is still on the backlog).
382-
383-
`omind doctor --agent hermes|openclaw|opencode|codex` diagnoses that agent's
377+
**Codex CLI** and the **Gemini CLI** are wired **guard-only** — just the hard-block
378+
hooks described above (`~/.codex/hooks.json` for Codex; the `BeforeTool` hook under
379+
`hooks` in `~/.gemini/settings.json` for Gemini); their MCP-memory registration,
380+
skill, and priming are a separate follow-up. **OpenCode** priming is likewise not
381+
wired yet (its MCP server and skill are). The cross-harness **guard** reaches Claude
382+
Code, Hermes, OpenCode, Codex, and Gemini as hard-block; **OpenClaw** is wired
383+
**detect-only** — its POST `/hooks/agent` gateway receives the guard verdict but
384+
deny-enforcement is unverified against a live gateway, so the verdict is advisory
385+
until hard-block is proven.
386+
387+
`omind doctor --agent hermes|openclaw|opencode|codex|gemini` diagnoses that agent's
384388
wiring, and `omind quickstart --agent <name>` prints the manual steps (YAML/JSON
385389
snippets personalized to your paths) if you'd rather merge them in yourself.
386390

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "omind"
3-
version = "2.43.2"
3+
version = "2.44.0"
44
description = "Reproduce the OMI/Obsidian memory integration for AI agents, plus a local web app to view, edit, and add memory entries."
55
readme = "README.md"
66
requires-python = ">=3.10"

src/omind/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
# Copyright 2026 Aaron K. Clark
33
"""omind — OMI/Obsidian memory tooling for AI agents."""
44

5-
__version__ = "2.43.2"
5+
__version__ = "2.44.0"

src/omind/adapters.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@
2727

2828
from omind import guard
2929

30-
#: Tool-name prefixes that denote an OMI consult across harnesses (the omind MCP
31-
#: server is registered under the same tool namespace everywhere).
32-
_OMI_CONSULT_PREFIXES = ("mcp__omi__",)
30+
#: Tool-name prefixes that denote an OMI consult across harnesses. Most harnesses
31+
#: namespace MCP tools as ``mcp__<server>__<tool>`` (double underscore); the Gemini
32+
#: CLI uses ``mcp_<server>_<tool>`` (single underscore), so both forms are listed.
33+
_OMI_CONSULT_PREFIXES = ("mcp__omi__", "mcp_omi_")
3334

3435

3536
def _first_str(data: dict[str, Any], keys: tuple[str, ...]) -> str:

src/omind/agents.py

Lines changed: 220 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,25 @@ def codex_hooks_path() -> Path:
143143
CODEX_GUARD_MARKER = "guard adapter --harness codex"
144144

145145

146+
def gemini_config_dir() -> Path:
147+
"""Gemini CLI's config directory: ``$GEMINI_HOME`` or ``~/.gemini``."""
148+
base = os.environ.get("GEMINI_HOME")
149+
return Path(base) if base else Path.home() / ".gemini"
150+
151+
152+
def gemini_settings_path() -> Path:
153+
"""Gemini CLI's settings file (``hooks`` + ``mcpServers`` both live here)."""
154+
return gemini_config_dir() / "settings.json"
155+
156+
157+
#: Substring identifying omind's own Gemini guard hook command inside the user's
158+
#: settings.json, so a re-run replaces only our entry and never duplicates it.
159+
GEMINI_GUARD_MARKER = "guard adapter --harness gemini"
160+
161+
#: Substring identifying omind's own OpenClaw guard gateway hook in openclaw.json.
162+
OPENCLAW_GUARD_MARKER = "guard adapter --harness openclaw"
163+
164+
146165
# -- shared agent machinery ---------------------------------------------------
147166

148167

@@ -590,6 +609,158 @@ def install_priming(self) -> None:
590609
path.parent.mkdir(parents=True, exist_ok=True)
591610
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
592611

612+
def integrate(self) -> None:
613+
super().integrate()
614+
self.install_guard()
615+
616+
def install_guard(self) -> None:
617+
"""Register the OMI guard as an OpenClaw gateway hook in ``openclaw.json``.
618+
619+
OpenClaw's hook transport is an HTTP/WebSocket gateway (POST /hooks/agent
620+
on :18789, loopback), not a stdout shell hook — so we register a command
621+
entry the gateway invokes as ``omind guard adapter --harness openclaw``;
622+
the adapter emits an ``{"allow","reason","rule_id"}`` verdict the gateway
623+
reads. Until that gateway is confirmed to ENFORCE a deny against a live
624+
instance, OpenClaw is wired DETECT-ONLY (issue #88) and the verdict is
625+
advisory. Touches only our own entry (by :data:`OPENCLAW_GUARD_MARKER`),
626+
preserving any user-authored hooks.
627+
"""
628+
path = openclaw_config_path()
629+
data = self._read_settings(path)
630+
command = f"{shutil.which('omind') or 'omind'} guard adapter --harness openclaw"
631+
desired = {"event": "pre_tool", "command": command, "enabled": True}
632+
hooks = data.get("hooks")
633+
if not isinstance(hooks, dict):
634+
hooks = {}
635+
agent_hooks = hooks.get("agent")
636+
existing = agent_hooks if isinstance(agent_hooks, list) else []
637+
kept = [
638+
e
639+
for e in existing
640+
if not (isinstance(e, dict) and OPENCLAW_GUARD_MARKER in json.dumps(e))
641+
]
642+
merged = kept + [desired]
643+
if merged != existing or self.config.force:
644+
hooks["agent"] = merged
645+
data["hooks"] = hooks
646+
self._record(f"register OMI guard gateway hook (detect-only) in {path}")
647+
if not self.config.dry_run:
648+
path.parent.mkdir(parents=True, exist_ok=True)
649+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
650+
else:
651+
self.log(f" OMI guard gateway hook already installed in {path}")
652+
653+
def _guard_wired(self) -> bool:
654+
try:
655+
data = self._read_settings(openclaw_config_path())
656+
except ProvisionError:
657+
return False
658+
hooks = data.get("hooks")
659+
agent_hooks = hooks.get("agent") if isinstance(hooks, dict) else None
660+
return any(
661+
isinstance(e, dict) and OPENCLAW_GUARD_MARKER in json.dumps(e)
662+
for e in (agent_hooks or [])
663+
)
664+
665+
666+
# -- Gemini CLI -----------------------------------------------------------------
667+
668+
669+
class GeminiProvisioner(AgentProvisioner):
670+
"""Wire the OMI guard into the Google Gemini CLI via its ``BeforeTool`` hook.
671+
672+
Gemini CLI's hooks live under a top-level ``hooks`` key in
673+
``~/.gemini/settings.json``. ``BeforeTool`` is the PreToolUse analog and can
674+
HARD-BLOCK: omind mounts ``omind guard adapter --harness gemini`` matching
675+
every tool (``matcher: ".*"``). On a deny the adapter prints
676+
``{"decision":"deny","reason":...}`` on stdout (exit 0), which Gemini enforces
677+
as a tool block.
678+
679+
Guard-only: Gemini MCP-memory registration (``mcpServers`` in the same file)
680+
is a separate concern and intentionally not bundled here.
681+
"""
682+
683+
AGENT_LABEL = "Gemini CLI"
684+
INSTALL_HINT = "Install the Gemini CLI (`npm i -g @google/gemini-cli`), then re-run."
685+
DONE_MESSAGE = (
686+
"Done. Restart the Gemini CLI to load the OMI guard "
687+
"(needs a Gemini CLI with BeforeTool hook support)."
688+
)
689+
690+
def agent_root(self) -> Path:
691+
return gemini_config_dir()
692+
693+
def integrate(self) -> None:
694+
# Guard-only wiring (no MCP/skill/priming — see the class docstring).
695+
self.install_guard()
696+
697+
def _guard_hook_group(self) -> dict[str, Any]:
698+
"""One ``BeforeTool`` matcher group running the omind gemini adapter on
699+
every tool. Gemini pipes the event JSON on stdin; the adapter reads it."""
700+
omind = shutil.which("omind") or "omind"
701+
return {
702+
"matcher": ".*",
703+
"hooks": [
704+
{
705+
"type": "command",
706+
"command": f"{omind} guard adapter --harness gemini",
707+
"name": "omind-omi-guard",
708+
"timeout": 30000,
709+
}
710+
],
711+
}
712+
713+
def install_guard(self) -> None:
714+
"""Merge omind's guard hook into ``~/.gemini/settings.json`` under
715+
``hooks.BeforeTool``, replacing only our own entry (by
716+
:data:`GEMINI_GUARD_MARKER`) so user-authored hooks are preserved."""
717+
path = gemini_settings_path()
718+
data = self._read_settings(path)
719+
desired = self._guard_hook_group()
720+
hooks = data.get("hooks")
721+
if not isinstance(hooks, dict):
722+
hooks = {}
723+
groups = hooks.get("BeforeTool")
724+
existing = groups if isinstance(groups, list) else []
725+
kept = [
726+
g
727+
for g in existing
728+
if not (isinstance(g, dict) and GEMINI_GUARD_MARKER in json.dumps(g))
729+
]
730+
merged = kept + [desired]
731+
if merged != existing or self.config.force:
732+
hooks["BeforeTool"] = merged
733+
data["hooks"] = hooks
734+
self._record(f"install OMI guard hook (BeforeTool) in {path}")
735+
if not self.config.dry_run:
736+
path.parent.mkdir(parents=True, exist_ok=True)
737+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
738+
else:
739+
self.log(f" OMI guard hook already installed in {path}")
740+
741+
def _guard_wired(self) -> bool:
742+
try:
743+
data = self._read_settings(gemini_settings_path())
744+
except ProvisionError:
745+
return False
746+
hooks = data.get("hooks")
747+
groups = hooks.get("BeforeTool") if isinstance(hooks, dict) else None
748+
return any(
749+
isinstance(g, dict) and GEMINI_GUARD_MARKER in json.dumps(g)
750+
for g in (groups or [])
751+
)
752+
753+
def verify(self) -> None:
754+
if self.config.dry_run:
755+
return
756+
if self._guard_wired():
757+
self.log(f" verified: OMI guard wired into Gemini CLI ({gemini_settings_path()})")
758+
else:
759+
self.log(
760+
" NOTE: could not confirm the OMI guard in Gemini's settings.json; "
761+
"re-run with --force."
762+
)
763+
593764

594765
# -- OpenCode -------------------------------------------------------------------
595766

@@ -848,7 +1019,25 @@ def diagnose_hermes(config: SetupConfig) -> list[CheckResult]:
8481019

8491020

8501021
def diagnose_openclaw(config: SetupConfig) -> list[CheckResult]:
851-
return _diagnose_agent(OpenClawProvisioner(config=config, log=lambda _msg: None))
1022+
prov = OpenClawProvisioner(config=config, log=lambda _msg: None)
1023+
results = _diagnose_agent(prov)
1024+
if prov._guard_wired():
1025+
results.append(
1026+
CheckResult(
1027+
"openclaw_guard",
1028+
"ok",
1029+
f"OMI guard (detect-only) wired into {openclaw_config_path()}",
1030+
)
1031+
)
1032+
else:
1033+
results.append(
1034+
CheckResult(
1035+
"openclaw_guard",
1036+
"warn",
1037+
"OMI guard not in openclaw.json (run `omind setup --agent openclaw`)",
1038+
)
1039+
)
1040+
return results
8521041

8531042

8541043
def diagnose_opencode(config: SetupConfig) -> list[CheckResult]:
@@ -887,6 +1076,34 @@ def diagnose_codex(config: SetupConfig) -> list[CheckResult]:
8871076
return results
8881077

8891078

1079+
def diagnose_gemini(config: SetupConfig) -> list[CheckResult]:
1080+
"""Gemini is guard-only here (no MCP/skill), so its doctor checks the
1081+
settings.json ``BeforeTool`` guard wiring rather than MCP registration."""
1082+
prov = GeminiProvisioner(config=config, log=lambda _msg: None)
1083+
results = _diagnose_tools(prov.REQUIRED_TOOLS)
1084+
root = gemini_config_dir()
1085+
if root.is_dir():
1086+
results.append(CheckResult("gemini_root", "ok", f"Gemini CLI found: {root}"))
1087+
else:
1088+
results.append(
1089+
CheckResult("gemini_root", "fail", f"Gemini CLI not found: {root} does not exist")
1090+
)
1091+
results.extend(_diagnose_omi_folder(prov.config))
1092+
if prov._guard_wired():
1093+
results.append(
1094+
CheckResult("gemini_guard", "ok", f"OMI guard wired into {gemini_settings_path()}")
1095+
)
1096+
else:
1097+
results.append(
1098+
CheckResult(
1099+
"gemini_guard",
1100+
"fail",
1101+
"OMI guard not in Gemini settings.json (run `omind setup --agent gemini`)",
1102+
)
1103+
)
1104+
return results
1105+
1106+
8901107
# -- dispatch -------------------------------------------------------------------
8911108

8921109
PROVISIONERS: dict[str, type[Provisioner]] = {
@@ -895,6 +1112,7 @@ def diagnose_codex(config: SetupConfig) -> list[CheckResult]:
8951112
"openclaw": OpenClawProvisioner,
8961113
"opencode": OpenCodeProvisioner,
8971114
"codex": CodexProvisioner,
1115+
"gemini": GeminiProvisioner,
8981116
}
8991117

9001118
DIAGNOSERS = {
@@ -903,6 +1121,7 @@ def diagnose_codex(config: SetupConfig) -> list[CheckResult]:
9031121
"openclaw": diagnose_openclaw,
9041122
"opencode": diagnose_opencode,
9051123
"codex": diagnose_codex,
1124+
"gemini": diagnose_gemini,
9061125
}
9071126

9081127
AGENT_CHOICES = tuple(PROVISIONERS)

0 commit comments

Comments
 (0)