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
12 changes: 12 additions & 0 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ _Closed as not planned or rejected. Kept for the record; nothing here is pending

## Done

### 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_ —
`omi-guard.sh` unwraps `use_tool` / `CallMcpTool` and camelCase Grok events before the consult
case, and maps `run_terminal_command` onto Bash. A snake_case-only dispatcher on the Claude
harness is translated too, so PostToolUse records the consult. Listing tools still do not clear
the gate. JSON-string `arguments` are parsed in both paths.
- [x] **Commit freshness gate skipped every non-Claude shell tool** ([#473](https://github.com/CryptoJones/omind/issues/473)) — **v10.3.1** — _bug_ —
the gate matched `Bash` only, so Antigravity, Gemini, Hermes, OpenCode and Poolside commits
never needed a fetch. The adapter maps their shell tools onto `Bash`; Antigravity takes the
repo from `run_command`'s `Cwd`. Cross-harness fetch-then-multi-line-commit tests added.

### Shipped — 2026-10-04 (v10.3.0)

- [x] **Grok Build client: setup, doctor, guard payloads, and quickstart** ([#470](https://github.com/CryptoJones/omind/issues/470)) — **v10.3.0** — _feature_ —
Expand Down
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [10.3.1] - 2026-10-09

### Fixed
- **Grok's Claude-hook fast path counts a `use_tool` recall as a consult
([#472](https://github.com/CryptoJones/omind/issues/472)).** `omi-guard.sh`
reads `.tool_name` only, and v10.3.0's Grok translation runs in the Python
adapter. On the bash fast path a recall arrives as `use_tool` with
`omi__recall-note` nested inside, so it is not a consult, and the gate
denies the recall that would have cleared it. The script now accepts
camelCase, unwraps `use_tool` / `CallMcpTool`, and maps Grok's built-in
names (`run_terminal_command`, `read_file`, …) onto the names it already
classifies. A snake_case-only `use_tool` or built-in name on
`--harness claude` is translated the same way, so PostToolUse records the
consult. Listing tools still do not clear the gate, and vault writes are
still not consults. A plain Claude event is unchanged. A dispatcher's
`arguments` sent as a JSON string is parsed, an empty first payload no
longer hides the real arguments, and a dispatcher with no inner tool name or
an event with no tool is left alone instead of erroring jq. The Python
translator applies the same rules.
- **The commit freshness gate runs in every harness, not just Claude
([#473](https://github.com/CryptoJones/omind/issues/473)).** Its checks matched the tool name `Bash` only. Antigravity
(`run_command`), Gemini (`run_shell_command`), Hermes (`terminal`), OpenCode
(`bash`) and Poolside (`shell`) send their own names, so a commit with no
fetch went through, and the repo-work and global-config checks were skipped
too. The adapter now maps each harness's shell tool onto `Bash`
(`guard.SHELL_TOOL_NAMES`), and `guard.decide` does the same for a caller
that builds the action itself. The command is read from `command`,
`CommandLine` or `cmd`, as a string or an argv list (Codex `local_shell`),
by both the PreToolUse path and the PostToolUse retraction, so a failed
fetch under any of these names is retracted.
- **The no-jq, no-core last resort in `omi-guard.sh` fails closed for every
shell tool name and for any `use_tool` / `CallMcpTool` dispatcher.** It
matched `Bash` and `run_terminal_command` only. A test pins its list to
`guard.SHELL_TOOL_NAMES`.
- **A dispatcher whose first name field is empty is still unwrapped.** jq's
`//` kept `""`, so `{"tool_name":"","name":"run_terminal_command",…}` stayed
`use_tool` and its command never reached the Bash path. jq now takes the
first non-empty name, as the Python translator does.
- **Antigravity resolves the repo from `run_command`'s own `Cwd`.** It wins
over a top-level `cwd` and the workspace root, so a `git fetch` or
`git commit` run in a repo outside the root is judged against that repo. A
JSON-quoted `Cwd` is decoded, and a relative one is taken from the
workspace root.

### Tests
- A fetch, then a commit with a multi-line `-m` trailer block, run from a
different repo's cwd, is fresh in every harness that runs the guard
(Claude, Codex, Grok, Antigravity, Gemini, Hermes, OpenCode, Poolside;
MiniMax models run inside these). omind 10.0.2 split that commit at the
newline inside the quotes, lost its `git -C` path, and judged it against
the cwd's repo, so a commit right after a clean fetch was blocked. Later
releases fixed that as a side effect; these tests pin it.

## [10.3.0] - 2026-10-04

### Added
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -671,9 +671,10 @@ under `[mcp_servers.omi]` in `~/.grok/config.toml`. Hooks go in
`SessionStart` do accounting, the loop guard, and session bookkeeping. Grok
does not inject `SessionStart` stdout, so priming is a managed
`~/.grok/rules/omind.md` plus the packaged skill in `~/.grok/skills/omind/`.
Grok also runs Claude Code's hooks and sends them a camelCase payload
(`toolName`, `toolInput`, `sessionId`); the guard accepts that shape and
answers a deny as `{"decision":"deny","reason":…}`. `$GROK_HOME` overrides
Grok also runs Claude Code's hooks. The bash fast path and the Python
adapter accept camelCase (`toolName`, `toolInput`, `sessionId`) and
snake_case, unwrap `use_tool` so an `omi__…` recall counts as a consult,
and answer a deny as `{"decision":"deny","reason":…}`. `$GROK_HOME` overrides
`~/.grok`. `omind quickstart --agent grok` prints the same wiring to apply
by hand.

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 = "10.3.0"
version = "10.3.1"
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__ = "10.3.0"
__version__ = "10.3.1"
17 changes: 9 additions & 8 deletions src/omind/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,13 @@ def _derive_command(event: dict[str, Any], tool_input: dict[str, Any]) -> str:
"""
# Poolside's ``shell`` tool carries the command line as ``cmd``;
# Antigravity uses ``CommandLine``.
for source in (
event.get("command"),
tool_input.get("command"),
tool_input.get("CommandLine"),
tool_input.get("cmd"),
):
if isinstance(source, str) and source:
return source
source = event.get("command")
if isinstance(source, str) and source:
return source
# command / CommandLine / cmd, a string or an argv list (Codex local_shell).
shell = guard.shell_command_text(tool_input)
if shell:
return shell
for container in (event, tool_input):
for key in ("args", "input"):
val = container.get(key)
Expand Down Expand Up @@ -95,6 +94,8 @@ def normalize_action(event: dict[str, Any]) -> dict[str, Any]:
tool_input = args
tool_input = tool_input if isinstance(tool_input, dict) else {}
command = _derive_command(event, tool_input)
if command and tool in guard.SHELL_TOOL_NAMES:
tool = "Bash"
file_path = _first_str(
tool_input, ("file_path", "path", "TargetFile", "AbsolutePath")
) or _first_str(event, ("file_path", "path", "TargetFile", "AbsolutePath"))
Expand Down
49 changes: 46 additions & 3 deletions src/omind/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,28 @@ def _retract_git_freshness(session: str, repo: Path) -> None:
path.unlink()


def shell_command_text(tool_input: object) -> str:
"""The command a shell tool runs, from any harness's key, or ``""``.

``command`` (Claude, Codex, Gemini, Hermes, Grok), ``CommandLine``
(Antigravity) or ``cmd`` (Poolside, Codex ``exec_command``). An argv list
(Codex ``local_shell``) is joined with spaces. PreToolUse and the
PostToolUse retraction both read it here, so a failed fetch is retracted
under the same key it was granted under (#473).
"""
if not isinstance(tool_input, dict):
return ""
for key in ("command", "CommandLine", "cmd"):
value = tool_input.get(key)
if isinstance(value, str) and value:
return value
if isinstance(value, list):
joined = " ".join(str(part) for part in value if part is not None).strip()
if joined:
return joined
return ""


def _tool_outcome_failed(tool_response: object) -> bool:
"""True on an EXPLICIT failure signal only — a harness that reports no
outcome at all is trusted (mirrors hooks._extract_outcome's discipline)."""
Expand Down Expand Up @@ -538,10 +560,9 @@ def record_freshness_outcome(event: dict[str, Any]) -> None:
enforced it (2026-08-27 review). This retracts the grant when the outcome
says the command failed. Best-effort; never raises."""
try:
if str(event.get("tool_name") or "") != "Bash":
if str(event.get("tool_name") or "") not in SHELL_TOOL_NAMES:
return
tool_input = event.get("tool_input")
command = str(tool_input.get("command") or "") if isinstance(tool_input, dict) else ""
command = shell_command_text(event.get("tool_input"))
if not command or not _is_freshness_command(command):
return
if not _tool_outcome_failed(event.get("tool_response")):
Expand Down Expand Up @@ -1304,6 +1325,24 @@ def clear_all_gates() -> None:
#: Hard rules still apply to everything here; they match on command text, which
#: none of these tools carry.
_GATE_EXEMPT_TOOLS = frozenset({"ToolSearch", "exit", "todo_action"})
#: Each harness's shell tool. The adapter maps these onto ``Bash`` so every
#: Bash-only check (commit freshness, repo work, global-config mutation) judges
#: them. Before, a commit from Antigravity's ``run_command`` or Gemini's
#: ``run_shell_command`` skipped the freshness gate: it was not named Bash.
SHELL_TOOL_NAMES = frozenset(
{
"Bash",
"bash", # OpenCode
"shell", # Poolside, Codex
"local_shell", # Codex
"exec_command", # Codex
"shell_command", # Codex
"terminal", # Hermes
"run_command", # Antigravity
"run_shell_command", # Gemini CLI
"run_terminal_command", # Grok
}
)
_WRITE_TOOLS = frozenset(
{
"Edit",
Expand Down Expand Up @@ -4638,6 +4677,10 @@ def decide(action: dict[str, Any], *, git_rules_missing: bool = False) -> Verdic
timeout, so an ordinary command never gets there."""
session = str(action.get("session") or "")
command = str(action.get("command") or "")
# Every harness's shell tool is Bash to the checks below, whatever built
# the action (#473). The adapter maps it too; this covers direct callers.
if command and str(action.get("tool") or "") in SHELL_TOOL_NAMES:
action = {**action, "tool": "Bash"}

with deadline.scope():
try:
Expand Down
92 changes: 81 additions & 11 deletions src/omind/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,47 @@ def payload_is_grok(event: dict[str, Any]) -> bool:
return "toolName" in event or "toolInput" in event


def _agy_cwd(tool_call: object) -> str:
"""The ``Cwd`` argument of an Antigravity ``run_command``, or ``""``.

Antigravity's transcript logs record argument values JSON-encoded, with
literal double quotes around the path. The hook may send either form, so
a quoted value is decoded.
"""
if not isinstance(tool_call, dict):
return ""
args = tool_call.get("args")
value = args.get("Cwd") if isinstance(args, dict) else None
if not isinstance(value, str):
return ""
value = value.strip()
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
try:
decoded = json.loads(value)
except (ValueError, RecursionError):
return value
return decoded if isinstance(decoded, str) else ""
return value


def _first_dict_arg(*values: object) -> dict[str, Any] | None:
"""The first non-empty dispatcher argument: an object, or a JSON string of one.

An OpenAI-style ``use_tool`` sends ``arguments`` as a JSON string. Without
parsing it, the consult target would be the dispatcher wrapper instead of
the note name. ``omi-guard.sh`` parses the same keys in the same order.
"""
for value in values:
if isinstance(value, str):
try:
value = json.loads(value)
except (ValueError, RecursionError):
continue
if isinstance(value, dict) and value:
return dict(value)
return None


def _translate_grok(event: dict[str, Any]) -> dict[str, Any]:
"""Map a Grok hook event onto the Claude-shaped event the rest of omind uses."""
out = dict(event)
Expand Down Expand Up @@ -430,13 +471,13 @@ def _translate_grok(event: dict[str, Any]) -> dict[str, Any]:
inner = tool_input.get("tool_name") or tool_input.get("toolName") or tool_input.get("name")
if isinstance(inner, str) and inner:
tool = inner
nested = (
tool_input.get("tool_input")
or tool_input.get("arguments")
or tool_input.get("toolInput")
nested = _first_dict_arg(
tool_input.get("tool_input"),
tool_input.get("arguments"),
tool_input.get("toolInput"),
)
if isinstance(nested, dict):
out["tool_input"] = dict(nested)
if nested is not None:
out["tool_input"] = nested
out["tool_name"] = tool
if tool in _GROK_TOOL_ALIASES:
tool = _GROK_TOOL_ALIASES[tool]
Expand All @@ -450,6 +491,19 @@ def _translate_grok(event: dict[str, Any]) -> dict[str, Any]:
return out


def _claude_event_uses_grok_tool(event: dict[str, Any]) -> bool:
"""True when a snake_case Claude-hook event is one of Grok's own tool names.

Grok's Claude-compatible hook sometimes sends ``tool_name`` / ``tool_input``
and no camelCase keys, so :func:`payload_is_grok` is false. ``use_tool``
still hides the MCP tool, and ``run_terminal_command`` is not ``Bash``.
A plain Claude event (``Bash``, ``Edit``, ``mcp__omi__…``, ``omi__…``) does
not match, so :func:`translate_event` can return that same object.
"""
tool = event.get("tool_name")
return isinstance(tool, str) and (tool in _GROK_DISPATCHERS or tool in _GROK_TOOL_ALIASES)


def translate_event(harness: str, event: dict[str, Any]) -> dict[str, Any]:
"""Map a harness's raw hook event onto the Claude-shaped event the rest of
omind consumes (journal, verifier, accounting, guard). Identity for every
Expand All @@ -464,7 +518,10 @@ def translate_event(harness: str, event: dict[str, Any]) -> dict[str, Any]:
- Grok: camelCase ``toolName`` / ``toolInput`` / ``sessionId``, built-in
tool names aliased onto Claude's, MCP tools ``<server>__<tool>``. A
Grok-shaped payload is translated even when the hook was installed as
Claude's, because Grok runs those hooks with its own event shape.
Claude's, because Grok runs those hooks with its own event shape. A
snake_case ``use_tool`` / ``CallMcpTool`` or a Grok built-in name on the
Claude harness is translated too (#472). ``omi-guard.sh`` normalizes the
same shapes before its consult case.
"""
if not isinstance(event, dict):
return event
Expand All @@ -488,10 +545,21 @@ def translate_event(harness: str, event: dict[str, Any]) -> dict[str, Any]:
out["tool_input"] = agy_tool_input
if "session_id" not in out and "conversationId" in out:
out["session_id"] = out["conversationId"]
if "cwd" not in out:
workspaces = out.get("workspacePaths")
if isinstance(workspaces, list) and workspaces and isinstance(workspaces[0], str):
out["cwd"] = workspaces[0]
workspaces = out.get("workspacePaths")
root = ""
if isinstance(workspaces, list) and workspaces and isinstance(workspaces[0], str):
root = workspaces[0]
# run_command's own Cwd is where the command runs, so it wins over a
# top-level cwd and the workspace root: a `git commit` run in a repo
# outside the root is judged against that repo (#473). A relative Cwd
# is taken from the root, not from wherever this hook process runs.
agy_cwd = _agy_cwd(tool_call)
if agy_cwd:
if root and not Path(agy_cwd).is_absolute():
agy_cwd = str(Path(root) / agy_cwd)
out["cwd"] = agy_cwd
elif "cwd" not in out and root:
out["cwd"] = root
if "prompt" not in out:
prompt = agy_last_prompt(out.get("transcriptPath"))
if prompt:
Expand All @@ -511,6 +579,8 @@ def translate_event(harness: str, event: dict[str, Any]) -> dict[str, Any]:
):
out["tool_name"] = "mcp__omi__" + tool
return out
if harness == "claude" and _claude_event_uses_grok_tool(event):
return _translate_grok(event)
if spec.name != "poolside":
return event
out = dict(event)
Expand Down
Loading
Loading