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
15 changes: 14 additions & 1 deletion src/vouch/jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,19 @@ def _store() -> KBStore:
raise RuntimeError(str(e)) from e


def _store_or_none() -> KBStore | None:
"""The KB when one resolves, else None.

Only for reads whose data source is outside `.vouch/` — the KB is an
enrichment, not the subject. Every method that reads or writes knowledge
must keep using `_store()` so a missing KB stays a hard error.
Comment on lines +87 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

keep added prose lowercase across the vouch sources.

The same path-level rule is violated in each changed docstring/comment:

  • src/vouch/jsonl_server.py#L87-L92: lowercase The, Only, and Every.
  • src/vouch/server.py#L73-L78: lowercase The, Only, and Every.
  • src/vouch/server.py#L701-L702: lowercase Degrades.
  • src/vouch/transcript.py#L367-L371: lowercase No and The.
  • src/vouch/transcript.py#L374-L386: lowercase Locate, Returns, and The.

As per path instructions, comments and review notes under src/vouch/** must use lowercase prose.

📍 Affects 3 files
  • src/vouch/jsonl_server.py#L87-L92 (this comment)
  • src/vouch/server.py#L73-L78
  • src/vouch/server.py#L701-L702
  • src/vouch/transcript.py#L367-L371
  • src/vouch/transcript.py#L374-L386
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/jsonl_server.py` around lines 87 - 92, Convert the added prose to
lowercase in the affected docstrings/comments: `src/vouch/jsonl_server.py` lines
87-92 (`_store_or_none`), `src/vouch/server.py` lines 73-78 and 701-702, and
`src/vouch/transcript.py` lines 367-371 and 374-386. Lowercase the specified
sentence-initial words without changing the surrounding content or behavior.

Source: Path instructions

"""
try:
return _store()
except RuntimeError:
return None


def _agent() -> str:
# An authenticated bearer subject is the principal's real identity; it must
# win over the client-supplied X-Vouch-Agent header (and VOUCH_AGENT env),
Expand Down Expand Up @@ -477,7 +490,7 @@ def _h_session_transcript(p: dict) -> dict:
agent = p.get("agent")
if agent is not None and agent not in ("claude", "codex"):
raise ValueError(f"unknown agent: {agent!r} (expected 'claude' or 'codex')")
return transcript.load_transcript(_store(), session_id, agent=agent)
return transcript.load_transcript(_store_or_none(), session_id, agent=agent)


def _h_propose_entity(p: dict) -> dict:
Expand Down
18 changes: 16 additions & 2 deletions src/vouch/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ def _store() -> KBStore:
) from e


def _store_or_none() -> KBStore | None:
"""The KB when one resolves, else None.

Only for reads whose data source is outside `.vouch/` — the KB is an
enrichment, not the subject. Every method that reads or writes knowledge
must keep using `_store()` so a missing KB stays a hard error.
"""
try:
return _store()
except RuntimeError:
return None
Comment on lines +80 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

preserve non-missing-kb failures

