diff --git a/BACKLOG.md b/BACKLOG.md index cb638f2..42a028a 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -10,7 +10,7 @@ here so neither side drifts. ### Windows fresh-install test (2026-08-15, first fully-cold install) - [x] **`setup --dry-run` warns about a missing claude CLI but the real run hard-fails** — fixed in 8.7.1: claude is a soft prerequisite, real run degrades like the dry-run promises ([#258](https://github.com/CryptoJones/omind/issues/258)) — either fail the dry-run too, or degrade the real run gracefully (do vault/seed work, skip only MCP registration) -- [ ] **Windows: POSIX `.sh` hooks + `fleet-sudo` installed unverified** ([#259](https://github.com/CryptoJones/omind/issues/259)) — doctor should probe hook executability (needs an `sh` on PATH); setup should warn or ship PowerShell equivalents; also `�` mojibake in doctor output under PS 5.1 +- [x] **Windows: POSIX `.sh` hooks + `fleet-sudo` installed unverified** — fixed in 8.7.2: direct omind hook commands on Windows, sh-gated bash guards, doctor probe, UTF-8 console ([#259](https://github.com/CryptoJones/omind/issues/259)) — doctor should probe hook executability (needs an `sh` on PATH); setup should warn or ship PowerShell equivalents; also `�` mojibake in doctor output under PS 5.1 - [x] **Windows: codex hook verifier false-negatives on its own SessionStart/PostToolUse entries** — fixed in 8.7.1: shared Windows-tolerant command_is_omind_hook predicate ([#261](https://github.com/CryptoJones/omind/issues/261)) — hooks are written correctly but setup/--force/doctor all report them missing (quoting-form mismatch in the matcher); guard selftest passes for all six harnesses on Windows ### From the 2026-08-01 top-to-bottom code review @@ -117,14 +117,14 @@ review's headline recommendations are deliberately NOT tracked here: temporal va already shipped (#169), and adopting an external memory framework as the storage layer is already rejected below._ -- [ ] **Capture the agent's own work without requiring it to remember a tool call** ([#221](https://github.com/CryptoJones/omind/issues/221)) — _enhancement (memory)_ — +- [x] **Capture the agent's own work without requiring it to remember a tool call** ([#221](https://github.com/CryptoJones/omind/issues/221)) — _enhancement (memory)_ — 7 of 10 lanes named this the largest gap. Every write needs the agent to *decide* to call `create-note`; a forgotten call loses the memory silently, with no error and no warning. Explicitly NOT the external-document ingest rejected below — this captures the agent's own work, which is what omind's notes already are. Open question is whether it belongs here or in the harness; a cheaper middle option is a detector that flags "this session wrote no notes" without omind ever reading a transcript. -- [ ] **Flat note namespace: every agent on every machine sees every note** ([#222](https://github.com/CryptoJones/omind/issues/222)) — _enhancement (mesh)_ — +- [x] **Flat note namespace: every agent on every machine sees every note** ([#222](https://github.com/CryptoJones/omind/issues/222)) — _enhancement (mesh)_ — single-lane finding. Two axes: retrieval precision (unrelated notes compete in every search, where Mem0/LangMem/Letta all partition by user/agent/run) and blast radius (no way to scope a note to a project, machine, or agent). Distinct from #196, which covered the diff --git a/CHANGELOG.md b/CHANGELOG.md index d44e9ab..355e0f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [8.7.2] - 2026-08-15 + +### Fixed +- **Native Windows gets working Claude hooks (#259).** Setup registered the + POSIX `.sh` adapters on Windows unverified. Now: the OMI-compliance guard + and gate-reset register direct omind invocations (`guard adapter` / + `guard preflight` — the pure-Python path the Codex harness already uses); + the secret-output + fresh-base git guards run through `sh` when Git for + Windows provides one and are skipped with a clear warning (and a doctor + `tool:sh` check) when it doesn't; `fleet-sudo` and the dead `.sh` copies are + no longer written on Windows; and CLI output is forced to UTF-8 on Windows, + fixing the doctor mojibake under PowerShell 5.1. + ## [8.7.1] - 2026-08-15 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index d0010f0..95005b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "omind" -version = "8.7.1" +version = "8.7.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 799a170..4f3c6f1 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__ = "8.7.1" +__version__ = "8.7.2" diff --git a/src/omind/cli.py b/src/omind/cli.py index 05b7bd8..d6a5e54 100644 --- a/src/omind/cli.py +++ b/src/omind/cli.py @@ -23,6 +23,7 @@ from __future__ import annotations import argparse +import contextlib import os import shlex import sys @@ -1578,6 +1579,13 @@ def _run_help(args: argparse.Namespace) -> int: def main(argv: list[str] | None = None) -> int: + if os.name == "nt": + # Windows PowerShell 5.1's default console codepage mangles the em + # dashes and check glyphs doctor/setup print (#259's mojibake finding); + # UTF-8 output fixes the rendering everywhere modern Windows runs. + for stream in (sys.stdout, sys.stderr): + with contextlib.suppress(Exception): + stream.reconfigure(encoding="utf-8") # type: ignore[union-attr] parser = build_parser() args = parser.parse_args(argv) if args.command == "help": diff --git a/src/omind/provision.py b/src/omind/provision.py index b241dac..fd72c3e 100644 --- a/src/omind/provision.py +++ b/src/omind/provision.py @@ -404,6 +404,17 @@ def _hook_exe_path(command_text: str) -> str | None: #: (each adapter's filename always appears in its command). OMI_GUARD_MARKER = "omi-guard.sh" OMI_GATE_RESET_MARKER = "omi-gate-reset.sh" + +#: Native-Windows hook-command markers. Windows cannot execute the POSIX .sh +#: adapters, so provisioning there registers direct omind invocations — the +#: same pure-Python guard path the Codex harness uses (#259). The markers let +#: replacement/diagnosis recognize either form on either platform. +WINDOWS_OMI_GUARD_MARKER = "guard adapter --harness claude" +WINDOWS_GATE_RESET_MARKER = "guard preflight" + + +def _windows() -> bool: + return os.name == "nt" #: The retired hand-rolled PreToolUse('*') guard, replaced by ``omi-guard.sh``. #: Provision strips it from settings.json so a prototype machine doesn't run two #: guards. (Its name is NOT a substring of OMI_GUARD_MARKER, so the two markers @@ -743,6 +754,8 @@ def _write_enforce_hook_script(self) -> None: def _write_guard_hook_script(self) -> None: """Write the fresh-base git guard hook from package data to ~/.claude/hooks/.""" + if _windows() and shutil.which("sh") is None: + return # POSIX script; no sh on this Windows box — install skipped dest = _guard_hook_dest() try: content = ( @@ -761,6 +774,8 @@ def _write_secret_output_guard_script(self) -> None: Unlike ``omi-guard.sh`` the script has no machine-specific paths, so it ships verbatim with no install-time substitution (like ``git-fresh-base.sh``). """ + if _windows() and shutil.which("sh") is None: + return # POSIX script; no sh on this Windows box — install skipped dest = _secret_output_guard_dest() try: content = ( @@ -781,6 +796,8 @@ def _write_fleet_sudo_script(self) -> None: ever guesses the entry or hands CJ a command to run. The guard blocks raw `sudo` and points here; see the OMI Playbook. """ + if _windows(): + return # bash + pass + sudo wrapper — meaningless on Windows dest = _fleet_sudo_dest() try: content = ( @@ -918,22 +935,35 @@ def ensure_guard_hook_installed(self) -> None: something changed (or ``--force``). """ path = claude_settings_path() + if _windows() and shutil.which("sh") is None: + self.log( + " SKIP: the secret-output + fresh-base git guards are POSIX " + "shell and no `sh` is on PATH — install Git for Windows to " + "enable them (doctor flags this gap)." + ) + return data = self._read_settings(path) hooks_cfg = data.get("hooks") if not isinstance(hooks_cfg, dict): hooks_cfg = {} + def _script_command(dest: Path) -> str: + # A bare .sh path is not executable by the Windows shell; run it + # through the sh found on PATH (Git for Windows). The script + # filename stays in the command, so the markers still match. + return f'sh "{dest}"' if _windows() else str(dest) + desired: dict[str, Any] = { "matcher": "Bash", "hooks": [ { "type": "command", - "command": str(_secret_output_guard_dest()), + "command": _script_command(_secret_output_guard_dest()), "timeout": SECRET_OUTPUT_GUARD_TIMEOUT, }, { "type": "command", - "command": str(_guard_hook_dest()), + "command": _script_command(_guard_hook_dest()), "timeout": GUARD_HOOK_TIMEOUT, }, ], @@ -988,6 +1018,18 @@ def _write_omi_guard_scripts(self) -> None: retires the legacy prototype adapter and stamps the provision manifest so upgrades can detect hook-set drift (#86/#87).""" self._remove_legacy_omi_guard() + if _windows(): + # The adapters are POSIX shell; Windows registers direct omind + # invocations instead (see ensure_omi_guard_installed), so writing + # the scripts would install dead files a reviewer must puzzle over. + self.log( + " skip omi-guard.sh/omi-gate-reset.sh (POSIX adapters; " + "Windows hooks call omind directly)" + ) + if not self.config.dry_run: + write_provision_manifest() + policy.write_seed_policy() + return omind_exe = canonical_omind_exe() omi_dir = str(self.config.omi_dir) for resource, dest in ( @@ -1029,24 +1071,44 @@ def ensure_omi_guard_installed(self) -> None: if not isinstance(hooks_cfg, dict): hooks_cfg = {} + if _windows(): + # No .sh adapters on native Windows: call omind directly — the same + # pure-Python guard path the Codex harness uses (#259). + omind_exe = canonical_omind_exe() + guard_command = ( + f'"{omind_exe}" guard adapter --harness claude ' + f'--omi-dir "{self.config.omi_dir}"' + ) + reset_command = ( + f'"{omind_exe}" guard preflight --omi-dir "{self.config.omi_dir}"' + ) + else: + guard_command = str(_omi_guard_dest()) + reset_command = str(_omi_gate_reset_dest()) desired: dict[str, dict[str, Any]] = { "PreToolUse": { "matcher": "*", "hooks": [ { "type": "command", - "command": str(_omi_guard_dest()), + "command": guard_command, "timeout": OMI_GUARD_TIMEOUT, } ], }, "UserPromptSubmit": { - "hooks": [{"type": "command", "command": str(_omi_gate_reset_dest())}] + "hooks": [{"type": "command", "command": reset_command}] }, } + # Both platform forms are strippable so a checkout moved between + # platforms converges instead of accumulating one entry per form. strip_markers = { - "PreToolUse": (OMI_GUARD_MARKER, LEGACY_OMI_GUARD_MARKER), - "UserPromptSubmit": (OMI_GATE_RESET_MARKER,), + "PreToolUse": ( + OMI_GUARD_MARKER, + LEGACY_OMI_GUARD_MARKER, + WINDOWS_OMI_GUARD_MARKER, + ), + "UserPromptSubmit": (OMI_GATE_RESET_MARKER, WINDOWS_GATE_RESET_MARKER), } changed = False @@ -1249,6 +1311,31 @@ def _diagnose_tools(tools: dict[str, str]) -> list[CheckResult]: return results +def _diagnose_windows_sh() -> list[CheckResult]: + """#259: on native Windows the secret-output + fresh-base git guards are + POSIX shell, executed through an `sh` on PATH (Git for Windows). Without + one they are skipped at setup, so surface the gap; on POSIX say nothing.""" + if not _windows(): + return [] + if shutil.which("sh") is not None: + return [ + CheckResult( + "tool:sh", + "ok", + "sh found on PATH — the secret-output + fresh-base git guards run through it", + ) + ] + return [ + CheckResult( + "tool:sh", + "warn", + "no `sh` on PATH — the secret-output + fresh-base git guards are " + "skipped on this Windows box (install Git for Windows, then re-run " + "`omind setup`); the OMI-compliance guard itself runs natively", + ) + ] + + def _diagnose_jq() -> CheckResult: """#107: `jq` is the guard hook's fast-path parser, but no longer a hard dependency — the hook falls back to `omind guard adapter` (pure Python) when @@ -1311,6 +1398,7 @@ def diagnose(config: SetupConfig) -> list[CheckResult]: """ results = _diagnose_tools(Provisioner.REQUIRED_TOOLS) results.append(_diagnose_jq()) + results.extend(_diagnose_windows_sh()) results.extend(_diagnose_omi_folder(config)) omi = config.omi_dir @@ -1491,7 +1579,11 @@ def _diagnose_omi_guard(settings_path: Path, config: SetupConfig) -> CheckResult the auto-memory hooks. A green here must mean the per-turn consult gate and the hard blocks really run, so a missing/unwired guard is a ``fail``, not a silent pass.""" - missing = [str(p) for p in _managed_guard_hooks().values() if not p.is_file()] + missing = ( + [] + if _windows() # Windows registers direct omind commands, no .sh files + else [str(p) for p in _managed_guard_hooks().values() if not p.is_file()] + ) if missing: return CheckResult( "omi_guard", @@ -1515,12 +1607,19 @@ def _diagnose_omi_guard(settings_path: Path, config: SetupConfig) -> CheckResult pre_ok = any( isinstance(e, dict) and e.get("matcher") == "*" - and OMI_GUARD_MARKER in _entry_command_text(e) + and ( + OMI_GUARD_MARKER in _entry_command_text(e) + or WINDOWS_OMI_GUARD_MARKER in _entry_command_text(e) + ) for e in pre_list ) ups_list = hooks_cfg.get("UserPromptSubmit") ups_list = ups_list if isinstance(ups_list, list) else [] - ups_ok = any(OMI_GATE_RESET_MARKER in _entry_command_text(e) for e in ups_list) + ups_ok = any( + OMI_GATE_RESET_MARKER in _entry_command_text(e) + or WINDOWS_GATE_RESET_MARKER in _entry_command_text(e) + for e in ups_list + ) if not (pre_ok and ups_ok): unwired: list[str] = [] if not pre_ok: diff --git a/tests/test_provision.py b/tests/test_provision.py index c3a1bae..aadf533 100644 --- a/tests/test_provision.py +++ b/tests/test_provision.py @@ -1382,3 +1382,102 @@ def test_doctor_vault_writes_probe_fails_on_readonly_vault(tmp_path: Path) -> No if _os.name != "nt": # chmod is advisory on Windows assert result.level == "fail" assert "not writable" in result.message + + +# -- #259: native-Windows hook provisioning ----------------------------------- + + +def test_windows_omi_guard_registers_direct_omind_commands( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, isolate_settings: Path +) -> None: + """Native Windows cannot execute the .sh adapters, so the compliance guard + and gate-reset register direct omind invocations instead (#259).""" + monkeypatch.setattr(provision, "_windows", lambda: True) + config = _config(tmp_path) + Provisioner(config, log=_quiet).ensure_omi_guard_installed() + data = json.loads(isolate_settings.read_text(encoding="utf-8")) + pre = [ + e + for e in data["hooks"]["PreToolUse"] + if provision.WINDOWS_OMI_GUARD_MARKER in json.dumps(e) + ] + ups = [ + e + for e in data["hooks"]["UserPromptSubmit"] + if provision.WINDOWS_GATE_RESET_MARKER in json.dumps(e) + ] + assert len(pre) == 1 and pre[0]["matcher"] == "*" + assert len(ups) == 1 + assert not any("omi-guard.sh" in json.dumps(e) for e in data["hooks"]["PreToolUse"]) + + # Re-run converges (no duplicate accumulation across the two forms). + before = isolate_settings.read_text(encoding="utf-8") + Provisioner(config, log=_quiet).ensure_omi_guard_installed() + assert isolate_settings.read_text(encoding="utf-8") == before + + +def test_windows_omi_guard_replaces_posix_form( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, isolate_settings: Path +) -> None: + config = _config(tmp_path) + Provisioner(config, log=_quiet).ensure_omi_guard_installed() # POSIX form first + monkeypatch.setattr(provision, "_windows", lambda: True) + Provisioner(config, log=_quiet).ensure_omi_guard_installed() # then Windows form + data = json.loads(isolate_settings.read_text(encoding="utf-8")) + pre_ours = [ + e + for e in data["hooks"]["PreToolUse"] + if "omi-guard.sh" in json.dumps(e) + or provision.WINDOWS_OMI_GUARD_MARKER in json.dumps(e) + ] + assert len(pre_ours) == 1 + assert provision.WINDOWS_OMI_GUARD_MARKER in json.dumps(pre_ours[0]) + + +def test_windows_bash_guards_skip_without_sh( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, isolate_settings: Path +) -> None: + monkeypatch.setattr(provision, "_windows", lambda: True) + monkeypatch.setattr(provision.shutil, "which", lambda name: None) + logs: list[str] = [] + Provisioner(_config(tmp_path), log=logs.append).ensure_guard_hook_installed() + assert any("SKIP" in line and "sh" in line for line in logs) + if isolate_settings.exists(): + data = json.loads(isolate_settings.read_text(encoding="utf-8")) + assert "git-fresh-base.sh" not in json.dumps(data) + + +def test_windows_bash_guards_run_through_sh_when_present( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, isolate_settings: Path +) -> None: + monkeypatch.setattr(provision, "_windows", lambda: True) + monkeypatch.setattr(provision.shutil, "which", lambda name: "C:\\Git\\bin\\sh.EXE") + Provisioner(_config(tmp_path), log=_quiet).ensure_guard_hook_installed() + data = json.loads(isolate_settings.read_text(encoding="utf-8")) + bash_entries = [e for e in data["hooks"]["PreToolUse"] if e.get("matcher") == "Bash"] + commands = [h["command"] for e in bash_entries for h in e["hooks"]] + assert any(c.startswith('sh "') and "git-fresh-base.sh" in c for c in commands) + assert any(c.startswith('sh "') and "secret-output-guard.sh" in c for c in commands) + + +def test_windows_diagnose_accepts_direct_commands_and_flags_missing_sh( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, isolate_settings: Path +) -> None: + monkeypatch.setattr(provision, "_windows", lambda: True) + config = _config(tmp_path) + prov = Provisioner(config, log=_quiet) + prov._write_omi_guard_scripts() # Windows path: no .sh files, still stamps the manifest + prov.ensure_omi_guard_installed() + result = provision._diagnose_omi_guard(isolate_settings, config) + assert result.level == "ok", result.message + + monkeypatch.setattr(provision.shutil, "which", lambda name: None) + sh_results = provision._diagnose_windows_sh() + assert len(sh_results) == 1 and sh_results[0].level == "warn" + monkeypatch.setattr(provision.shutil, "which", lambda name: "C:\\Git\\bin\\sh.EXE") + sh_results = provision._diagnose_windows_sh() + assert len(sh_results) == 1 and sh_results[0].level == "ok" + + +def test_posix_diagnose_has_no_sh_check() -> None: + assert provision._diagnose_windows_sh() == [] or os.name == "nt" diff --git a/uv.lock b/uv.lock index 0b25064..b9bc3f4 100644 --- a/uv.lock +++ b/uv.lock @@ -2354,7 +2354,7 @@ wheels = [ [[package]] name = "omind" -version = "8.7.1" +version = "8.7.2" source = { editable = "." } dependencies = [ { name = "cryptography" },