diff --git a/CHANGELOG.md b/CHANGELOG.md index bb2ec563..cc6fc363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,17 @@ All notable changes to vouch are documented here. Format follows filtered like `kb.audit`. - console Dashboard view: 12-month activity calendar, last-30-days bars, hour-of-week heatmap, top actors and event mix, driven by `kb.activity`. +- codex: wired `UserPromptSubmit` to `vouch context-hook` for the first + time, reusing the existing command unmodified — codex's hook + payload/response shape matches claude-code's exactly. (#425) +### Fixed +- claude-code: the `UserPromptSubmit` context hook computed retrieval but + never fed the entity-salience reflex (#223) — `salience.record_query` + was never called from the hook path, leaving the reflex permanently + dormant for every claude-code session. OpenClaw's context engine already + called it correctly; cursor's `beforeSubmitPrompt` hook cannot accept + injected context at all, so it is not wired. (#425) ## [1.2.2] — 2026-07-07 ### Packaging diff --git a/adapters/codex/hooks.json b/adapters/codex/hooks.json index acaf8d04..d110fd74 100644 --- a/adapters/codex/hooks.json +++ b/adapters/codex/hooks.json @@ -1,5 +1,16 @@ { "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "vouch context-hook", + "statusMessage": "vouch context" + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/adapters/codex/install.yaml b/adapters/codex/install.yaml index 9d4cb447..ea678f1f 100644 --- a/adapters/codex/install.yaml +++ b/adapters/codex/install.yaml @@ -38,14 +38,19 @@ tiers: - { src: ../openclaw/skills/vouch-record/SKILL.md, dst: .codex/skills/vouch-record/SKILL.md } - { src: ../openclaw/skills/vouch-followup/SKILL.md, dst: .codex/skills/vouch-followup/SKILL.md } - { src: ../openclaw/skills/vouch-standup/SKILL.md, dst: .codex/skills/vouch-standup/SKILL.md } - # T4 = automatic session capture -- see issue #388. codex's hooks system - # fires Stop when a turn completes; the handler re-ingests the session's - # rollout idempotently (`vouch capture ingest-codex --hook` exits 0 even - # on failure, so capture can never break a codex turn), updating the - # session's single PENDING summary proposal as the session grows. - # hooks live in their own `.codex/hooks.json` file (project-local in - # trusted projects) and json_merge preserves any hooks the user already - # has. note: the legacy `notify` setting can't be used here -- codex only - # honours it in user-global config, which the #179 rule forbids touching. + # T4 = automatic session capture (issue #388) plus per-prompt KB context + # injection (issue #425). codex's hooks system fires Stop when a turn + # completes; the handler re-ingests the session's rollout idempotently + # (`vouch capture ingest-codex --hook` exits 0 even on failure, so capture + # can never break a codex turn), updating the session's single PENDING + # summary proposal as the session grows. codex also fires UserPromptSubmit + # with the same {prompt, session_id, ...} shape and additionalContext + # response contract as claude-code's UserPromptSubmit, so the same + # `vouch context-hook` command (see hooks.py, #425) serves both hosts + # unmodified. hooks live in their own `.codex/hooks.json` file + # (project-local in trusted projects) and json_merge preserves any hooks + # the user already has. note: the legacy `notify` setting can't be used + # here -- codex only honours it in user-global config, which the #179 + # rule forbids touching. T4: - { src: hooks.json, dst: .codex/hooks.json, json_merge: true } diff --git a/adapters/cursor/install.yaml b/adapters/cursor/install.yaml index 900a752f..e75f1e4e 100644 --- a/adapters/cursor/install.yaml +++ b/adapters/cursor/install.yaml @@ -5,6 +5,15 @@ # `~/.cursor/mcp.json` is intentionally out of scope (see #179 acceptance — # we don't touch user-global config from a project-scoped install). # T2 = AGENTS.md fenced snippet (Cursor reads AGENTS.md the way Claude Code reads CLAUDE.md). +# +# Scope decision (issue #425): vouch's per-prompt KB-context hook was NOT +# mirrored to Cursor. Cursor's closest analogue, beforeSubmitPrompt, is +# validation/block-only -- it can inspect and reject a prompt but cannot +# inject additionalContext the way claude-code's and codex's +# UserPromptSubmit can. Cursor's own sessionStart hook does support +# additional_context, but that fires once per session, not per prompt, so +# it isn't a substitute for the reflex-driven per-turn injection #425 asks +# for. Revisit if Cursor ships prompt-time context injection. host: cursor pretty: Cursor fence: diff --git a/adapters/openclaw/install.yaml b/adapters/openclaw/install.yaml index efd2ff8a..49dade1c 100644 --- a/adapters/openclaw/install.yaml +++ b/adapters/openclaw/install.yaml @@ -18,6 +18,16 @@ # from adapters/claude-code/ directly). # T4 = `.openclaw/policy.json` -- the trust boundary as project-local # policy (review-gated writes, audit-logged lifecycle, confined fs). +# +# Note re issue #425: OpenClaw needs no changes here. Its per-prompt context +# injection isn't a hooks.json-style shell hook at all -- it's the +# context-engine slot (openclaw.plugin.json's kind: "context-engine", +# adapters/openclaw/vouch-context-engine.mjs bridging to +# src/vouch/openclaw/context_engine.py's assemble()), and that engine +# already calls salience.record_query / attach_salience on every assemble() +# (see src/vouch/openclaw/context_engine.py). #425's fix was specifically +# for the claude-code/codex hooks.py path, which had the reflex dead; +# OpenClaw's own path never had that bug. host: openclaw pretty: OpenClaw fence: diff --git a/src/vouch/cli.py b/src/vouch/cli.py index bb9f1f48..3cdd8218 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2536,9 +2536,10 @@ def context( def context_hook() -> None: """Emit relevant KB context for a host UserPromptSubmit hook (reads stdin). - Wired by the claude-code adapter; not meant to be run by hand. Reads the - host's JSON hook payload on stdin, prints an additionalContext envelope, - and always exits 0 so it can never block a turn. + Wired by the claude-code and codex adapters (#425); not meant to be run + by hand. Both hosts emit the same {"prompt", "session_id", ...} shape on + stdin and expect the same additionalContext envelope back, so one + command serves both. Always exits 0 so it can never block a turn. """ import sys diff --git a/src/vouch/hooks.py b/src/vouch/hooks.py index e90c2f66..6fef47fd 100644 --- a/src/vouch/hooks.py +++ b/src/vouch/hooks.py @@ -14,6 +14,9 @@ import logging from typing import Any +import yaml + +from . import salience as salience_mod from .context import build_context_pack from .storage import KBStore @@ -46,9 +49,29 @@ def build_claude_prompt_hook(store: KBStore, stdin_text: str) -> str: prompt = str(payload.get("prompt", "")).strip() if not prompt: return "" + session_id = payload.get("session_id") + session_id = str(session_id) if session_id else None + # Feed the entity-salience reflex (#223) so repeated mentions of an + # entity within a session sharpen ranking on subsequent turns -- this + # was previously computed but never actually recorded from the hook + # path, so the reflex sat dormant for claude-code sessions (#425). + if session_id: + try: + cfg = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) or {} + if not isinstance(cfg, dict): + cfg = {} + _enabled, window, _top_k = salience_mod.reflex_cfg(cfg) + salience_mod.record_query(session_id, prompt, window=window) + except Exception: + # Best-effort reflex feed: recording must never break a + # working hook (module contract -- see docstring). + _log.warning("context-hook: salience record_query failed", exc_info=True) try: pack = build_context_pack( - store, query=prompt, limit=_MAX_ITEMS, max_chars=_MAX_CHARS, + store, + query=prompt, + limit=_MAX_ITEMS, + max_chars=_MAX_CHARS, ) except Exception: _log.warning("context-hook: build_context_pack failed", exc_info=True) @@ -60,9 +83,11 @@ def build_claude_prompt_hook(store: KBStore, stdin_text: str) -> str: "Relevant knowledge from the project's vouch KB " "(approved & cited — consider it before answering):\n" + body ) - return json.dumps({ - "hookSpecificOutput": { - "hookEventName": "UserPromptSubmit", - "additionalContext": block, + return json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": block, + } } - }) + ) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 9a096eff..5ba4a32d 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -7,7 +7,7 @@ import pytest -from vouch import context, health, hooks +from vouch import context, health, hooks, salience from vouch.models import Claim from vouch.storage import KBStore @@ -23,7 +23,8 @@ def store(tmp_path: Path) -> KBStore: def _force_hit(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( - context.index_db, "search_semantic", + context.index_db, + "search_semantic", lambda *a, **k: [("claim", "c1", "deploys run on tuesdays via ci", 0.9)], ) monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) @@ -44,17 +45,13 @@ def test_relevant_prompt_yields_additional_context( assert "tuesdays" in env["hookSpecificOutput"]["additionalContext"] -def test_raw_non_json_stdin_is_tolerated( - store: KBStore, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_raw_non_json_stdin_is_tolerated(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: _force_hit(monkeypatch) out = hooks.build_claude_prompt_hook(store, "when do deploys run") assert "tuesdays" in json.loads(out)["hookSpecificOutput"]["additionalContext"] -def test_no_hits_injects_nothing( - store: KBStore, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_no_hits_injects_nothing(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(context.index_db, "search_semantic", lambda *a, **k: []) monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) assert hooks.build_claude_prompt_hook(store, json.dumps({"prompt": "zzznomatch"})) == "" @@ -70,6 +67,7 @@ def test_build_context_pack_exception_is_swallowed( ) -> None: def _boom(*a: object, **k: object) -> object: raise RuntimeError("boom") + monkeypatch.setattr(hooks, "build_context_pack", _boom) assert hooks.build_claude_prompt_hook(store, json.dumps({"prompt": "x"})) == "" @@ -80,6 +78,64 @@ def test_context_hook_cli_always_exits_zero_without_kb( from click.testing import CliRunner from vouch.cli import cli + monkeypatch.chdir(tmp_path) # no .vouch here result = CliRunner().invoke(cli, ["context-hook"], input='{"prompt":"anything"}') assert result.exit_code == 0 + + +def test_prompt_with_session_id_feeds_salience_reflex( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """Regression for #425: the hook computed context but never recorded the + prompt into the entity-salience reflex (#223), leaving it permanently + empty for every claude-code session. record_query must actually run.""" + _force_hit(monkeypatch) + session_id = "sess-425-a" + try: + assert salience._buffered_queries(session_id) == [] + hooks.build_claude_prompt_hook( + store, + json.dumps({"prompt": "when do deploys run", "session_id": session_id}), + ) + assert salience._buffered_queries(session_id) == ["when do deploys run"] + finally: + salience.reset_session(session_id) + + +def test_prompt_without_session_id_does_not_touch_salience( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """No session_id in the payload -- e.g. an older host or a bare prompt + string -- must not raise and must not create any buffer.""" + _force_hit(monkeypatch) + hooks.build_claude_prompt_hook(store, json.dumps({"prompt": "when do deploys run"})) + hooks.build_claude_prompt_hook(store, "when do deploys run") + # no session_id was ever given, so nothing should have been buffered + # under any of the plain-string forms a caller might mistakenly pass. + assert salience._buffered_queries("") == [] + assert salience._buffered_queries("when do deploys run") == [] + + +def test_salience_recording_failure_does_not_block_the_hook( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """Salience is a best-effort reflex; if config reading or recording + breaks for any reason, the hook must still return real context rather + than swallowing the whole response.""" + _force_hit(monkeypatch) + + def _boom(*a: object, **k: object) -> None: + raise RuntimeError("boom") + + monkeypatch.setattr(hooks.salience_mod, "record_query", _boom) + session_id = "sess-425-b" + try: + out = hooks.build_claude_prompt_hook( + store, + json.dumps({"prompt": "when do deploys run", "session_id": session_id}), + ) + # salience recording failed, but real context must still come back. + assert "tuesdays" in json.loads(out)["hookSpecificOutput"]["additionalContext"] + finally: + salience.reset_session(session_id) diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index 64fe097f..19564590 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -574,6 +574,35 @@ def test_codex_t4_rerun_does_not_duplicate_hook(tmp_path: Path) -> None: assert cmds.count("vouch capture ingest-codex --hook") == 1 +def test_codex_t4_writes_user_prompt_submit_hook(tmp_path: Path) -> None: + """Fresh T4 install also wires UserPromptSubmit -> vouch context-hook + (vouchdev/vouch#425), reusing the same command claude-code installs -- + codex's UserPromptSubmit payload/response shape matches claude-code's.""" + result = install("codex", target=tmp_path, tier="T4") + hooks_path = tmp_path / ".codex" / "hooks.json" + data = json.loads(hooks_path.read_text(encoding="utf-8")) + cmds = [ + h["command"] + for g in data["hooks"]["UserPromptSubmit"] + for h in g["hooks"] + ] + assert "vouch context-hook" in cmds + assert ".codex/hooks.json" in result.written + + +def test_codex_t4_user_prompt_submit_rerun_does_not_duplicate(tmp_path: Path) -> None: + install("codex", target=tmp_path, tier="T4") + second = install("codex", target=tmp_path, tier="T4") + assert ".codex/hooks.json" in second.skipped + data = json.loads((tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8")) + cmds = [ + h["command"] + for g in data["hooks"]["UserPromptSubmit"] + for h in g["hooks"] + ] + assert cmds.count("vouch context-hook") == 1 + + # --- codex: config.toml deep-merge (vouchdev/vouch#384) ---------------------