_store() converts KBNotFoundError into RuntimeError, so this catch also swallows any unrelated RuntimeError raised by discover_root() or KBStore(...). Malformed configuration or permission failures could therefore be reported as “no KB” and silently drop transcript enrichment. Use a dedicated missing-KB exception/sentinel and catch only that failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/server.py` around lines 80 - 83, Update the _store() flow to
distinguish the expected missing-KB condition from unrelated RuntimeError
failures, using a dedicated exception or sentinel for KBNotFoundError. Catch
only that dedicated missing-KB result when returning None, and allow
RuntimeError from discover_root() or KBStore(...) to propagate.



def _agent() -> str:
# An authenticated bearer subject (set by the /mcp transport) is the
# principal's real identity and must be what proposals/audit attribute to,
Expand Down Expand Up @@ -685,12 +698,13 @@ def kb_session_transcript(session_id: str, agent: str | None = None) -> dict[str
Read-only. Locates the raw Claude Code / Codex file on disk and normalizes
it into message blocks (text, thinking, tool_use with paired results).
``agent`` restricts the search ("claude" | "codex"); omit to try both.
Degrades to compact capture observations when the raw file is unavailable.
Degrades to compact capture observations when the raw file is unavailable,
and to a bare unavailable result when no KB resolves at all.
"""
from . import transcript
if agent is not None and agent not in ("claude", "codex"):
raise ValueError(f"unknown agent: {agent!r} (expected 'claude' or 'codex')")
return transcript.load_transcript(_store(), session_id, agent=agent)
return transcript.load_transcript(_store_or_none(), session_id, agent=agent)


@mcp.tool()
Expand Down
13 changes: 10 additions & 3 deletions src/vouch/transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,19 +374,26 @@ def flush() -> None:
return {"session": session, "messages": messages, "truncated": truncated}


def _degraded(store: KBStore, session_id: str, reason: str) -> dict[str, Any]:
obs = _read_observations(buffer_path(store, session_id))
def _degraded(store: KBStore | None, session_id: str, reason: str) -> dict[str, Any]:
# No KB resolved: the raw transcript lives outside `.vouch/`, so the
# lookup still answers — there is just no capture buffer to fall back on.
obs = _read_observations(buffer_path(store, session_id)) if store is not None else []
return {"available": False, "reason": reason, "observations": obs}


def load_transcript(
store: KBStore, session_id: str, *, agent: str | None = None
store: KBStore | None, session_id: str, *, agent: str | None = None
) -> dict[str, Any]:
"""Locate + parse the raw transcript for ``session_id``.

``agent`` restricts the search ("claude" | "codex"); when None both are
tried. Returns the normalized schema on success, or a degraded result
(compact capture observations) when the raw file is missing/too large.

``store`` may be None when no KB resolves from the caller's cwd. The raw
transcript is read from the agent's own directory, not from the KB, so the
normalized result is unaffected; only the degraded path loses its
observations.
"""
path: Path | None = None
source_agent = ""
Expand Down
46 changes: 46 additions & 0 deletions tests/test_session_transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,52 @@ def test_handler_returns_degraded_when_absent(
assert resp["result"]["available"] is False


def test_handler_degrades_when_no_kb_resolves(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# The sibling test above covers "KB present, raw transcript missing". This
# one covers "no KB at all", which the handler used to answer with an
# internal error rather than the degraded envelope.
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("VOUCH_KB_PATH", raising=False)
monkeypatch.delenv("VOUCH_PROJECT_DIR", raising=False)
monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(tmp_path / "no-claude"))
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no-codex"))
from vouch.jsonl_server import handle_request

resp = handle_request({
"id": "3", "method": "kb.session_transcript",
"params": {"session_id": "11111111-1111-1111-1111-111111111111"},
})
assert resp["ok"] is True
assert resp["result"]["available"] is False
assert resp["result"]["observations"] == []


def test_load_transcript_without_store_degrades(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(tmp_path / "projects"))
out = transcript.load_transcript(None, "11111111-1111-1111-1111-111111111111")
Comment on lines +240 to +244

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

isolate both transcript lookup roots in the direct-load test

with agent omitted, load_transcript() searches both claude and codex. this test sets only VOUCH_CLAUDE_PROJECTS_DIR, so an ambient codex rollout can make the assertion flaky. set CODEX_HOME to an empty temporary root as the neighboring tests do.

proposed fix
     monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(tmp_path / "projects"))
+    monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex"))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_session_transcript.py` around lines 240 - 244, Update
test_load_transcript_without_store_degrades to isolate both transcript lookup
roots by setting CODEX_HOME to a separate empty temporary directory alongside
VOUCH_CLAUDE_PROJECTS_DIR before calling transcript.load_transcript. Preserve
the existing direct-load assertion and use tmp_path for the isolated Codex root.

assert out["available"] is False
assert out["observations"] == []


def test_mcp_session_transcript_degrades_without_kb(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("VOUCH_KB_PATH", raising=False)
monkeypatch.delenv("VOUCH_PROJECT_DIR", raising=False)
monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(tmp_path / "no-claude"))
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no-codex"))
from vouch.server import kb_session_transcript

out = kb_session_transcript("11111111-1111-1111-1111-111111111111")
assert out["available"] is False
assert out["observations"] == []


# --- Task 9: Codex parser -------------------------------------------------

_CODEX_LINES = [
Expand Down
Loading