Skip to content
Open
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions adapters/codex/hooks.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "vouch context-hook",
"statusMessage": "vouch context"
}
]
}
],
"Stop": [
{
"hooks": [
Expand Down
23 changes: 14 additions & 9 deletions adapters/codex/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
9 changes: 9 additions & 0 deletions adapters/cursor/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions adapters/openclaw/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 4 additions & 3 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 31 additions & 6 deletions src/vouch/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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,
}
}
})
)
72 changes: 64 additions & 8 deletions tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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: [])
Expand All @@ -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"})) == ""
Expand All @@ -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"})) == ""

Expand All @@ -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)
29 changes: 29 additions & 0 deletions tests/test_install_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) ---------------------


Expand Down
Loading