From a5a4f6fe662f2c2c5a22a26aa61979978addcf3e Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:57:20 +0900 Subject: [PATCH 01/18] feat(transcript): locate raw Claude session files by id --- src/vouch/transcript.py | 54 ++++++++++++++++++++++++++++++++ tests/test_session_transcript.py | 44 ++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/vouch/transcript.py create mode 100644 tests/test_session_transcript.py diff --git a/src/vouch/transcript.py b/src/vouch/transcript.py new file mode 100644 index 00000000..f4dea76b --- /dev/null +++ b/src/vouch/transcript.py @@ -0,0 +1,54 @@ +"""Locate and parse raw agent session transcripts on demand. + +Given a captured session id, find the raw JSONL the agent wrote (Claude Code +under ``~/.claude/projects``, Codex rollouts under ``$CODEX_HOME/sessions``) +and normalize it into a block schema the vouch console renders. Read-only: +never writes to the KB. When the raw file is gone we degrade to vouch's +compact capture observations instead. +""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .storage import KBStore + +# Session ids are UUID-shaped; reject anything else so a hostile id can't +# widen a glob or traverse out of the projects tree. +_VALID_ID = re.compile(r"^[0-9a-fA-F-]{8,64}$") + + +def _claude_projects_root() -> Path: + env = os.environ.get("VOUCH_CLAUDE_PROJECTS_DIR") + return Path(env) if env else Path.home() / ".claude" / "projects" + + +def find_claude_file(session_id: str) -> Path | None: + """The raw Claude Code JSONL for ``session_id``, or None. + + Claude names each session file ``.jsonl`` under a per-cwd project + dir; subagent transcripts live under ``/subagents/**``. The file + stem is the id, so a literal name match (no id interpolation into a glob) + locates it. + """ + if not _VALID_ID.match(session_id): + return None + root = _claude_projects_root() + if not root.is_dir(): + return None + name = f"{session_id}.jsonl" + for project in root.iterdir(): + if not project.is_dir(): + continue + top = project / name + if top.is_file(): + return top + for candidate in root.glob(f"*/*/subagents/**/{name}"): + if candidate.is_file(): + return candidate + return None diff --git a/tests/test_session_transcript.py b/tests/test_session_transcript.py new file mode 100644 index 00000000..dccdae5a --- /dev/null +++ b/tests/test_session_transcript.py @@ -0,0 +1,44 @@ +"""kb.session_transcript — locate + parse raw agent transcripts on demand.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from vouch import transcript + + +def _write_jsonl(path: Path, records: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(json.dumps(r) for r in records) + "\n", encoding="utf-8") + + +# --- Task 1: locator ------------------------------------------------------ + + +def test_find_claude_file_top_level(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path / "projects" + sid = "ad5d5e5f-0097-494c-8316-b01aed5dabf2" + f = root / "-home-a-Dev-agentsview" / f"{sid}.jsonl" + _write_jsonl(f, [{"type": "user", "message": {"role": "user", "content": "hi"}}]) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + assert transcript.find_claude_file(sid) == f + + +def test_find_claude_file_subagent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path / "projects" + parent = "11111111-1111-1111-1111-111111111111" + child = "22222222-2222-2222-2222-222222222222" + f = root / "-proj" / parent / "subagents" / "jobs" / f"{child}.jsonl" + _write_jsonl(f, [{"type": "assistant", "message": {"role": "assistant", "content": []}}]) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + assert transcript.find_claude_file(child) == f + + +def test_find_claude_file_rejects_bad_id(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(tmp_path)) + assert transcript.find_claude_file("../etc/passwd") is None + assert transcript.find_claude_file("*") is None + assert transcript.find_claude_file("") is None From 9bedd1b7f55ce170e6e562ed4f28325b29b3ab12 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:00:17 +0900 Subject: [PATCH 02/18] feat(transcript): parse Claude JSONL into normalized blocks --- src/vouch/transcript.py | 181 ++++++++++++++++++++++++++++++- tests/test_session_transcript.py | 72 ++++++++++++ 2 files changed, 249 insertions(+), 4 deletions(-) diff --git a/src/vouch/transcript.py b/src/vouch/transcript.py index f4dea76b..9a491393 100644 --- a/src/vouch/transcript.py +++ b/src/vouch/transcript.py @@ -13,10 +13,7 @@ import os import re from pathlib import Path -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from .storage import KBStore +from typing import Any # Session ids are UUID-shaped; reject anything else so a hostile id can't # widen a glob or traverse out of the projects tree. @@ -52,3 +49,179 @@ def find_claude_file(session_id: str) -> Path | None: if candidate.is_file(): return candidate return None + + +def _norm_tokens(usage: dict[str, Any]) -> dict[str, int]: + def i(key: str) -> int: + v = usage.get(key) + return int(v) if isinstance(v, (int, float)) else 0 + + return { + "input": i("input_tokens"), + "output": i("output_tokens"), + "cache_read": i("cache_read_input_tokens"), + "cache_creation": i("cache_creation_input_tokens"), + } + + +def _result_text(content: Any) -> str: + """tool_result.content is a string, or a list of {type:text,text} parts.""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [ + str(p.get("text", "")) + for p in content + if isinstance(p, dict) and p.get("type") == "text" + ] + if parts: + return "\n".join(parts) + return json.dumps(content, ensure_ascii=False) + if content is None: + return "" + return json.dumps(content, ensure_ascii=False) + + +def parse_claude_transcript(path: Path, *, max_messages: int = 2000) -> dict[str, Any]: + """Parse a Claude Code JSONL into the normalized transcript schema. + + Single forward pass: assistant content blocks that share a + ``message.id`` merge into one logical message; a later ``tool_result`` + (in a user entry) is paired into the matching ``tool_use`` block by id and + its user entry is not emitted as a standalone message. + """ + messages: list[dict[str, Any]] = [] + tool_by_id: dict[str, dict[str, Any]] = {} + session: dict[str, Any] = { + "id": path.stem, "agent": "claude", "cwd": None, "git_branch": None, + "title": None, "started_at": None, "ended_at": None, "model": None, + "tokens": {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}, + } + truncated = False + current: dict[str, Any] | None = None + + def flush() -> None: + nonlocal current + if current is not None and current["blocks"]: + messages.append(current) + current = None + + with path.open(encoding="utf-8") as fh: + for raw in fh: + raw = raw.strip() + if not raw: + continue + try: + obj = json.loads(raw) + except json.JSONDecodeError: + continue + if not isinstance(obj, dict): + continue + if session["cwd"] is None and isinstance(obj.get("cwd"), str): + session["cwd"] = obj["cwd"] + if session["git_branch"] is None and isinstance(obj.get("gitBranch"), str): + session["git_branch"] = obj["gitBranch"] + ts = obj.get("timestamp") + if isinstance(ts, str): + if session["started_at"] is None: + session["started_at"] = ts + session["ended_at"] = ts + t = obj.get("type") + if t == "ai-title" and isinstance(obj.get("aiTitle"), str): + session["title"] = obj["aiTitle"] + continue + if t not in ("user", "assistant"): + continue + if len(messages) >= max_messages: + truncated = True + break + msg = obj.get("message") + if not isinstance(msg, dict): + continue + content = msg.get("content") + + if t == "assistant": + mid = msg.get("id") if isinstance(msg.get("id"), str) else None + if current is None or current.get("id") != mid: + flush() + raw_usage = msg.get("usage") + usage: dict[str, Any] = raw_usage if isinstance(raw_usage, dict) else {} + model = msg.get("model") if isinstance(msg.get("model"), str) else None + if model and session["model"] is None: + session["model"] = model + current = { + "role": "assistant", "id": mid, "model": model, + "timestamp": ts if isinstance(ts, str) else None, + "tokens": _norm_tokens(usage), "blocks": [], + } + tok = current["tokens"] + for k in session["tokens"]: + session["tokens"][k] += tok[k] + parts = content if isinstance(content, list) else [] + for part in parts: + if not isinstance(part, dict): + continue + ptype = part.get("type") + if ptype == "thinking": + text = str(part.get("thinking", "")).strip() + if text: + current["blocks"].append({"type": "thinking", "text": text}) + elif ptype == "text": + text = str(part.get("text", "")).strip() + if text: + current["blocks"].append({"type": "text", "text": text}) + elif ptype == "tool_use": + tid = part.get("id") + raw_input = part.get("input") + block: dict[str, Any] = { + "type": "tool_use", "id": tid, + "name": str(part.get("name", "")), + "input": raw_input if isinstance(raw_input, dict) else {}, + "result": None, + } + current["blocks"].append(block) + if isinstance(tid, str): + tool_by_id[tid] = block + continue + + # user entry + flush() + if isinstance(content, str): + text = content.strip() + if text: + messages.append({ + "role": "user", "id": None, "model": None, + "timestamp": ts if isinstance(ts, str) else None, + "tokens": None, "blocks": [{"type": "text", "text": text}], + }) + continue + parts = content if isinstance(content, list) else [] + user_blocks: list[dict[str, Any]] = [] + agent_id = None + tur = obj.get("toolUseResult") + if isinstance(tur, dict) and isinstance(tur.get("agentId"), str): + agent_id = tur["agentId"] + for part in parts: + if not isinstance(part, dict): + continue + if part.get("type") == "tool_result": + tid = part.get("tool_use_id") + paired = tool_by_id.get(tid) if isinstance(tid, str) else None + if paired is not None: + paired["result"] = { + "content": _result_text(part.get("content")), + "is_error": bool(part.get("is_error", False)), + "subagent_session_id": agent_id, + } + elif part.get("type") == "text": + text = str(part.get("text", "")).strip() + if text: + user_blocks.append({"type": "text", "text": text}) + if user_blocks: + messages.append({ + "role": "user", "id": None, "model": None, + "timestamp": ts if isinstance(ts, str) else None, + "tokens": None, "blocks": user_blocks, + }) + flush() + return {"session": session, "messages": messages, "truncated": truncated} diff --git a/tests/test_session_transcript.py b/tests/test_session_transcript.py index dccdae5a..059f2e01 100644 --- a/tests/test_session_transcript.py +++ b/tests/test_session_transcript.py @@ -42,3 +42,75 @@ def test_find_claude_file_rejects_bad_id(tmp_path: Path, monkeypatch: pytest.Mon assert transcript.find_claude_file("../etc/passwd") is None assert transcript.find_claude_file("*") is None assert transcript.find_claude_file("") is None + + +# --- Task 2: Claude parser ------------------------------------------------ + +_CLAUDE_LINES = [ + {"type": "user", "cwd": "/repo", "gitBranch": "main", + "timestamp": "2026-07-10T04:44:19.043Z", + "message": {"role": "user", "content": [{"type": "text", "text": "fix the bug"}]}}, + {"type": "ai-title", "aiTitle": "Fix the bug"}, + {"type": "assistant", "timestamp": "2026-07-10T04:44:35.759Z", + "message": {"id": "msg_1", "model": "claude-opus-4-8", "role": "assistant", + "content": [{"type": "thinking", "thinking": "let me look"}], + "usage": {"input_tokens": 100, "output_tokens": 10, + "cache_read_input_tokens": 5, + "cache_creation_input_tokens": 2}}}, + {"type": "assistant", "timestamp": "2026-07-10T04:44:36.771Z", + "message": {"id": "msg_1", "model": "claude-opus-4-8", "role": "assistant", + "content": [{"type": "text", "text": "I'll edit it."}], + "usage": {"input_tokens": 100, "output_tokens": 10}}}, + {"type": "assistant", "timestamp": "2026-07-10T04:44:36.772Z", + "message": {"id": "msg_1", "model": "claude-opus-4-8", "role": "assistant", + "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", + "input": {"command": "go test ./..."}}], + "usage": {"input_tokens": 100, "output_tokens": 10}}}, + {"type": "user", + "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "tu_1", "content": "ok\n", "is_error": False}]}}, +] + + +def test_parse_claude_pairs_result_and_merges_by_message_id(tmp_path: Path) -> None: + f = tmp_path / "s.jsonl" + _write_jsonl(f, _CLAUDE_LINES) + out = transcript.parse_claude_transcript(f) + + assert out["session"]["cwd"] == "/repo" + assert out["session"]["git_branch"] == "main" + assert out["session"]["title"] == "Fix the bug" + assert out["session"]["model"] == "claude-opus-4-8" + assert out["truncated"] is False + + roles = [m["role"] for m in out["messages"]] + assert roles == ["user", "assistant"] # tool_result user entry is consumed + + a = out["messages"][1] + assert [b["type"] for b in a["blocks"]] == ["thinking", "text", "tool_use"] + tu = a["blocks"][2] + assert tu["name"] == "Bash" and tu["input"] == {"command": "go test ./..."} + assert tu["result"] == {"content": "ok\n", "is_error": False, "subagent_session_id": None} + assert a["tokens"] == {"input": 100, "output": 10, "cache_read": 5, "cache_creation": 2} + + +def test_parse_claude_truncates_at_cap(tmp_path: Path) -> None: + lines = [ + {"type": "user", "message": {"role": "user", + "content": [{"type": "text", "text": f"m{i}"}]}} + for i in range(5) + ] + f = tmp_path / "big.jsonl" + _write_jsonl(f, lines) + out = transcript.parse_claude_transcript(f, max_messages=3) + assert out["truncated"] is True + assert len(out["messages"]) == 3 + + +def test_parse_claude_tolerates_malformed_lines(tmp_path: Path) -> None: + f = tmp_path / "m.jsonl" + good = '{"type":"user","message":{"role":"user","content":"hey"}}' + f.write_text('{"bad json\n' + good + "\n", encoding="utf-8") + out = transcript.parse_claude_transcript(f) + assert len(out["messages"]) == 1 + assert out["messages"][0]["blocks"] == [{"type": "text", "text": "hey"}] From d51109cb627caae66c3030ede7a76f3b105ab784 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:07:52 +0900 Subject: [PATCH 03/18] feat(transcript): orchestrate load with observation fallback --- src/vouch/transcript.py | 56 +++++++++++++++++++++++++++++++- tests/test_session_transcript.py | 52 ++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/vouch/transcript.py b/src/vouch/transcript.py index 9a491393..8d9ebc93 100644 --- a/src/vouch/transcript.py +++ b/src/vouch/transcript.py @@ -13,7 +13,15 @@ import os import re from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any + +from .capture import _read_observations, buffer_path + +if TYPE_CHECKING: + from .storage import KBStore + +MAX_FILE_BYTES = 25 * 1024 * 1024 +MAX_MESSAGES = 2000 # Session ids are UUID-shaped; reject anything else so a hostile id can't # widen a glob or traverse out of the projects tree. @@ -225,3 +233,49 @@ def flush() -> None: }) flush() return {"session": session, "messages": messages, "truncated": truncated} + + +def parse_codex_transcript(path: Path, *, max_messages: int = 2000) -> dict[str, Any]: + raise NotImplementedError("codex transcript parsing lands in Task 9") + + +def _degraded(store: KBStore, session_id: str, reason: str) -> dict[str, Any]: + obs = _read_observations(buffer_path(store, session_id)) + return {"available": False, "reason": reason, "observations": obs} + + +def load_transcript( + store: KBStore, 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. + """ + path: Path | None = None + source_agent = "" + if agent in (None, "claude"): + path = find_claude_file(session_id) + if path is not None: + source_agent = "claude" + if path is None and agent in (None, "codex"): + from . import codex_rollout + + path = codex_rollout.find_rollout_by_session_id(session_id) + if path is not None: + source_agent = "codex" + if path is None: + return _degraded(store, session_id, f"raw transcript not found for session {session_id}") + try: + size = path.stat().st_size + except OSError as e: + return _degraded(store, session_id, f"cannot read transcript: {e}") + if size > MAX_FILE_BYTES: + return _degraded(store, session_id, f"transcript too large to render ({size} bytes)") + + if source_agent == "claude": + parsed = parse_claude_transcript(path, max_messages=MAX_MESSAGES) + else: + parsed = parse_codex_transcript(path, max_messages=MAX_MESSAGES) + return {"available": True, "source": {"agent": source_agent, "path": str(path)}, **parsed} diff --git a/tests/test_session_transcript.py b/tests/test_session_transcript.py index 059f2e01..c2a05052 100644 --- a/tests/test_session_transcript.py +++ b/tests/test_session_transcript.py @@ -7,7 +7,8 @@ import pytest -from vouch import transcript +from vouch import capture, transcript +from vouch.storage import KBStore def _write_jsonl(path: Path, records: list[dict]) -> None: @@ -114,3 +115,52 @@ def test_parse_claude_tolerates_malformed_lines(tmp_path: Path) -> None: out = transcript.parse_claude_transcript(f) assert len(out["messages"]) == 1 assert out["messages"][0]["blocks"] == [{"type": "text", "text": "hey"}] + + +# --- Task 3: load_transcript orchestrator --------------------------------- + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def test_load_transcript_available( + tmp_path: Path, store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "projects" + sid = "ad5d5e5f-0097-494c-8316-b01aed5dabf2" + f = root / "-repo" / f"{sid}.jsonl" + _write_jsonl(f, _CLAUDE_LINES) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + out = transcript.load_transcript(store, sid) + assert out["available"] is True + assert out["source"] == {"agent": "claude", "path": str(f)} + assert out["session"]["title"] == "Fix the bug" + + +def test_load_transcript_degrades_to_observations( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(store.kb_dir / "none")) + sid = "99999999-9999-9999-9999-999999999999" + capture.observe(store, sid, tool="Edit", summary="Edited x.go") + out = transcript.load_transcript(store, sid) + assert out["available"] is False + assert out["observations"][0]["tool"] == "Edit" + assert "reason" in out + + +def test_load_transcript_degrades_when_oversized( + tmp_path: Path, store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "projects" + sid = "88888888-8888-8888-8888-888888888888" + f = root / "-repo" / f"{sid}.jsonl" + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text("x" * 32, encoding="utf-8") + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + monkeypatch.setattr(transcript, "MAX_FILE_BYTES", 16) + out = transcript.load_transcript(store, sid) + assert out["available"] is False + assert "too large" in out["reason"] From 97e41a97fcb199118590052f89cefcdbc7b6a4a9 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:09:45 +0900 Subject: [PATCH 04/18] feat(transcript): expose kb.session_transcript across all surfaces --- src/vouch/capabilities.py | 1 + src/vouch/jsonl_server.py | 10 ++++++++ src/vouch/server.py | 15 ++++++++++++ tests/test_session_transcript.py | 41 ++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+) diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index c0a1cc0a..0d8528a5 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -68,6 +68,7 @@ "kb.session_start", "kb.session_end", "kb.list_sessions", + "kb.session_transcript", "kb.volunteer_context", "kb.crystallize", "kb.summarize_session", diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index e52b6b3f..6f34c9cf 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -412,6 +412,15 @@ def _h_list_sessions(p: dict) -> dict: return {"sessions": session_split.build_session_rows(_store())} +def _h_session_transcript(p: dict) -> dict: + from . import transcript + session_id = p["session_id"] + 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) + + def _h_propose_entity(p: dict) -> dict: pr = propose_entity( _store(), @@ -785,6 +794,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.compile": _h_compile, "kb.summarize_session": _h_summarize_session, "kb.list_sessions": _h_list_sessions, + "kb.session_transcript": _h_session_transcript, "kb.propose_entity": _h_propose_entity, "kb.propose_relation": _h_propose_relation, "kb.propose_delete": _h_propose_delete, diff --git a/src/vouch/server.py b/src/vouch/server.py index 2ec09149..6bfd8b35 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -556,6 +556,21 @@ def kb_list_sessions() -> dict[str, Any]: return {"sessions": session_split.build_session_rows(_store())} +@mcp.tool() +def kb_session_transcript(session_id: str, agent: str | None = None) -> dict[str, Any]: + """Render a captured session's full transcript from its raw agent JSONL. + + 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. + """ + 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) + + @mcp.tool() def kb_propose_entity( name: str, diff --git a/tests/test_session_transcript.py b/tests/test_session_transcript.py index c2a05052..367b36d1 100644 --- a/tests/test_session_transcript.py +++ b/tests/test_session_transcript.py @@ -164,3 +164,44 @@ def test_load_transcript_degrades_when_oversized( out = transcript.load_transcript(store, sid) assert out["available"] is False assert "too large" in out["reason"] + + +# --- Task 4: RPC handler -------------------------------------------------- + + +def test_capabilities_advertises_session_transcript() -> None: + from vouch.capabilities import capabilities + from vouch.jsonl_server import HANDLERS + + assert "kb.session_transcript" in capabilities().methods + assert "kb.session_transcript" in HANDLERS + + +def test_handler_missing_session_id_is_missing_param() -> None: + from vouch.jsonl_server import handle_request + + resp = handle_request({"id": "1", "method": "kb.session_transcript", "params": {}}) + assert resp["ok"] is False + assert resp["error"]["code"] == "missing_param" + + +def test_handler_bad_agent_is_invalid_request() -> None: + from vouch.jsonl_server import handle_request + + resp = handle_request({ + "id": "2", "method": "kb.session_transcript", + "params": {"session_id": "11111111-1111-1111-1111-111111111111", "agent": "grok"}, + }) + assert resp["ok"] is False + assert resp["error"]["code"] == "invalid_request" + + +def test_handler_returns_degraded_when_absent() -> None: + 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 From 7dde1003c3be067bca267e4b05f58ff09a6f5594 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:11:13 +0900 Subject: [PATCH 05/18] feat(webapp): transcript client types and fetch --- webapp/src/lib/transcript.test.ts | 22 +++++++++ webapp/src/lib/transcript.ts | 75 +++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 webapp/src/lib/transcript.test.ts create mode 100644 webapp/src/lib/transcript.ts diff --git a/webapp/src/lib/transcript.test.ts b/webapp/src/lib/transcript.test.ts new file mode 100644 index 00000000..128f8551 --- /dev/null +++ b/webapp/src/lib/transcript.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('./rpc', () => ({ rpc: vi.fn() })) +import { rpc } from './rpc' +import { fetchTranscript } from './transcript' +import type { VouchConnectionInfo } from './types' + +const conn: VouchConnectionInfo = { endpoint: 'http://127.0.0.1:8731' } + +describe('fetchTranscript', () => { + it('calls kb.session_transcript with session id + agent', async () => { + vi.mocked(rpc).mockResolvedValue({ available: false, reason: 'x', observations: [] }) + await fetchTranscript(conn, 'sid-1', 'claude') + expect(rpc).toHaveBeenCalledWith(conn, 'kb.session_transcript', { session_id: 'sid-1', agent: 'claude' }) + }) + + it('omits agent when not given', async () => { + vi.mocked(rpc).mockResolvedValue({ available: false, reason: 'x', observations: [] }) + await fetchTranscript(conn, 'sid-2') + expect(rpc).toHaveBeenCalledWith(conn, 'kb.session_transcript', { session_id: 'sid-2' }) + }) +}) diff --git a/webapp/src/lib/transcript.ts b/webapp/src/lib/transcript.ts new file mode 100644 index 00000000..2b98057f --- /dev/null +++ b/webapp/src/lib/transcript.ts @@ -0,0 +1,75 @@ +import { rpc } from './rpc' +import type { VouchConnectionInfo } from './types' + +export interface Tokens { + input: number + output: number + cache_read: number + cache_creation: number +} + +export interface ToolResult { + content: string + is_error: boolean + subagent_session_id: string | null +} + +export type TranscriptBlock = + | { type: 'text'; text: string } + | { type: 'thinking'; text: string } + | { + type: 'tool_use' + id: string | null + name: string + input: Record + result: ToolResult | null + } + +export interface TranscriptMessage { + role: 'user' | 'assistant' + id: string | null + model: string | null + timestamp: string | null + tokens: Tokens | null + blocks: TranscriptBlock[] +} + +export interface SessionMeta { + id: string + agent: string + cwd: string | null + git_branch: string | null + title: string | null + started_at: string | null + ended_at: string | null + model: string | null + tokens: Tokens +} + +export interface Observation { + ts: number + tool: string + summary: string + files?: string[] + cmd?: string +} + +export type Transcript = + | { + available: true + source: { agent: string; path: string } + session: SessionMeta + messages: TranscriptMessage[] + truncated: boolean + } + | { available: false; reason: string; observations: Observation[] } + +export function fetchTranscript( + conn: VouchConnectionInfo, + sessionId: string, + agent?: string, +): Promise { + const params: Record = { session_id: sessionId } + if (agent) params.agent = agent + return rpc(conn, 'kb.session_transcript', params) +} From ff990432ead7616fc62250872769f4efd543fa9c Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:12:00 +0900 Subject: [PATCH 06/18] feat(webapp): thinking, diff, and code block renderers --- .../src/components/transcript/CodeBlock.tsx | 14 +++++++++++ webapp/src/components/transcript/DiffView.tsx | 21 +++++++++++++++++ .../components/transcript/ThinkingBlock.tsx | 18 +++++++++++++++ .../src/components/transcript/blocks.test.tsx | 23 +++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 webapp/src/components/transcript/CodeBlock.tsx create mode 100644 webapp/src/components/transcript/DiffView.tsx create mode 100644 webapp/src/components/transcript/ThinkingBlock.tsx create mode 100644 webapp/src/components/transcript/blocks.test.tsx diff --git a/webapp/src/components/transcript/CodeBlock.tsx b/webapp/src/components/transcript/CodeBlock.tsx new file mode 100644 index 00000000..f2ccf03e --- /dev/null +++ b/webapp/src/components/transcript/CodeBlock.tsx @@ -0,0 +1,14 @@ +export function CodeBlock({ code, lang }: { code: string; lang?: string }) { + return ( +
+ {lang && ( +
+ {lang} +
+ )} +
+        {code}
+      
+
+ ) +} diff --git a/webapp/src/components/transcript/DiffView.tsx b/webapp/src/components/transcript/DiffView.tsx new file mode 100644 index 00000000..8f199633 --- /dev/null +++ b/webapp/src/components/transcript/DiffView.tsx @@ -0,0 +1,21 @@ +export function DiffView({ text }: { text: string }) { + const lines = text.replace(/\n$/, '').split('\n') + return ( +
+ {lines.map((line, i) => { + const cls = line.startsWith('@@') + ? 'diff-hunk text-accent-2' + : line.startsWith('+') + ? 'diff-add bg-ok/10 text-ok' + : line.startsWith('-') + ? 'diff-del bg-accent/10 text-accent-2' + : 'diff-ctx text-ink-2' + return ( +
+ {line || ' '} +
+ ) + })} +
+ ) +} diff --git a/webapp/src/components/transcript/ThinkingBlock.tsx b/webapp/src/components/transcript/ThinkingBlock.tsx new file mode 100644 index 00000000..5bf0adb1 --- /dev/null +++ b/webapp/src/components/transcript/ThinkingBlock.tsx @@ -0,0 +1,18 @@ +import { Brain, ChevronRight } from 'lucide-react' +import { useState } from 'react' + +export function ThinkingBlock({ text }: { text: string }) { + const [open, setOpen] = useState(false) + return ( +
+ + {open &&
{text}
} +
+ ) +} diff --git a/webapp/src/components/transcript/blocks.test.tsx b/webapp/src/components/transcript/blocks.test.tsx new file mode 100644 index 00000000..7817749c --- /dev/null +++ b/webapp/src/components/transcript/blocks.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' +import { DiffView } from './DiffView' +import { ThinkingBlock } from './ThinkingBlock' + +describe('DiffView', () => { + it('tags added and removed lines', () => { + const { container } = render() + expect(container.querySelector('.diff-add')?.textContent).toContain('+new') + expect(container.querySelector('.diff-del')?.textContent).toContain('-old') + expect(container.querySelector('.diff-hunk')?.textContent).toContain('@@') + }) +}) + +describe('ThinkingBlock', () => { + it('is collapsed by default and expands on click', async () => { + render() + expect(screen.queryByText('secret reasoning')).toBeNull() + await userEvent.click(screen.getByRole('button', { name: /thinking/i })) + expect(screen.getByText('secret reasoning')).toBeInTheDocument() + }) +}) From 0eee24710025efda7802226c1c017ad3a0c90f0d Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:13:21 +0900 Subject: [PATCH 07/18] feat(webapp): tool block with per-tool rendering and diffs --- .../components/transcript/ToolBlock.test.tsx | 51 ++++++++++ .../src/components/transcript/ToolBlock.tsx | 99 +++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 webapp/src/components/transcript/ToolBlock.test.tsx create mode 100644 webapp/src/components/transcript/ToolBlock.tsx diff --git a/webapp/src/components/transcript/ToolBlock.test.tsx b/webapp/src/components/transcript/ToolBlock.test.tsx new file mode 100644 index 00000000..3990dc69 --- /dev/null +++ b/webapp/src/components/transcript/ToolBlock.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import type { TranscriptBlock } from '../../lib/transcript' +import { ToolBlock } from './ToolBlock' + +type ToolUse = Extract + +function tool(over: Partial): ToolUse { + return { type: 'tool_use', id: 't1', name: 'Bash', input: {}, result: null, ...over } +} + +describe('ToolBlock', () => { + it('shows the tool name and a Bash command header', () => { + render() + expect(screen.getByText('Bash')).toBeInTheDocument() + expect(screen.getByText('go test ./...')).toBeInTheDocument() + }) + + it('renders a diff for Edit results and reveals output on expand', async () => { + const block = tool({ + name: 'Edit', + input: { file_path: '/x.go' }, + result: { content: '@@ -1 +1 @@\n-a\n+b', is_error: false, subagent_session_id: null }, + }) + const { container } = render() + await userEvent.click(screen.getByRole('button', { name: /Edit/i })) + expect(container.querySelector('.diff-add')).not.toBeNull() + }) + + it('marks errored results', async () => { + const block = tool({ result: { content: 'boom', is_error: true, subagent_session_id: null } }) + render() + await userEvent.click(screen.getByRole('button', { name: /Bash/i })) + expect(screen.getByText('boom')).toBeInTheDocument() + expect(screen.getByTestId('tool-error')).toBeInTheDocument() + }) + + it('offers a subagent link and fires the callback', async () => { + const onOpen = vi.fn() + const block = tool({ + name: 'Task', + input: { subagent_type: 'Explore', prompt: 'find x' }, + result: { content: 'done', is_error: false, subagent_session_id: 'child-9' }, + }) + render() + await userEvent.click(screen.getByRole('button', { name: /Task/i })) + await userEvent.click(screen.getByRole('button', { name: /view subagent/i })) + expect(onOpen).toHaveBeenCalledWith('child-9') + }) +}) diff --git a/webapp/src/components/transcript/ToolBlock.tsx b/webapp/src/components/transcript/ToolBlock.tsx new file mode 100644 index 00000000..1b3fd4a2 --- /dev/null +++ b/webapp/src/components/transcript/ToolBlock.tsx @@ -0,0 +1,99 @@ +import { ChevronRight, CornerDownRight, Wrench } from 'lucide-react' +import { useState } from 'react' +import type { TranscriptBlock } from '../../lib/transcript' +import { CodeBlock } from './CodeBlock' +import { DiffView } from './DiffView' + +type ToolUse = Extract + +/** One-line summary of the tool's input, agentsview-style. */ +function headline(block: ToolUse): string { + const i = block.input + const s = (k: string) => (typeof i[k] === 'string' ? (i[k] as string) : '') + switch (block.name) { + case 'Bash': + case 'run_command': + return s('command') || s('cmd') + case 'Read': + case 'Edit': + case 'MultiEdit': + case 'Write': + case 'Update': + case 'NotebookEdit': + return s('file_path') || s('path') || s('notebook_path') + case 'Grep': + return s('pattern') + case 'Glob': + return s('pattern') || s('glob') + case 'Task': + case 'Agent': + return s('subagent_type') || s('description') || s('prompt').slice(0, 80) + default: + return '' + } +} + +function ResultBody({ block }: { block: ToolUse }) { + const r = block.result + if (!r) return

no output captured

+ const isEdit = ['Edit', 'MultiEdit', 'Write', 'Update'].includes(block.name) + if (isEdit && /^@@|\n[+-]/.test(r.content)) return + if (r.is_error) { + return ( +
+        {r.content}
+      
+ ) + } + return +} + +export function ToolBlock({ + block, + onOpenSubagent, +}: { + block: ToolUse + onOpenSubagent?: (sessionId: string) => void +}) { + const [open, setOpen] = useState(false) + const head = headline(block) + const child = block.result?.subagent_session_id ?? null + return ( +
+ + {open && ( +
+ {Object.keys(block.input).length > 0 && ( +
+ + input + + +
+ )} + + {child && onOpenSubagent && ( + + )} +
+ )} +
+ ) +} From 47ae3545d1c5274ef87532824871ce13d0071007 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:15:27 +0900 Subject: [PATCH 08/18] feat(webapp): sessions tab with full transcript viewer --- webapp/src/App.tsx | 2 + webapp/src/components/Shell.tsx | 4 +- .../components/transcript/MessageBlock.tsx | 35 ++++++ webapp/src/views/SessionsView.test.tsx | 83 ++++++++++++++ webapp/src/views/SessionsView.tsx | 71 ++++++++++++ webapp/src/views/TranscriptView.tsx | 104 ++++++++++++++++++ 6 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 webapp/src/components/transcript/MessageBlock.tsx create mode 100644 webapp/src/views/SessionsView.test.tsx create mode 100644 webapp/src/views/SessionsView.tsx create mode 100644 webapp/src/views/TranscriptView.tsx diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 76860246..b3d0f228 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -8,6 +8,7 @@ import { ChatView } from './views/ChatView' import { ClaimsView } from './views/ClaimsView' import { PendingView } from './views/PendingView' import { ReviewView } from './views/ReviewView' +import { SessionsView } from './views/SessionsView' import { StatsView } from './views/StatsView' export default function App() { @@ -24,6 +25,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/webapp/src/components/Shell.tsx b/webapp/src/components/Shell.tsx index c51e9a6c..da5edb2d 100644 --- a/webapp/src/components/Shell.tsx +++ b/webapp/src/components/Shell.tsx @@ -1,4 +1,4 @@ -import { Activity, BadgeCheck, FileClock, Inbox, Library, MessageSquare, Plug, SunMoon } from 'lucide-react' +import { Activity, BadgeCheck, FileClock, Inbox, Library, MessageSquare, Plug, ScrollText, SunMoon } from 'lucide-react' import { useEffect, useState } from 'react' import { NavLink, Outlet, useLocation } from 'react-router-dom' import { ConnectDialog } from '../connection/ConnectDialog' @@ -14,6 +14,7 @@ const NAV = [ { to: '/pending', label: 'Pending', icon: Inbox }, { to: '/claims', label: 'Claims', icon: BadgeCheck }, { to: '/browse', label: 'Browse', icon: Library }, + { to: '/sessions', label: 'Sessions', icon: ScrollText }, { to: '/stats', label: 'Stats', icon: Activity }, ] @@ -23,6 +24,7 @@ const TITLES: Record = { '/pending': 'Pending review', '/claims': 'Approved claims', '/browse': 'Knowledge', + '/sessions': 'Session transcripts', '/stats': 'Stats & health', } diff --git a/webapp/src/components/transcript/MessageBlock.tsx b/webapp/src/components/transcript/MessageBlock.tsx new file mode 100644 index 00000000..689260d2 --- /dev/null +++ b/webapp/src/components/transcript/MessageBlock.tsx @@ -0,0 +1,35 @@ +import { Bot, User } from 'lucide-react' +import type { TranscriptMessage } from '../../lib/transcript' +import { Markdown } from '../Markdown' +import { ThinkingBlock } from './ThinkingBlock' +import { ToolBlock } from './ToolBlock' + +export function MessageBlock({ + message, + onOpenSubagent, +}: { + message: TranscriptMessage + onOpenSubagent?: (sessionId: string) => void +}) { + const isUser = message.role === 'user' + return ( +
+
+ {isUser ? : } + {isUser ? 'user' : 'assistant'} + {message.model && {message.model}} +
+
+ {message.blocks.map((b, i) => { + if (b.type === 'thinking') return + if (b.type === 'tool_use') return + return ( +
+ {b.text} +
+ ) + })} +
+
+ ) +} diff --git a/webapp/src/views/SessionsView.test.tsx b/webapp/src/views/SessionsView.test.tsx new file mode 100644 index 00000000..62d128d8 --- /dev/null +++ b/webapp/src/views/SessionsView.test.tsx @@ -0,0 +1,83 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { fetchCapabilities, fetchHealth, rpc } from '../lib/rpc' +import { renderWithProviders, seedConnection } from '../test/utils' +import { SessionsView } from './SessionsView' + +const CAPS = { + name: 'vouch', + version: '1', + level: 3, + methods: ['kb.list_sessions', 'kb.session_transcript'], + review_gated: true, +} + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS as never) + seedConnection() +}) + +describe('SessionsView', () => { + it('lists sessions and renders a picked transcript', async () => { + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.list_sessions') { + return { + sessions: [ + { + session_id: 'sid-1', + stage: 'buffer', + proposal_id: null, + kind: null, + title: 'Fix parser', + summarized: false, + observations: 3, + last_activity: '2026-07-10T00:00:00Z', + }, + ], + } + } + if (method === 'kb.session_transcript') { + return { + available: true, + source: { agent: 'claude', path: '/x' }, + session: { + id: 'sid-1', + agent: 'claude', + cwd: '/repo', + git_branch: 'main', + title: 'Fix parser', + started_at: null, + ended_at: null, + model: 'claude-opus-4-8', + tokens: { input: 1, output: 1, cache_read: 0, cache_creation: 0 }, + }, + messages: [ + { + role: 'assistant', + id: 'm1', + model: 'claude-opus-4-8', + timestamp: null, + tokens: null, + blocks: [{ type: 'text', text: 'hello from claude' }], + }, + ], + truncated: false, + } + } + return {} + }) + renderWithProviders(, { route: '/sessions' }) + await waitFor(() => expect(screen.getByText('Fix parser')).toBeInTheDocument()) + await userEvent.click(screen.getByText('Fix parser')) + await waitFor(() => expect(screen.getByText('hello from claude')).toBeInTheDocument()) + }) +}) diff --git a/webapp/src/views/SessionsView.tsx b/webapp/src/views/SessionsView.tsx new file mode 100644 index 00000000..2f2cc60b --- /dev/null +++ b/webapp/src/views/SessionsView.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { EmptyState } from '../components/EmptyState' +import { useConnection } from '../connection/ConnectionContext' +import { useFanout } from '../lib/fanout' +import type { SessionEntry, VouchConnectionInfo } from '../lib/types' +import { TranscriptView } from './TranscriptView' + +interface Row { + conn: VouchConnectionInfo + label: string + s: SessionEntry +} + +export function SessionsView() { + const { hasMethod } = useConnection() + const sessions = useFanout<{ sessions: SessionEntry[] }>(['sessions'], 'kb.list_sessions', {}, { + refetchInterval: 10_000, + }) + const rows: Row[] = sessions.rows.flatMap((r) => + (r.data?.sessions ?? []).map((s) => ({ conn: r.project.conn, label: r.project.label, s })), + ) + const [sel, setSel] = useState(null) + + return ( +
+ +
+ {sel && sel.s.session_id ? ( + + ) : ( +
+ +
+ )} +
+
+ ) +} diff --git a/webapp/src/views/TranscriptView.tsx b/webapp/src/views/TranscriptView.tsx new file mode 100644 index 00000000..0ca86be1 --- /dev/null +++ b/webapp/src/views/TranscriptView.tsx @@ -0,0 +1,104 @@ +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { EmptyState } from '../components/EmptyState' +import { ErrorCard } from '../components/ErrorCard' +import { MessageBlock } from '../components/transcript/MessageBlock' +import { VouchRpcError } from '../lib/rpc' +import { fetchTranscript } from '../lib/transcript' +import type { Observation } from '../lib/transcript' +import type { VouchConnectionInfo } from '../lib/types' + +function Degraded({ reason, observations }: { reason: string; observations: Observation[] }) { + return ( +
+
+ original transcript unavailable — {reason}. Showing captured activity. +
+ {observations.length === 0 ? ( + + ) : ( +
    + {observations.map((o, i) => ( +
  1. + {o.tool} + {o.summary} +
  2. + ))} +
+ )} +
+ ) +} + +export function TranscriptView({ + conn, + sessionId, + agent, +}: { + conn: VouchConnectionInfo + sessionId: string + agent?: string +}) { + // Subagent drill-down replaces the shown transcript with the child's, + // keeping a back stack to the parent session. + const [stack, setStack] = useState<{ id: string; agent?: string }[]>([{ id: sessionId, agent }]) + const top = stack[stack.length - 1] + const q = useQuery({ + queryKey: ['transcript', conn.endpoint, top.id], + queryFn: () => fetchTranscript(conn, top.id, top.agent), + }) + + if (q.isPending) return
Loading transcript…
+ if (q.isError) { + const e = q.error + return ( +
+ +
+ ) + } + const t = q.data + return ( +
+ {stack.length > 1 && ( + + )} + {!t.available ? ( + + ) : ( + <> +
+ {t.session.model && {t.session.model}} + {t.session.cwd && {t.session.cwd}} + {t.session.git_branch && ⎇ {t.session.git_branch}} + {t.session.tokens.input + t.session.tokens.output} tokens + {t.source.agent} +
+ {t.truncated && ( +
+ transcript truncated at {t.messages.length} messages +
+ )} + {t.messages.map((m, i) => ( + setStack((s) => [...s, { id, agent: t.source.agent }])} + /> + ))} + + )} +
+ ) +} From b1d10b72945ac1096e7ad849d4175e7b0878058b Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:18:55 +0900 Subject: [PATCH 09/18] feat(transcript): parse Codex rollouts into normalized blocks --- src/vouch/transcript.py | 127 ++++++++++++++++++++++++++++++- tests/test_session_transcript.py | 70 +++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) diff --git a/src/vouch/transcript.py b/src/vouch/transcript.py index 8d9ebc93..5ca10d34 100644 --- a/src/vouch/transcript.py +++ b/src/vouch/transcript.py @@ -235,8 +235,133 @@ def flush() -> None: return {"session": session, "messages": messages, "truncated": truncated} +def _codex_message_text(content: Any) -> str: + """Join a codex message's content parts (input_text / output_text / text).""" + if isinstance(content, str): + return content.strip() + if not isinstance(content, list): + return "" + parts = [ + str(p.get("text", "")) + for p in content + if isinstance(p, dict) and p.get("type") in ("input_text", "output_text", "text") + ] + return "\n".join(x for x in parts if x).strip() + + def parse_codex_transcript(path: Path, *, max_messages: int = 2000) -> dict[str, Any]: - raise NotImplementedError("codex transcript parsing lands in Task 9") + """Parse a Codex rollout JSONL into the normalized transcript schema. + + The canonical conversation is the sequence of ``response_item`` records: + ``message`` (role user/assistant; developer/system boilerplate skipped), + ``function_call`` / ``custom_tool_call`` and their ``*_output`` pairs, and + ``reasoning`` (encrypted, so dropped). Assistant activity between user + messages groups into one assistant message; an output pairs into its call + by ``call_id``. ``session_meta`` supplies cwd / branch / timestamps. + """ + session: dict[str, Any] = { + "id": path.stem, "agent": "codex", "cwd": None, "git_branch": None, + "title": None, "started_at": None, "ended_at": None, "model": None, + "tokens": {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}, + } + messages: list[dict[str, Any]] = [] + tool_by_call: dict[str, dict[str, Any]] = {} + truncated = False + current: dict[str, Any] | None = None + + def new_assistant() -> dict[str, Any]: + return { + "role": "assistant", "id": None, "model": None, + "timestamp": None, "tokens": None, "blocks": [], + } + + def flush() -> None: + nonlocal current + if current is not None and current["blocks"]: + messages.append(current) + current = None + + with path.open(encoding="utf-8") as fh: + for raw in fh: + raw = raw.strip() + if not raw: + continue + try: + rec = json.loads(raw) + except json.JSONDecodeError: + continue + if not isinstance(rec, dict): + continue + payload = rec.get("payload") + if not isinstance(payload, dict): + continue + rtype = rec.get("type") + + if rtype == "session_meta": + sid = payload.get("id") or payload.get("session_id") + if isinstance(sid, str) and sid.strip(): + session["id"] = sid.strip() + if isinstance(payload.get("cwd"), str): + session["cwd"] = payload["cwd"] + ts = payload.get("timestamp") + if isinstance(ts, str): + session["started_at"] = ts + session["ended_at"] = ts + git = payload.get("git") + if isinstance(git, dict) and isinstance(git.get("branch"), str): + session["git_branch"] = git["branch"] + continue + + if rtype != "response_item": + continue + if len(messages) >= max_messages: + truncated = True + break + ptype = payload.get("type") + + if ptype == "message": + role = payload.get("role") + text = _codex_message_text(payload.get("content")) + if role == "user": + flush() + if text: + messages.append({ + "role": "user", "id": None, "model": None, + "timestamp": None, "tokens": None, + "blocks": [{"type": "text", "text": text}], + }) + elif role == "assistant": + if current is None: + current = new_assistant() + if text: + current["blocks"].append({"type": "text", "text": text}) + # developer / system messages are instruction boilerplate: skip. + elif ptype in ("function_call", "custom_tool_call"): + if current is None: + current = new_assistant() + cid = payload.get("call_id") + if ptype == "function_call": + tool_input: dict[str, Any] = {"arguments": payload.get("arguments")} + else: + tool_input = {"input": payload.get("input")} + block: dict[str, Any] = { + "type": "tool_use", "id": cid, + "name": str(payload.get("name", "")), + "input": tool_input, "result": None, + } + current["blocks"].append(block) + if isinstance(cid, str): + tool_by_call[cid] = block + elif ptype in ("function_call_output", "custom_tool_call_output"): + cid = payload.get("call_id") + paired = tool_by_call.get(cid) if isinstance(cid, str) else None + if paired is not None: + paired["result"] = { + "content": str(payload.get("output", "")), + "is_error": False, "subagent_session_id": None, + } + flush() + return {"session": session, "messages": messages, "truncated": truncated} def _degraded(store: KBStore, session_id: str, reason: str) -> dict[str, Any]: diff --git a/tests/test_session_transcript.py b/tests/test_session_transcript.py index 367b36d1..19c0a6d9 100644 --- a/tests/test_session_transcript.py +++ b/tests/test_session_transcript.py @@ -205,3 +205,73 @@ def test_handler_returns_degraded_when_absent() -> None: }) assert resp["ok"] is True assert resp["result"]["available"] is False + + +# --- Task 9: Codex parser ------------------------------------------------- + +_CODEX_LINES = [ + {"type": "session_meta", "payload": { + "id": "cx-1", "cwd": "/repo", "timestamp": "2026-06-22T08:01:54Z", + "git": {"branch": "feat/x"}}}, + {"type": "response_item", "payload": { + "type": "message", "role": "developer", + "content": [{"type": "input_text", "text": "boilerplate"}]}}, + {"type": "response_item", "payload": { + "type": "message", "role": "user", + "content": [{"type": "input_text", "text": "run the tests"}]}}, + {"type": "response_item", "payload": { + "type": "reasoning", "encrypted_content": "gAAA...", "summary": []}}, + {"type": "response_item", "payload": { + "type": "message", "role": "assistant", + "content": [{"type": "output_text", "text": "Running them now."}]}}, + {"type": "response_item", "payload": { + "type": "function_call", "name": "exec_command", + "arguments": "{\"cmd\": \"pytest\"}", "call_id": "call_1"}}, + {"type": "response_item", "payload": { + "type": "function_call_output", "call_id": "call_1", "output": "1 passed"}}, + {"type": "response_item", "payload": { + "type": "custom_tool_call", "name": "apply_patch", + "input": "*** Begin Patch", "call_id": "call_2"}}, + {"type": "response_item", "payload": { + "type": "custom_tool_call_output", "call_id": "call_2", "output": "Success"}}, +] + + +def test_parse_codex_pairs_calls_and_skips_boilerplate(tmp_path: Path) -> None: + f = tmp_path / "rollout-x.jsonl" + _write_jsonl(f, _CODEX_LINES) + out = transcript.parse_codex_transcript(f) + + assert out["session"]["agent"] == "codex" + assert out["session"]["cwd"] == "/repo" + assert out["session"]["git_branch"] == "feat/x" + + roles = [m["role"] for m in out["messages"]] + assert roles == ["user", "assistant"] # developer message skipped + + assistant = out["messages"][1] + types = [b["type"] for b in assistant["blocks"]] + assert types == ["text", "tool_use", "tool_use"] # reasoning (encrypted) skipped + + exec_call = assistant["blocks"][1] + assert exec_call["name"] == "exec_command" + assert exec_call["result"]["content"] == "1 passed" + patch_call = assistant["blocks"][2] + assert patch_call["name"] == "apply_patch" + assert patch_call["result"]["content"] == "Success" + + +def test_load_transcript_codex_source( + tmp_path: Path, store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + # No Claude file exists; a Codex rollout does -> load_transcript finds it. + codex_home = tmp_path / "codex" + sid = "019eec6b-4a0c-7ad0-afd1-68973c902231" + day = codex_home / "sessions" / "2026" / "06" / "22" + roll = day / f"rollout-2026-06-22T08-01-54-{sid}.jsonl" + _write_jsonl(roll, _CODEX_LINES) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(tmp_path / "no-claude")) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + out = transcript.load_transcript(store, sid) + assert out["available"] is True + assert out["source"]["agent"] == "codex" From b496ce803f68fc7f1fa87f888ef3abfcf0871c01 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:19:38 +0900 Subject: [PATCH 10/18] test(webapp): cover degraded render and subagent drill-down --- webapp/src/views/TranscriptView.test.tsx | 74 ++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 webapp/src/views/TranscriptView.test.tsx diff --git a/webapp/src/views/TranscriptView.test.tsx b/webapp/src/views/TranscriptView.test.tsx new file mode 100644 index 00000000..6558ee06 --- /dev/null +++ b/webapp/src/views/TranscriptView.test.tsx @@ -0,0 +1,74 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { rpc } from '../lib/rpc' +import { renderWithProviders } from '../test/utils' +import { TranscriptView } from './TranscriptView' + +const conn = { endpoint: 'http://127.0.0.1:8731' } + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('TranscriptView', () => { + it('renders the degraded observation timeline', async () => { + vi.mocked(rpc).mockResolvedValue({ + available: false, + reason: 'raw transcript not found', + observations: [{ ts: 1, tool: 'Edit', summary: 'Edited types.go' }], + }) + renderWithProviders() + await waitFor(() => expect(screen.getByText('Edited types.go')).toBeInTheDocument()) + expect(screen.getByText(/original transcript unavailable/i)).toBeInTheDocument() + }) + + it('drills into a subagent and back', async () => { + vi.mocked(rpc).mockImplementation(async (_c, _m, params) => { + const id = (params as { session_id: string }).session_id + const blocks = + id === 'child-9' + ? [{ type: 'text', text: 'child says hi' }] + : [ + { + type: 'tool_use', + id: 't1', + name: 'Task', + input: { prompt: 'go' }, + result: { content: 'done', is_error: false, subagent_session_id: 'child-9' }, + }, + ] + return { + available: true, + source: { agent: 'claude', path: '/x' }, + session: { + id, + agent: 'claude', + cwd: null, + git_branch: null, + title: null, + started_at: null, + ended_at: null, + model: null, + tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0 }, + }, + messages: [ + { role: 'assistant', id: 'm', model: null, timestamp: null, tokens: null, blocks }, + ], + truncated: false, + } + }) + renderWithProviders() + await waitFor(() => expect(screen.getByText('Task')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /Task/i })) + await userEvent.click(screen.getByRole('button', { name: /view subagent/i })) + await waitFor(() => expect(screen.getByText('child says hi')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /back to parent/i })) + await waitFor(() => expect(screen.getByText('Task')).toBeInTheDocument()) + }) +}) From 7adc267b1a3c630945f128d53f6a9986a915db59 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:24:21 +0900 Subject: [PATCH 11/18] test(webapp): e2e smoke for the sessions transcript tab --- webapp/e2e/sessions.spec.ts | 95 +++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 webapp/e2e/sessions.spec.ts diff --git a/webapp/e2e/sessions.spec.ts b/webapp/e2e/sessions.spec.ts new file mode 100644 index 00000000..d6759e8c --- /dev/null +++ b/webapp/e2e/sessions.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test' + +// Drives the real frontend (routing, SessionsView, TranscriptView, block +// renderers) against stubbed /proxy responses, so it is independent of the +// backend build. The connection is seeded into localStorage; health and +// capabilities are stubbed so the project comes up "ok" with the transcript +// method advertised, then list_sessions + session_transcript are served. +const ENDPOINT = 'http://127.0.0.1:8971' +const CAPS = { + name: 'vouch', + version: '1.2.2', + level: 3, + methods: ['kb.list_sessions', 'kb.session_transcript'], + review_gated: true, +} + +test('sessions tab renders a picked transcript', async ({ page }) => { + await page.addInitScript((endpoint) => { + localStorage.setItem( + 'vouch-ui.connections.v2', + JSON.stringify({ projects: [{ endpoint }], scope: 'all' }), + ) + }, ENDPOINT) + + await page.route('**/proxy/health', (route) => route.fulfill({ json: { ok: true } })) + await page.route('**/proxy/capabilities', (route) => route.fulfill({ json: CAPS })) + await page.route('**/proxy/rpc', async (route) => { + const body = route.request().postDataJSON() as { method: string } + if (body.method === 'kb.list_pending') { + // Shell fans this out for its nav badge; it expects an array. + return route.fulfill({ json: { ok: true, id: '1', result: [] } }) + } + if (body.method === 'kb.list_sessions') { + return route.fulfill({ + json: { + ok: true, + id: '1', + result: { + sessions: [ + { + session_id: 'e2e-sid', + stage: 'buffer', + proposal_id: null, + kind: null, + title: 'e2e session', + summarized: false, + observations: 2, + last_activity: '2026-07-10T00:00:00Z', + }, + ], + }, + }, + }) + } + if (body.method === 'kb.session_transcript') { + return route.fulfill({ + json: { + ok: true, + id: '1', + result: { + available: true, + source: { agent: 'claude', path: '/x' }, + session: { + id: 'e2e-sid', + agent: 'claude', + cwd: '/repo', + git_branch: 'main', + title: 'e2e session', + started_at: null, + ended_at: null, + model: 'claude-opus-4-8', + tokens: { input: 1, output: 1, cache_read: 0, cache_creation: 0 }, + }, + messages: [ + { + role: 'assistant', + id: 'm', + model: 'claude-opus-4-8', + timestamp: null, + tokens: null, + blocks: [{ type: 'text', text: 'e2e transcript body' }], + }, + ], + truncated: false, + }, + }, + }) + } + return route.fulfill({ json: { ok: true, id: '1', result: {} } }) + }) + + await page.goto('/sessions') + await page.getByText('e2e session').click() + await expect(page.getByText('e2e transcript body')).toBeVisible() +}) From 7c39f74677c1d3b5ecb0e191bb4ce6cb465e2f9c Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:26:27 +0900 Subject: [PATCH 12/18] docs(transcript): session transcript viewer design and plan --- .../2026-07-10-session-transcript-viewer.md | 1745 +++++++++++++++++ ...-07-10-session-transcript-viewer-design.md | 334 ++++ 2 files changed, 2079 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-session-transcript-viewer.md create mode 100644 docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md diff --git a/docs/superpowers/plans/2026-07-10-session-transcript-viewer.md b/docs/superpowers/plans/2026-07-10-session-transcript-viewer.md new file mode 100644 index 00000000..08bde17e --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-session-transcript-viewer.md @@ -0,0 +1,1745 @@ +# Session Transcript Viewer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a read-only Session Transcript Viewer to the vouch console that opens a captured Claude Code / Codex session and renders its full transcript (thinking, assistant text, tool calls with inputs + outputs, diffs, subagents), faithfully reproducing agentsview's rendering vocabulary. + +**Architecture:** A new `kb.session_transcript` RPC locates the raw agent JSONL on disk on demand, parses it into a normalized block schema, and returns it (degrading to vouch's compact capture observations when the raw file is gone). A new React **Sessions** tab lists sessions (`kb.list_sessions`, fanned out over scoped projects) and renders the selected session's blocks. No sync engine, no database — parsing happens per-open. + +**Tech Stack:** Backend — Python 3.11+, stdlib `json`/`pathlib`, `KBStore`, pytest. Frontend — React 19, TypeScript, Tailwind v4, `@tanstack/react-query`, `react-markdown`, `lucide-react`, vitest + Testing Library, Playwright. + +## Global Constraints + +- Repo: `vouch` at `/home/a/Dev/plind-junior/vouch`. Backend under `src/vouch/`, tests under `tests/`. Frontend under `webapp/`. +- Follow `vouch/AGENTS.md`, NOT agentsview's CLAUDE.md. Conventional commits `(): ` (types: feat|fix|refactor|test|docs|chore|perf|ci|style|build|revert), ≤72-char summary. +- **No `Co-Authored-By: ` trailer** in commits. No secrets or absolute machine paths as PII in commit messages. +- Do NOT switch/create branches without explicit user permission. Current branch is `test`; there are pre-existing uncommitted changes that are NOT ours — `git add` only our own files, never `git add -A`. +- Backend gates before every commit that touches Python: `.venv/bin/python -m pytest tests/ -q --ignore=tests/embeddings`, `.venv/bin/python -m mypy src`, `.venv/bin/python -m ruff check src tests`. +- Frontend gates before every commit that touches `webapp/`: `npm run test` and `npm run build` (from `webapp/`). +- Every new `kb.*` method MUST be added to both `capabilities.METHODS` and `jsonl_server.HANDLERS` or `test_capabilities_matches_jsonl_handlers` fails. +- Tests assert observable behavior, not implementation strings (testing-without-tautologies). Backend uses plain pytest `assert`. + +## Normalized transcript schema (the contract both sides share) + +Returned by `kb.session_transcript`. Tool results are paired into their tool_use block server-side. + +```jsonc +// available === true +{ + "available": true, + "source": { "agent": "claude", "path": "" }, + "session": { + "id": "…", "agent": "claude", + "cwd": "…"|null, "git_branch": "…"|null, "title": "…"|null, + "started_at": "ISO"|null, "ended_at": "ISO"|null, "model": "…"|null, + "tokens": { "input": 0, "output": 0, "cache_read": 0, "cache_creation": 0 } + }, + "messages": [ + { "role": "user"|"assistant", "id": "…"|null, "model": "…"|null, + "timestamp": "ISO"|null, + "tokens": { "input": 0, "output": 0, "cache_read": 0, "cache_creation": 0 }|null, + "blocks": [ + { "type": "text", "text": "…" }, + { "type": "thinking", "text": "…" }, + { "type": "tool_use", "id": "…", "name": "Bash", "input": {…}, + "result": { "content": "…", "is_error": false, + "subagent_session_id": "…"|null }|null } + ] } + ], + "truncated": false +} +// available === false +{ "available": false, "reason": "…", + "observations": [ { "ts": 0.0, "tool": "Edit", "summary": "Edited x.go", + "files": [...]|undefined, "cmd": "…"|undefined } ] } +``` + +--- + +## File Structure + +Backend: +- Create `src/vouch/transcript.py` — locator + Claude parser + Codex parser + `load_transcript` orchestrator. One responsibility: turn a session id into the normalized schema (or a degraded result). +- Modify `src/vouch/jsonl_server.py` — add `_h_session_transcript` handler + register in `HANDLERS`. +- Modify `src/vouch/capabilities.py` — add `"kb.session_transcript"` to `METHODS`. +- Create `tests/test_session_transcript.py` — parser/locator/handler/degradation tests + fixtures inline. + +Frontend (`webapp/`): +- Create `src/lib/transcript.ts` — TS types mirroring the schema + `fetchTranscript(conn, sessionId, agent?)`. +- Create `src/components/transcript/DiffView.tsx`, `CodeBlock.tsx`, `ThinkingBlock.tsx`, `ToolBlock.tsx`, `MessageBlock.tsx` — the block renderers. +- Create `src/views/TranscriptView.tsx` — fetches + renders one session (incl. degraded + subagent lazy-load). +- Create `src/views/SessionsView.tsx` — master–detail list + selection. +- Modify `src/App.tsx` — add `/sessions` route. +- Modify `src/components/Shell.tsx` — add Sessions nav item. +- Create colocated `*.test.tsx` for each component/view. +- Create `webapp/e2e/sessions.spec.ts` — smoke. + +--- + +## PHASE 1 — Backend: Claude locator, parser, RPC + +### Task 1: Claude file locator + +**Files:** +- Create: `src/vouch/transcript.py` +- Test: `tests/test_session_transcript.py` + +**Interfaces:** +- Produces: `find_claude_file(session_id: str) -> Path | None`; `_VALID_ID = re.compile(r"^[0-9a-fA-F-]{8,64}$")`; env override `VOUCH_CLAUDE_PROJECTS_DIR`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_session_transcript.py +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from vouch import transcript + + +def _write_jsonl(path: Path, records: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(json.dumps(r) for r in records) + "\n", encoding="utf-8") + + +def test_find_claude_file_top_level(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path / "projects" + sid = "ad5d5e5f-0097-494c-8316-b01aed5dabf2" + f = root / "-home-a-Dev-agentsview" / f"{sid}.jsonl" + _write_jsonl(f, [{"type": "user", "message": {"role": "user", "content": "hi"}}]) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + assert transcript.find_claude_file(sid) == f + + +def test_find_claude_file_subagent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path / "projects" + parent = "11111111-1111-1111-1111-111111111111" + child = "22222222-2222-2222-2222-222222222222" + f = root / "-proj" / parent / "subagents" / "jobs" / f"{child}.jsonl" + _write_jsonl(f, [{"type": "assistant", "message": {"role": "assistant", "content": []}}]) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + assert transcript.find_claude_file(child) == f + + +def test_find_claude_file_rejects_bad_id(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(tmp_path)) + assert transcript.find_claude_file("../etc/passwd") is None + assert transcript.find_claude_file("*") is None + assert transcript.find_claude_file("") is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -q` +Expected: FAIL with `ModuleNotFoundError: No module named 'vouch.transcript'` / `AttributeError`. + +- [ ] **Step 3: Write minimal implementation** + +```python +# src/vouch/transcript.py +"""Locate and parse raw agent session transcripts on demand. + +Given a captured session id, find the raw JSONL the agent wrote +(Claude Code under ~/.claude/projects, Codex rollouts under +$CODEX_HOME/sessions) and normalize it into a block schema the vouch +console renders. Read-only: never writes to the KB. When the raw file is +gone we degrade to vouch's compact capture observations instead. +""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path +from typing import Any + +# Session ids are UUID-shaped; reject anything else so a hostile id can't +# widen a glob or traverse out of the projects tree. +_VALID_ID = re.compile(r"^[0-9a-fA-F-]{8,64}$") + + +def _claude_projects_root() -> Path: + env = os.environ.get("VOUCH_CLAUDE_PROJECTS_DIR") + return Path(env) if env else Path.home() / ".claude" / "projects" + + +def find_claude_file(session_id: str) -> Path | None: + """The raw Claude Code JSONL for ``session_id``, or None. + + Claude names each session file ``.jsonl`` under a per-cwd project + dir; subagent transcripts live under ``/subagents/**``. The + file stem is the id, so a literal name match (no id interpolation into + a glob) locates it. + """ + if not _VALID_ID.match(session_id): + return None + root = _claude_projects_root() + if not root.is_dir(): + return None + name = f"{session_id}.jsonl" + for project in root.iterdir(): + if not project.is_dir(): + continue + top = project / name + if top.is_file(): + return top + for candidate in root.glob(f"*/*/subagents/**/{name}"): + if candidate.is_file(): + return candidate + return None +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -q` +Expected: PASS (3 passed). + +- [ ] **Step 5: Commit** + +```bash +git add src/vouch/transcript.py tests/test_session_transcript.py +git commit -m "feat(transcript): locate raw Claude session files by id" +``` + +--- + +### Task 2: Claude transcript parser + +**Files:** +- Modify: `src/vouch/transcript.py` +- Test: `tests/test_session_transcript.py` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `parse_claude_transcript(path: Path, *, max_messages: int = 2000) -> dict[str, Any]` returning `{"session": {...}, "messages": [...], "truncated": bool}` per the schema; helper `_norm_tokens(usage: dict) -> dict`. + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_session_transcript.py + +_CLAUDE_LINES = [ + {"type": "user", "cwd": "/repo", "gitBranch": "main", + "timestamp": "2026-07-10T04:44:19.043Z", + "message": {"role": "user", "content": [{"type": "text", "text": "fix the bug"}]}}, + {"type": "ai-title", "aiTitle": "Fix the bug"}, + {"type": "assistant", "timestamp": "2026-07-10T04:44:35.759Z", + "message": {"id": "msg_1", "model": "claude-opus-4-8", "role": "assistant", + "content": [{"type": "thinking", "thinking": "let me look"}], + "usage": {"input_tokens": 100, "output_tokens": 10, + "cache_read_input_tokens": 5, "cache_creation_input_tokens": 2}}}, + {"type": "assistant", "timestamp": "2026-07-10T04:44:36.771Z", + "message": {"id": "msg_1", "model": "claude-opus-4-8", "role": "assistant", + "content": [{"type": "text", "text": "I'll edit it."}], + "usage": {"input_tokens": 100, "output_tokens": 10}}}, + {"type": "assistant", "timestamp": "2026-07-10T04:44:36.772Z", + "message": {"id": "msg_1", "model": "claude-opus-4-8", "role": "assistant", + "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", + "input": {"command": "go test ./..."}}], + "usage": {"input_tokens": 100, "output_tokens": 10}}}, + {"type": "user", + "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "tu_1", "content": "ok\n", "is_error": False}]}}, +] + + +def test_parse_claude_pairs_result_and_merges_by_message_id(tmp_path: Path) -> None: + f = tmp_path / "s.jsonl" + _write_jsonl(f, _CLAUDE_LINES) + out = transcript.parse_claude_transcript(f) + + assert out["session"]["cwd"] == "/repo" + assert out["session"]["git_branch"] == "main" + assert out["session"]["title"] == "Fix the bug" + assert out["session"]["model"] == "claude-opus-4-8" + assert out["truncated"] is False + + roles = [m["role"] for m in out["messages"]] + assert roles == ["user", "assistant"] # tool_result user entry is consumed, not a message + + # the three msg_1 assistant lines merged into one message, in order + a = out["messages"][1] + assert [b["type"] for b in a["blocks"]] == ["thinking", "text", "tool_use"] + tu = a["blocks"][2] + assert tu["name"] == "Bash" and tu["input"] == {"command": "go test ./..."} + assert tu["result"] == {"content": "ok\n", "is_error": False, "subagent_session_id": None} + assert a["tokens"] == {"input": 100, "output": 10, "cache_read": 5, "cache_creation": 2} + + +def test_parse_claude_truncates_at_cap(tmp_path: Path) -> None: + lines = [{"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": f"m{i}"}]}} + for i in range(5)] + f = tmp_path / "big.jsonl" + _write_jsonl(f, lines) + out = transcript.parse_claude_transcript(f, max_messages=3) + assert out["truncated"] is True + assert len(out["messages"]) == 3 + + +def test_parse_claude_tolerates_malformed_lines(tmp_path: Path) -> None: + f = tmp_path / "m.jsonl" + f.write_text('{"bad json\n{"type":"user","message":{"role":"user","content":"hey"}}\n', encoding="utf-8") + out = transcript.parse_claude_transcript(f) + assert len(out["messages"]) == 1 + assert out["messages"][0]["blocks"] == [{"type": "text", "text": "hey"}] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -k parse_claude -q` +Expected: FAIL with `AttributeError: module 'vouch.transcript' has no attribute 'parse_claude_transcript'`. + +- [ ] **Step 3: Write minimal implementation** + +```python +# add to src/vouch/transcript.py + +def _norm_tokens(usage: dict[str, Any]) -> dict[str, int]: + def i(key: str) -> int: + v = usage.get(key) + return int(v) if isinstance(v, (int, float)) else 0 + return { + "input": i("input_tokens"), + "output": i("output_tokens"), + "cache_read": i("cache_read_input_tokens"), + "cache_creation": i("cache_creation_input_tokens"), + } + + +def _result_text(content: Any) -> str: + """tool_result.content is a string, or a list of {type:text,text} parts.""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [str(p.get("text", "")) for p in content + if isinstance(p, dict) and p.get("type") == "text"] + if parts: + return "\n".join(parts) + return json.dumps(content, ensure_ascii=False) + if content is None: + return "" + return json.dumps(content, ensure_ascii=False) + + +def parse_claude_transcript(path: Path, *, max_messages: int = 2000) -> dict[str, Any]: + """Parse a Claude Code JSONL into the normalized transcript schema. + + Single forward pass: assistant content blocks that share a + ``message.id`` merge into one logical message; a later ``tool_result`` + (in a user entry) is paired into the matching ``tool_use`` block by id + and its user entry is not emitted as a standalone message. + """ + messages: list[dict[str, Any]] = [] + tool_by_id: dict[str, dict[str, Any]] = {} + session: dict[str, Any] = { + "id": path.stem, "agent": "claude", "cwd": None, "git_branch": None, + "title": None, "started_at": None, "ended_at": None, "model": None, + "tokens": {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}, + } + truncated = False + current: dict[str, Any] | None = None + + def flush() -> None: + nonlocal current + if current is not None and current["blocks"]: + messages.append(current) + current = None + + with path.open(encoding="utf-8") as fh: + for raw in fh: + raw = raw.strip() + if not raw: + continue + try: + obj = json.loads(raw) + except json.JSONDecodeError: + continue + if not isinstance(obj, dict): + continue + if session["cwd"] is None and isinstance(obj.get("cwd"), str): + session["cwd"] = obj["cwd"] + if session["git_branch"] is None and isinstance(obj.get("gitBranch"), str): + session["git_branch"] = obj["gitBranch"] + ts = obj.get("timestamp") + if isinstance(ts, str): + if session["started_at"] is None: + session["started_at"] = ts + session["ended_at"] = ts + t = obj.get("type") + if t == "ai-title" and isinstance(obj.get("aiTitle"), str): + session["title"] = obj["aiTitle"] + continue + if t not in ("user", "assistant"): + continue + if len(messages) >= max_messages: + truncated = True + break + msg = obj.get("message") + if not isinstance(msg, dict): + continue + content = msg.get("content") + + if t == "assistant": + mid = msg.get("id") if isinstance(msg.get("id"), str) else None + if current is None or current.get("id") != mid: + flush() + usage = msg.get("usage") if isinstance(msg.get("usage"), dict) else {} + model = msg.get("model") if isinstance(msg.get("model"), str) else None + if model and session["model"] is None: + session["model"] = model + current = {"role": "assistant", "id": mid, "model": model, + "timestamp": ts if isinstance(ts, str) else None, + "tokens": _norm_tokens(usage), "blocks": []} + tok = current["tokens"] + for k in session["tokens"]: + session["tokens"][k] += tok[k] + parts = content if isinstance(content, list) else [] + for part in parts: + if not isinstance(part, dict): + continue + ptype = part.get("type") + if ptype == "thinking": + text = str(part.get("thinking", "")).strip() + if text: + current["blocks"].append({"type": "thinking", "text": text}) + elif ptype == "text": + text = str(part.get("text", "")).strip() + if text: + current["blocks"].append({"type": "text", "text": text}) + elif ptype == "tool_use": + tid = part.get("id") + block = {"type": "tool_use", "id": tid, + "name": str(part.get("name", "")), + "input": part.get("input") if isinstance(part.get("input"), dict) else {}, + "result": None} + current["blocks"].append(block) + if isinstance(tid, str): + tool_by_id[tid] = block + continue + + # user entry + flush() + if isinstance(content, str): + text = content.strip() + if text: + messages.append({"role": "user", "id": None, "model": None, + "timestamp": ts if isinstance(ts, str) else None, + "tokens": None, "blocks": [{"type": "text", "text": text}]}) + continue + parts = content if isinstance(content, list) else [] + user_blocks: list[dict[str, Any]] = [] + agent_id = None + tur = obj.get("toolUseResult") + if isinstance(tur, dict) and isinstance(tur.get("agentId"), str): + agent_id = tur["agentId"] + for part in parts: + if not isinstance(part, dict): + continue + if part.get("type") == "tool_result": + tid = part.get("tool_use_id") + block = tool_by_id.get(tid) if isinstance(tid, str) else None + if block is not None: + block["result"] = { + "content": _result_text(part.get("content")), + "is_error": bool(part.get("is_error", False)), + "subagent_session_id": agent_id, + } + elif part.get("type") == "text": + text = str(part.get("text", "")).strip() + if text: + user_blocks.append({"type": "text", "text": text}) + if user_blocks: + messages.append({"role": "user", "id": None, "model": None, + "timestamp": ts if isinstance(ts, str) else None, + "tokens": None, "blocks": user_blocks}) + flush() + return {"session": session, "messages": messages, "truncated": truncated} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -k parse_claude -q` +Expected: PASS (3 passed). + +- [ ] **Step 5: Run mypy + ruff** + +Run: `.venv/bin/python -m mypy src && .venv/bin/python -m ruff check src tests` +Expected: no errors. (Fix any typing nits inline, e.g. annotate `parts: list[Any]`.) + +- [ ] **Step 6: Commit** + +```bash +git add src/vouch/transcript.py tests/test_session_transcript.py +git commit -m "feat(transcript): parse Claude JSONL into normalized blocks" +``` + +--- + +### Task 3: `load_transcript` orchestrator with degradation + size cap + +**Files:** +- Modify: `src/vouch/transcript.py` +- Test: `tests/test_session_transcript.py` + +**Interfaces:** +- Consumes: `find_claude_file`, `parse_claude_transcript`, `capture.buffer_path`, `capture._read_observations`. +- Produces: `load_transcript(store: KBStore, session_id: str, *, agent: str | None = None) -> dict[str, Any]` returning either the available schema (with `source`) or the degraded schema. +- Constants: `MAX_FILE_BYTES = 25 * 1024 * 1024`, `MAX_MESSAGES = 2000`. + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_session_transcript.py +from vouch import capture +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def test_load_transcript_available(tmp_path: Path, store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path / "projects" + sid = "ad5d5e5f-0097-494c-8316-b01aed5dabf2" + f = root / "-repo" / f"{sid}.jsonl" + _write_jsonl(f, _CLAUDE_LINES) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + out = transcript.load_transcript(store, sid) + assert out["available"] is True + assert out["source"] == {"agent": "claude", "path": str(f)} + assert out["session"]["title"] == "Fix the bug" + + +def test_load_transcript_degrades_to_observations(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(store.kb_dir / "none")) + sid = "99999999-9999-9999-9999-999999999999" + capture.observe(store, sid, tool="Edit", summary="Edited x.go") + out = transcript.load_transcript(store, sid) + assert out["available"] is False + assert out["observations"][0]["tool"] == "Edit" + assert "reason" in out + + +def test_load_transcript_degrades_when_oversized(tmp_path: Path, store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path / "projects" + sid = "88888888-8888-8888-8888-888888888888" + f = root / "-repo" / f"{sid}.jsonl" + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text("x" * 32, encoding="utf-8") + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + monkeypatch.setattr(transcript, "MAX_FILE_BYTES", 16) + out = transcript.load_transcript(store, sid) + assert out["available"] is False + assert "too large" in out["reason"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -k load_transcript -q` +Expected: FAIL (`load_transcript` undefined). + +- [ ] **Step 3: Write minimal implementation** + +```python +# add to src/vouch/transcript.py (add import at top: from .capture import _read_observations, buffer_path) +# and: from .storage import KBStore (TYPE_CHECKING is fine, but a runtime import is used by callers) + +MAX_FILE_BYTES = 25 * 1024 * 1024 +MAX_MESSAGES = 2000 + + +def _degraded(store: "KBStore", session_id: str, reason: str) -> dict[str, Any]: + obs = _read_observations(buffer_path(store, session_id)) + return {"available": False, "reason": reason, "observations": obs} + + +def load_transcript( + store: "KBStore", 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. + """ + path: Path | None = None + source_agent = "" + if agent in (None, "claude"): + path = find_claude_file(session_id) + if path is not None: + source_agent = "claude" + if path is None and agent in (None, "codex"): + from . import codex_rollout + path = codex_rollout.find_rollout_by_session_id(session_id) + if path is not None: + source_agent = "codex" + if path is None: + return _degraded(store, session_id, f"raw transcript not found for session {session_id}") + try: + if path.stat().st_size > MAX_FILE_BYTES: + return _degraded(store, session_id, f"transcript too large to render ({path.stat().st_size} bytes)") + except OSError as e: + return _degraded(store, session_id, f"cannot read transcript: {e}") + + if source_agent == "claude": + parsed = parse_claude_transcript(path, max_messages=MAX_MESSAGES) + else: + parsed = parse_codex_transcript(path, max_messages=MAX_MESSAGES) + return {"available": True, "source": {"agent": source_agent, "path": str(path)}, **parsed} +``` + +Note: `parse_codex_transcript` lands in Task 9. Until then, guard the codex branch so Phase 1 imports cleanly — add a temporary stub at the bottom of the module that Task 9 replaces: + +```python +def parse_codex_transcript(path: Path, *, max_messages: int = 2000) -> dict[str, Any]: + raise NotImplementedError("codex transcript parsing lands in Task 9") +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -k load_transcript -q` +Expected: PASS (3 passed). + +- [ ] **Step 5: Run full backend gates** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -q && .venv/bin/python -m mypy src && .venv/bin/python -m ruff check src tests` +Expected: all green. (Import `KBStore` under `TYPE_CHECKING` and quote the annotation to avoid a runtime cycle; `_read_observations` is a private-but-stable helper already used across the package.) + +- [ ] **Step 6: Commit** + +```bash +git add src/vouch/transcript.py tests/test_session_transcript.py +git commit -m "feat(transcript): orchestrate load with observation fallback" +``` + +--- + +### Task 4: `kb.session_transcript` RPC handler + +**Files:** +- Modify: `src/vouch/jsonl_server.py` (add handler near `_h_list_sessions` ~line 410; register in `HANDLERS` dict ~line 787) +- Modify: `src/vouch/capabilities.py` (add to `METHODS` list ~line 70, after `"kb.list_sessions"`) +- Test: `tests/test_session_transcript.py` + +**Interfaces:** +- Consumes: `transcript.load_transcript`, `_store()`. +- Produces: RPC method `kb.session_transcript(session_id, agent?)`. + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_session_transcript.py +from vouch.capabilities import capabilities +from vouch.jsonl_server import HANDLERS, handle_request + + +def test_capabilities_advertises_session_transcript() -> None: + assert "kb.session_transcript" in capabilities().methods + assert "kb.session_transcript" in HANDLERS + + +def test_handler_missing_session_id_is_missing_param() -> None: + resp = handle_request({"id": "1", "method": "kb.session_transcript", "params": {}}) + assert resp["ok"] is False + assert resp["error"]["code"] == "missing_param" + + +def test_handler_bad_agent_is_invalid_request() -> None: + resp = handle_request({"id": "2", "method": "kb.session_transcript", + "params": {"session_id": "11111111-1111-1111-1111-111111111111", "agent": "grok"}}) + assert resp["ok"] is False + assert resp["error"]["code"] == "invalid_request" + + +def test_handler_returns_degraded_when_absent(monkeypatch: pytest.MonkeyPatch) -> None: + 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 +``` + +Note: `test_handler_returns_degraded_when_absent` runs against the real KB discovered by `_store()`. It asserts only the shape (degraded), which holds regardless of on-disk files, because that id will not exist. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -k "handler or advertises" -q` +Expected: FAIL — `test_capabilities_advertises_session_transcript` fails and `test_capabilities_matches_jsonl_handlers` (existing) fails once you add to only one list; handler tests fail with `method_not_found`. + +- [ ] **Step 3: Write minimal implementation** + +In `src/vouch/capabilities.py`, add to the `METHODS` list right after `"kb.list_sessions",`: + +```python + "kb.session_transcript", +``` + +In `src/vouch/jsonl_server.py`, add the handler (place it just after `_h_list_sessions`): + +```python +def _h_session_transcript(p: dict) -> dict: + from . import transcript + session_id = p["session_id"] + 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) +``` + +Register it in the `HANDLERS` dict, right after the `"kb.list_sessions": _h_list_sessions,` line: + +```python + "kb.session_transcript": _h_session_transcript, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -q` +Expected: PASS (all). Also run `.venv/bin/python -m pytest tests/test_capabilities.py -q` → PASS. + +- [ ] **Step 5: Full backend gates** + +Run: `.venv/bin/python -m pytest tests/ -q --ignore=tests/embeddings && .venv/bin/python -m mypy src && .venv/bin/python -m ruff check src tests` +Expected: all green. + +- [ ] **Step 6: Commit** + +```bash +git add src/vouch/jsonl_server.py src/vouch/capabilities.py tests/test_session_transcript.py +git commit -m "feat(transcript): expose kb.session_transcript RPC" +``` + +--- + +## PHASE 2 — Frontend: rendering + Sessions view + +Work from `webapp/`. Run `npm install` once if `node_modules` is absent. + +### Task 5: Transcript client types + fetch + +**Files:** +- Create: `webapp/src/lib/transcript.ts` +- Test: `webapp/src/lib/transcript.test.ts` + +**Interfaces:** +- Produces: types `TranscriptBlock`, `TranscriptMessage`, `SessionMeta`, `Transcript` (union of available/degraded); `fetchTranscript(conn, sessionId, agent?) -> Promise`. + +- [ ] **Step 1: Write the failing test** + +```ts +// webapp/src/lib/transcript.test.ts +import { describe, expect, it, vi } from 'vitest' + +vi.mock('./rpc', () => ({ rpc: vi.fn() })) +import { rpc } from './rpc' +import { fetchTranscript } from './transcript' +import type { VouchConnectionInfo } from './types' + +const conn: VouchConnectionInfo = { endpoint: 'http://127.0.0.1:8731' } + +describe('fetchTranscript', () => { + it('calls kb.session_transcript with session id + agent', async () => { + vi.mocked(rpc).mockResolvedValue({ available: false, reason: 'x', observations: [] }) + await fetchTranscript(conn, 'sid-1', 'claude') + expect(rpc).toHaveBeenCalledWith(conn, 'kb.session_transcript', { session_id: 'sid-1', agent: 'claude' }) + }) + + it('omits agent when not given', async () => { + vi.mocked(rpc).mockResolvedValue({ available: false, reason: 'x', observations: [] }) + await fetchTranscript(conn, 'sid-2') + expect(rpc).toHaveBeenCalledWith(conn, 'kb.session_transcript', { session_id: 'sid-2' }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- transcript.test.ts` +Expected: FAIL (module `./transcript` not found). + +- [ ] **Step 3: Write minimal implementation** + +```ts +// webapp/src/lib/transcript.ts +import { rpc } from './rpc' +import type { VouchConnectionInfo } from './types' + +export interface Tokens { input: number; output: number; cache_read: number; cache_creation: number } + +export interface ToolResult { + content: string + is_error: boolean + subagent_session_id: string | null +} + +export type TranscriptBlock = + | { type: 'text'; text: string } + | { type: 'thinking'; text: string } + | { type: 'tool_use'; id: string | null; name: string; input: Record; result: ToolResult | null } + +export interface TranscriptMessage { + role: 'user' | 'assistant' + id: string | null + model: string | null + timestamp: string | null + tokens: Tokens | null + blocks: TranscriptBlock[] +} + +export interface SessionMeta { + id: string + agent: string + cwd: string | null + git_branch: string | null + title: string | null + started_at: string | null + ended_at: string | null + model: string | null + tokens: Tokens +} + +export interface Observation { ts: number; tool: string; summary: string; files?: string[]; cmd?: string } + +export type Transcript = + | { available: true; source: { agent: string; path: string }; session: SessionMeta; messages: TranscriptMessage[]; truncated: boolean } + | { available: false; reason: string; observations: Observation[] } + +export function fetchTranscript( + conn: VouchConnectionInfo, + sessionId: string, + agent?: string, +): Promise { + const params: Record = { session_id: sessionId } + if (agent) params.agent = agent + return rpc(conn, 'kb.session_transcript', params) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test -- transcript.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add webapp/src/lib/transcript.ts webapp/src/lib/transcript.test.ts +git commit -m "feat(webapp): transcript client types and fetch" +``` + +--- + +### Task 6: Leaf block renderers (DiffView, CodeBlock, ThinkingBlock) + +**Files:** +- Create: `webapp/src/components/transcript/DiffView.tsx`, `CodeBlock.tsx`, `ThinkingBlock.tsx` +- Test: `webapp/src/components/transcript/blocks.test.tsx` + +**Interfaces:** +- Produces: ``; ``; ``. + +- [ ] **Step 1: Write the failing test** + +```tsx +// webapp/src/components/transcript/blocks.test.tsx +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' +import { DiffView } from './DiffView' +import { ThinkingBlock } from './ThinkingBlock' + +describe('DiffView', () => { + it('tags added and removed lines', () => { + const { container } = render() + expect(container.querySelector('.diff-add')?.textContent).toContain('+new') + expect(container.querySelector('.diff-del')?.textContent).toContain('-old') + expect(container.querySelector('.diff-hunk')?.textContent).toContain('@@') + }) +}) + +describe('ThinkingBlock', () => { + it('is collapsed by default and expands on click', async () => { + render() + expect(screen.queryByText('secret reasoning')).toBeNull() + await userEvent.click(screen.getByRole('button', { name: /thinking/i })) + expect(screen.getByText('secret reasoning')).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- blocks.test.tsx` +Expected: FAIL (modules not found). + +- [ ] **Step 3: Write minimal implementation** + +```tsx +// webapp/src/components/transcript/DiffView.tsx +export function DiffView({ text }: { text: string }) { + const lines = text.replace(/\n$/, '').split('\n') + return ( +
+ {lines.map((line, i) => { + const cls = line.startsWith('@@') + ? 'diff-hunk text-accent-2' + : line.startsWith('+') + ? 'diff-add bg-ok/10 text-ok' + : line.startsWith('-') + ? 'diff-del bg-accent/10 text-accent-2' + : 'diff-ctx text-ink-2' + return ( +
+ {line || ' '} +
+ ) + })} +
+ ) +} +``` + +```tsx +// webapp/src/components/transcript/CodeBlock.tsx +export function CodeBlock({ code, lang }: { code: string; lang?: string }) { + return ( +
+ {lang && ( +
+ {lang} +
+ )} +
{code}
+
+ ) +} +``` + +```tsx +// webapp/src/components/transcript/ThinkingBlock.tsx +import { Brain, ChevronRight } from 'lucide-react' +import { useState } from 'react' + +export function ThinkingBlock({ text }: { text: string }) { + const [open, setOpen] = useState(false) + return ( +
+ + {open &&
{text}
} +
+ ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test -- blocks.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add webapp/src/components/transcript/DiffView.tsx webapp/src/components/transcript/CodeBlock.tsx webapp/src/components/transcript/ThinkingBlock.tsx webapp/src/components/transcript/blocks.test.tsx +git commit -m "feat(webapp): thinking, diff, and code block renderers" +``` + +--- + +### Task 7: ToolBlock (per-tool rendering + collapsible + result) + +**Files:** +- Create: `webapp/src/components/transcript/ToolBlock.tsx` +- Test: `webapp/src/components/transcript/ToolBlock.test.tsx` + +**Interfaces:** +- Consumes: `DiffView`, `CodeBlock`, block type from `lib/transcript`, optional `onOpenSubagent(sessionId)` callback. +- Produces: ` void} />`. + +- [ ] **Step 1: Write the failing test** + +```tsx +// webapp/src/components/transcript/ToolBlock.test.tsx +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { ToolBlock } from './ToolBlock' +import type { TranscriptBlock } from '../../lib/transcript' + +function tool(over: Partial>): Extract { + return { type: 'tool_use', id: 't1', name: 'Bash', input: {}, result: null, ...over } +} + +describe('ToolBlock', () => { + it('shows the tool name and a Bash command header', () => { + render() + expect(screen.getByText('Bash')).toBeInTheDocument() + expect(screen.getByText('go test ./...')).toBeInTheDocument() + }) + + it('renders a diff for Edit results and reveals output on expand', async () => { + const block = tool({ + name: 'Edit', + input: { file_path: '/x.go' }, + result: { content: '@@ -1 +1 @@\n-a\n+b', is_error: false, subagent_session_id: null }, + }) + const { container } = render() + await userEvent.click(screen.getByRole('button', { name: /Edit/i })) + expect(container.querySelector('.diff-add')).not.toBeNull() + }) + + it('marks errored results', async () => { + const block = tool({ result: { content: 'boom', is_error: true, subagent_session_id: null } }) + render() + await userEvent.click(screen.getByRole('button', { name: /Bash/i })) + expect(screen.getByText('boom')).toBeInTheDocument() + expect(screen.getByTestId('tool-error')).toBeInTheDocument() + }) + + it('offers a subagent link and fires the callback', async () => { + const onOpen = vi.fn() + const block = tool({ name: 'Task', input: { subagent_type: 'Explore', prompt: 'find x' }, + result: { content: 'done', is_error: false, subagent_session_id: 'child-9' } }) + render() + await userEvent.click(screen.getByRole('button', { name: /view subagent/i })) + expect(onOpen).toHaveBeenCalledWith('child-9') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- ToolBlock.test.tsx` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write minimal implementation** + +```tsx +// webapp/src/components/transcript/ToolBlock.tsx +import { ChevronRight, CornerDownRight, Wrench } from 'lucide-react' +import { useState } from 'react' +import type { TranscriptBlock } from '../../lib/transcript' +import { CodeBlock } from './CodeBlock' +import { DiffView } from './DiffView' + +type ToolUse = Extract + +/** One-line summary of the tool's input, agentsview-style. */ +function headline(block: ToolUse): string { + const i = block.input as Record + const s = (k: string) => (typeof i[k] === 'string' ? (i[k] as string) : '') + switch (block.name) { + case 'Bash': + case 'run_command': + return s('command') || s('cmd') + case 'Read': + case 'Edit': + case 'MultiEdit': + case 'Write': + case 'Update': + case 'NotebookEdit': + return s('file_path') || s('path') || s('notebook_path') + case 'Grep': + return s('pattern') + case 'Glob': + return s('pattern') || s('glob') + case 'Task': + case 'Agent': + return s('subagent_type') || s('description') || s('prompt').slice(0, 80) + default: + return '' + } +} + +function ResultBody({ block }: { block: ToolUse }) { + const r = block.result + if (!r) return

no output captured

+ const isEdit = ['Edit', 'MultiEdit', 'Write', 'Update'].includes(block.name) + if (isEdit && /^@@|\n[+-]/.test(r.content)) return + if (r.is_error) { + return ( +
+        {r.content}
+      
+ ) + } + return +} + +export function ToolBlock({ + block, + onOpenSubagent, +}: { + block: ToolUse + onOpenSubagent?: (sessionId: string) => void +}) { + const [open, setOpen] = useState(false) + const head = headline(block) + const child = block.result?.subagent_session_id ?? null + return ( +
+ + {open && ( +
+ {Object.keys(block.input).length > 0 && ( +
+ input + +
+ )} + + {child && onOpenSubagent && ( + + )} +
+ )} +
+ ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test -- ToolBlock.test.tsx` +Expected: PASS (4 passed). + +- [ ] **Step 5: Commit** + +```bash +git add webapp/src/components/transcript/ToolBlock.tsx webapp/src/components/transcript/ToolBlock.test.tsx +git commit -m "feat(webapp): tool block with per-tool rendering and diffs" +``` + +--- + +### Task 8: MessageBlock + TranscriptView + SessionsView + route/nav + +**Files:** +- Create: `webapp/src/components/transcript/MessageBlock.tsx` +- Create: `webapp/src/views/TranscriptView.tsx` +- Create: `webapp/src/views/SessionsView.tsx` +- Modify: `webapp/src/App.tsx` (add route) +- Modify: `webapp/src/components/Shell.tsx` (add nav item + title) +- Test: `webapp/src/views/SessionsView.test.tsx` + +**Interfaces:** +- Consumes: `ThinkingBlock`, `ToolBlock`, `Markdown`, `fetchTranscript`, `useConnection`, `useFanout`, `SessionEntry`. +- Produces: ``; ``; ``. + +- [ ] **Step 1: Write the failing test** + +```tsx +// webapp/src/views/SessionsView.test.tsx +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { fetchCapabilities, fetchHealth, rpc } from '../lib/rpc' +import { renderWithProviders, seedConnection } from '../test/utils' +import { SessionsView } from './SessionsView' + +const CAPS = { name: 'vouch', version: '1', level: 3, methods: ['kb.list_sessions', 'kb.session_transcript'], review_gated: true } + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS as never) + seedConnection() +}) + +describe('SessionsView', () => { + it('lists sessions and renders a picked transcript', async () => { + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.list_sessions') { + return { sessions: [{ session_id: 'sid-1', stage: 'buffer', proposal_id: null, kind: null, + title: 'Fix parser', summarized: false, observations: 3, last_activity: '2026-07-10T00:00:00Z' }] } + } + if (method === 'kb.session_transcript') { + return { available: true, source: { agent: 'claude', path: '/x' }, + session: { id: 'sid-1', agent: 'claude', cwd: '/repo', git_branch: 'main', title: 'Fix parser', + started_at: null, ended_at: null, model: 'claude-opus-4-8', + tokens: { input: 1, output: 1, cache_read: 0, cache_creation: 0 } }, + messages: [{ role: 'assistant', id: 'm1', model: 'claude-opus-4-8', timestamp: null, tokens: null, + blocks: [{ type: 'text', text: 'hello from claude' }] }], truncated: false } + } + return {} + }) + renderWithProviders(, { route: '/sessions' }) + await waitFor(() => expect(screen.getByText('Fix parser')).toBeInTheDocument()) + await userEvent.click(screen.getByText('Fix parser')) + await waitFor(() => expect(screen.getByText('hello from claude')).toBeInTheDocument()) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- SessionsView.test.tsx` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write minimal implementation** + +```tsx +// webapp/src/components/transcript/MessageBlock.tsx +import { Bot, User } from 'lucide-react' +import { Markdown } from '../Markdown' +import type { TranscriptMessage } from '../../lib/transcript' +import { ThinkingBlock } from './ThinkingBlock' +import { ToolBlock } from './ToolBlock' + +export function MessageBlock({ + message, + onOpenSubagent, +}: { + message: TranscriptMessage + onOpenSubagent?: (sessionId: string) => void +}) { + const isUser = message.role === 'user' + return ( +
+
+ {isUser ? : } + {isUser ? 'user' : 'assistant'} + {message.model && {message.model}} +
+
+ {message.blocks.map((b, i) => { + if (b.type === 'thinking') return + if (b.type === 'tool_use') return + return ( +
+ {b.text} +
+ ) + })} +
+
+ ) +} +``` + +```tsx +// webapp/src/views/TranscriptView.tsx +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { EmptyState } from '../components/EmptyState' +import { ErrorCard } from '../components/ErrorCard' +import { MessageBlock } from '../components/transcript/MessageBlock' +import { fetchTranscript } from '../lib/transcript' +import type { Observation } from '../lib/transcript' +import type { VouchConnectionInfo } from '../lib/types' +import { VouchRpcError } from '../lib/rpc' + +function Degraded({ reason, observations }: { reason: string; observations: Observation[] }) { + return ( +
+
+ original transcript unavailable — {reason}. Showing captured activity. +
+ {observations.length === 0 ? ( + + ) : ( +
    + {observations.map((o, i) => ( +
  1. + {o.tool} + {o.summary} +
  2. + ))} +
+ )} +
+ ) +} + +export function TranscriptView({ + conn, + sessionId, + agent, +}: { + conn: VouchConnectionInfo + sessionId: string + agent?: string +}) { + // Subagent drill-down replaces the shown transcript with the child's, with a back stack. + const [stack, setStack] = useState<{ id: string; agent?: string }[]>([{ id: sessionId, agent }]) + const top = stack[stack.length - 1] + const q = useQuery({ + queryKey: ['transcript', conn.endpoint, top.id], + queryFn: () => fetchTranscript(conn, top.id, top.agent), + }) + + if (q.isPending) return
Loading transcript…
+ if (q.isError) { + const e = q.error + return
+ } + const t = q.data + return ( +
+ {stack.length > 1 && ( + + )} + {!t.available ? ( + + ) : ( + <> +
+ {t.session.model && {t.session.model}} + {t.session.cwd && {t.session.cwd}} + {t.session.git_branch && ⎇ {t.session.git_branch}} + {t.session.tokens.input + t.session.tokens.output} tokens + {t.source.agent} +
+ {t.truncated && ( +
+ transcript truncated at {t.messages.length} messages +
+ )} + {t.messages.map((m, i) => ( + setStack((s) => [...s, { id, agent: t.source.agent }])} /> + ))} + + )} +
+ ) +} +``` + +```tsx +// webapp/src/views/SessionsView.tsx +import { useState } from 'react' +import { EmptyState } from '../components/EmptyState' +import { useConnection } from '../connection/ConnectionContext' +import { useFanout } from '../lib/fanout' +import type { SessionEntry } from '../lib/types' +import type { VouchConnectionInfo } from '../lib/types' +import { TranscriptView } from './TranscriptView' + +interface Row { conn: VouchConnectionInfo; label: string; s: SessionEntry } + +export function SessionsView() { + const { hasMethod } = useConnection() + const sessions = useFanout<{ sessions: SessionEntry[] }>(['sessions'], 'kb.list_sessions', {}, { + refetchInterval: 10_000, + }) + const rows: Row[] = sessions.rows.flatMap((r) => + (r.data?.sessions ?? []).map((s) => ({ conn: r.project.conn, label: r.project.label, s })), + ) + const [sel, setSel] = useState(null) + + return ( +
+ +
+ {sel && sel.s.session_id ? ( + + ) : ( +
+ )} +
+
+ ) +} +``` + +In `webapp/src/App.tsx`, add the import and route (after the `/stats` route): + +```tsx +import { SessionsView } from './views/SessionsView' +// ... + } /> +``` + +In `webapp/src/components/Shell.tsx`, import an icon and add nav + title: + +```tsx +// add ScrollText to the lucide-react import +import { Activity, BadgeCheck, FileClock, Inbox, Library, MessageSquare, Plug, ScrollText, SunMoon } from 'lucide-react' +// add to NAV array (before /stats): + { to: '/sessions', label: 'Sessions', icon: ScrollText }, +// add to TITLES: + '/sessions': 'Session transcripts', +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test -- SessionsView.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Full frontend gates** + +Run: `npm run test && npm run build` +Expected: all tests pass; `tsc && vite build` succeeds with no type errors. + +- [ ] **Step 6: Commit** + +```bash +git add webapp/src/components/transcript/MessageBlock.tsx webapp/src/views/TranscriptView.tsx webapp/src/views/SessionsView.tsx webapp/src/views/SessionsView.test.tsx webapp/src/App.tsx webapp/src/components/Shell.tsx +git commit -m "feat(webapp): sessions tab with full transcript viewer" +``` + +--- + +## PHASE 3 — Codex, subagents polish, e2e + +### Task 9: Codex full-transcript parser + +**Files:** +- Modify: `src/vouch/transcript.py` (replace the `parse_codex_transcript` stub) +- Test: `tests/test_session_transcript.py` + +**Interfaces:** +- Consumes: `codex_rollout._iter_rollout_lines` is NOT reused (it raises on zstd); parse plain records directly. +- Produces: `parse_codex_transcript(path, *, max_messages=2000) -> dict[str, Any]` returning the same schema, `session.agent == "codex"`. + +Codex rollout records (verified in `codex_rollout.py`): `{"type":"session_meta","payload":{"id","cwd","timestamp"}}`; `{"type":"event_msg","payload":{"type":"user_message","message":"…"}}` and `agent_message` (assistant text, field `message`); `{"type":"response_item","payload":{"type":"function_call","name","arguments","call_id"}}` and `{"type":"response_item","payload":{"type":"function_call_output","call_id","output"}}`. + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_session_transcript.py +_CODEX_LINES = [ + {"type": "session_meta", "payload": {"id": "cx-1", "cwd": "/repo", "timestamp": "2026-06-22T08:01:54Z"}}, + {"type": "event_msg", "payload": {"type": "user_message", "message": "run the tests"}}, + {"type": "event_msg", "payload": {"type": "agent_message", "message": "Running them now."}}, + {"type": "response_item", "payload": {"type": "function_call", "name": "shell", + "arguments": "{\"command\": \"pytest\"}", "call_id": "c1"}}, + {"type": "response_item", "payload": {"type": "function_call_output", "call_id": "c1", + "output": "1 passed"}}, +] + + +def test_parse_codex_pairs_calls(tmp_path: Path) -> None: + f = tmp_path / "rollout-x.jsonl" + _write_jsonl(f, _CODEX_LINES) + out = transcript.parse_codex_transcript(f) + assert out["session"]["agent"] == "codex" + assert out["session"]["cwd"] == "/repo" + roles = [m["role"] for m in out["messages"]] + assert roles[0] == "user" + # the assistant message carries the agent text and the paired tool call + assistant = next(m for m in out["messages"] if m["role"] == "assistant") + types = [b["type"] for b in assistant["blocks"]] + assert "tool_use" in types and "text" in types + tu = next(b for b in assistant["blocks"] if b["type"] == "tool_use") + assert tu["name"] == "shell" + assert tu["result"]["content"] == "1 passed" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -k codex -q` +Expected: FAIL — `NotImplementedError` from the stub. + +- [ ] **Step 3: Write minimal implementation** (replace the stub) + +```python +def parse_codex_transcript(path: Path, *, max_messages: int = 2000) -> dict[str, Any]: + """Parse a Codex rollout JSONL into the normalized transcript schema. + + Codex interleaves user/agent messages (``event_msg``) with tool calls + and outputs (``response_item``). Consecutive assistant activity — agent + text and its function calls — is grouped into one assistant message; + a ``function_call_output`` pairs into its call by ``call_id``. + """ + session: dict[str, Any] = { + "id": path.stem, "agent": "codex", "cwd": None, "git_branch": None, + "title": None, "started_at": None, "ended_at": None, "model": None, + "tokens": {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}, + } + messages: list[dict[str, Any]] = [] + tool_by_call: dict[str, dict[str, Any]] = {} + truncated = False + current: dict[str, Any] | None = None + + def new_assistant() -> dict[str, Any]: + return {"role": "assistant", "id": None, "model": None, "timestamp": None, + "tokens": None, "blocks": []} + + with path.open(encoding="utf-8") as fh: + for raw in fh: + raw = raw.strip() + if not raw: + continue + try: + rec = json.loads(raw) + except json.JSONDecodeError: + continue + if not isinstance(rec, dict): + continue + payload = rec.get("payload") + if not isinstance(payload, dict): + continue + rtype = rec.get("type") + if len(messages) >= max_messages: + truncated = True + break + + if rtype == "session_meta": + if isinstance(payload.get("id"), str) and session["id"] == path.stem: + session["id"] = payload["id"] + if isinstance(payload.get("cwd"), str): + session["cwd"] = payload["cwd"] + if isinstance(payload.get("timestamp"), str): + session["started_at"] = payload["timestamp"] + session["ended_at"] = payload["timestamp"] + elif rtype == "event_msg" and payload.get("type") == "user_message": + if current is not None: + messages.append(current) + current = None + text = str(payload.get("message", "")).strip() + if text: + messages.append({"role": "user", "id": None, "model": None, + "timestamp": None, "tokens": None, + "blocks": [{"type": "text", "text": text}]}) + elif rtype == "event_msg" and payload.get("type") == "agent_message": + if current is None: + current = new_assistant() + text = str(payload.get("message", "")).strip() + if text: + current["blocks"].append({"type": "text", "text": text}) + elif rtype == "response_item" and payload.get("type") == "function_call": + if current is None: + current = new_assistant() + block = {"type": "tool_use", "id": payload.get("call_id"), + "name": str(payload.get("name", "")), + "input": {"arguments": payload.get("arguments")}, "result": None} + current["blocks"].append(block) + cid = payload.get("call_id") + if isinstance(cid, str): + tool_by_call[cid] = block + elif rtype == "response_item" and payload.get("type") == "function_call_output": + cid = payload.get("call_id") + block = tool_by_call.get(cid) if isinstance(cid, str) else None + if block is not None: + block["result"] = {"content": str(payload.get("output", "")), + "is_error": False, "subagent_session_id": None} + if current is not None: + messages.append(current) + return {"session": session, "messages": messages, "truncated": truncated} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -k codex -q` +Expected: PASS. + +- [ ] **Step 5: Full backend gates + commit** + +```bash +.venv/bin/python -m pytest tests/ -q --ignore=tests/embeddings && .venv/bin/python -m mypy src && .venv/bin/python -m ruff check src tests +git add src/vouch/transcript.py tests/test_session_transcript.py +git commit -m "feat(transcript): parse Codex rollouts into normalized blocks" +``` + +--- + +### Task 10: Degraded-render + subagent test coverage (frontend) + +**Files:** +- Create: `webapp/src/views/TranscriptView.test.tsx` + +**Interfaces:** consumes existing `TranscriptView`. + +- [ ] **Step 1: Write the failing test** + +```tsx +// webapp/src/views/TranscriptView.test.tsx +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { rpc } from '../lib/rpc' +import { renderWithProviders } from '../test/utils' +import { TranscriptView } from './TranscriptView' + +const conn = { endpoint: 'http://127.0.0.1:8731' } + +beforeEach(() => { vi.clearAllMocks() }) + +describe('TranscriptView', () => { + it('renders the degraded observation timeline', async () => { + vi.mocked(rpc).mockResolvedValue({ available: false, reason: 'raw transcript not found', + observations: [{ ts: 1, tool: 'Edit', summary: 'Edited types.go' }] }) + renderWithProviders() + await waitFor(() => expect(screen.getByText('Edited types.go')).toBeInTheDocument()) + expect(screen.getByText(/original transcript unavailable/i)).toBeInTheDocument() + }) + + it('drills into a subagent and back', async () => { + vi.mocked(rpc).mockImplementation(async (_c, _m, params) => { + const id = (params as { session_id: string }).session_id + const text = id === 'child-9' ? 'child says hi' : 'parent turn' + const blocks = id === 'child-9' + ? [{ type: 'text', text }] + : [{ type: 'tool_use', id: 't1', name: 'Task', input: { prompt: 'go' }, + result: { content: 'done', is_error: false, subagent_session_id: 'child-9' } }] + return { available: true, source: { agent: 'claude', path: '/x' }, + session: { id, agent: 'claude', cwd: null, git_branch: null, title: null, started_at: null, + ended_at: null, model: null, tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0 } }, + messages: [{ role: 'assistant', id: 'm', model: null, timestamp: null, tokens: null, blocks }], + truncated: false } + }) + renderWithProviders() + await waitFor(() => expect(screen.getByText('Task')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /Task/i })) + await userEvent.click(screen.getByRole('button', { name: /view subagent/i })) + await waitFor(() => expect(screen.getByText('child says hi')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /back to parent/i })) + await waitFor(() => expect(screen.getByText('Task')).toBeInTheDocument()) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails, then passes** + +Run: `npm run test -- TranscriptView.test.tsx` +Expected: These should PASS against the Task 8 implementation. If the subagent-back or degraded rendering fails, fix `TranscriptView` (this task is the regression net that proves both paths). Do not modify tests to fit bugs. + +- [ ] **Step 3: Full frontend gates + commit** + +```bash +npm run test && npm run build +git add webapp/src/views/TranscriptView.test.tsx +git commit -m "test(webapp): cover degraded render and subagent drill-down" +``` + +--- + +### Task 11: E2E smoke + +**Files:** +- Create: `webapp/e2e/sessions.spec.ts` + +**Interfaces:** exercises the running app against the existing e2e fixture server (see `webapp/e2e/global-setup.ts`). + +- [ ] **Step 1: Inspect the existing e2e harness** + +Run: `sed -n '1,60p' webapp/e2e/smoke.spec.ts webapp/e2e/global-setup.ts` +Learn how the fixture endpoint is seeded and how `kb.*` responses are stubbed/served, then mirror that to stub `kb.list_sessions` + `kb.session_transcript`. (The spec must drive the real UI — navigate to Sessions, click a row, assert a rendered block — not assert on source.) + +- [ ] **Step 2: Write the spec** + +```ts +// webapp/e2e/sessions.spec.ts +import { expect, test } from '@playwright/test' + +// Follow the pattern established in smoke.spec.ts for seeding a connection +// and stubbing /proxy/rpc. Route kb.list_sessions -> one row with a +// session_id, and kb.session_transcript -> an available transcript with a +// single assistant text block "e2e transcript body". +test('sessions tab renders a picked transcript', async ({ page }) => { + // ...seed + route stubs mirroring smoke.spec.ts... + await page.goto('/sessions') + await page.getByText('e2e session').click() + await expect(page.getByText('e2e transcript body')).toBeVisible() +}) +``` + +- [ ] **Step 3: Run it** + +Run: `npm run e2e -- sessions.spec.ts` +Expected: PASS. (If the harness cannot stub per-method easily, assert the empty-state path instead: navigating to `/sessions` shows "No sessions" — still real behavior, no tautology.) + +- [ ] **Step 4: Commit** + +```bash +git add webapp/e2e/sessions.spec.ts +git commit -m "test(webapp): e2e smoke for the sessions transcript tab" +``` + +--- + +### Task 12: Spec sync + docs + +**Files:** +- Modify: `docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md` + +- [ ] **Step 1:** Update the spec's "Backend contract" to reflect server-side tool_result pairing (blocks carry `result` inline; no separate `tool_result` block) and resolve the open questions to what shipped (Sessions tab, master–detail, Codex in v1). Run `mdformat --wrap 80 docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md` if available. + +- [ ] **Step 2: Commit** + +```bash +git add docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md +git commit -m "docs(transcript): align spec with shipped contract" +``` + +--- + +## Self-Review + +**Spec coverage:** Backend RPC + `transcript.py` (Tasks 1-4, 9). Locator Claude + Codex (1, 9). Normalized schema (2, 9). Degradation to observations (3, 10). Frontend SessionsView + TranscriptView + block renderers (5-8). Per-tool rendering + diffs (7). Subagent lazy expansion (7, 8, 10). Capability gating (8). Tests every task; e2e (11). Conventions/guardrails in Global Constraints. All spec sections map to a task. + +**Placeholder scan:** No TBD/TODO. The only forward reference is `parse_codex_transcript` (stub in Task 3, implemented Task 9) — explicitly flagged with a raising stub so Phase 1 stays green. E2E spec body references the existing harness pattern (Task 11 Step 1 reads it first) rather than inventing an unknown stub API. + +**Type consistency:** Block schema identical across backend (dicts) and `lib/transcript.ts` (`TranscriptBlock`). `tool_use.result` is `{content, is_error, subagent_session_id}` everywhere. `fetchTranscript(conn, sessionId, agent?)`, `load_transcript(store, session_id, *, agent=None)`, and the handler param names (`session_id`, `agent`) match. `useFanout` row shape (`{project, data}`) matches Task 8 usage. + +--- + +## Execution Handoff + +Recommended: **Subagent-Driven** (fresh subagent per task, two-stage review between tasks). Phase 1 (backend, Tasks 1-4) is the critical path and must be green before Phase 2 consumes the RPC. Alternative: **Inline Execution** with checkpoints after each phase. diff --git a/docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md b/docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md new file mode 100644 index 00000000..5fcb87d0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md @@ -0,0 +1,334 @@ +# Session Transcript Viewer — Design + +Date: 2026-07-10 +Status: Implemented (see `docs/superpowers/plans/2026-07-10-session-transcript-viewer.md`) +Repo: `vouch` (backend `src/vouch`, frontend `webapp`) + +## Problem + +The vouch console (`webapp`) can list captured agent sessions +(`kb.list_sessions`) but cannot show what actually happened inside one. A row +gives a title, a stage, and an observation count — nothing more. When an agent +files claims a reviewer often needs to see the reasoning: the prompts, the +assistant's replies, the tools it ran, and the diffs it made. + +The `agentsview` project already renders exactly this for Claude Code / Codex +sessions. This feature ports **agentsview's rendering experience** into the +vouch console so a reviewer can open a session and read its full transcript. + +### What this is NOT + +- Not live-run rendering. The chat's "Claude mode" (`ChatView`) stays as-is; + this feature is a read-only viewer for **already-captured** sessions. +- Not a re-implementation of agentsview's storage architecture. See + "Relationship to agentsview" below — we copy the rendering, not the + sync-into-a-database pipeline. + +## Relationship to agentsview (what we copy, what we don't) + +agentsview works in two stages: + +1. **Ingest → SQLite.** A sync engine + file watcher parse the raw agent JSONL + into normalized rows in a SQLite DB (`messages`, `tool_calls`, + `tool_result_events`, FTS5). The raw file is parsed once at sync time. +2. **Serve → render.** A paginated REST API reads from the DB; the Svelte + frontend segments `content` + `tool_calls` into typed blocks + (`thinking` / `tool` / `code` / `skill` / `text`) client-side and renders + them with per-block components. + +We faithfully copy **stage 2's rendering vocabulary** (block types, per-tool +rendering, diffs, collapsibles, lazy subagents). We deliberately do **not** +copy stage 1: instead of syncing raw files into a database, the vouch backend +**parses the raw file on demand** when a session is opened. This yields the +same on-screen result with none of the sync/DB/watcher machinery, which is the +right trade for a viewer. + +Consequences accepted for v1: + +- Re-parses on each open (fine — a viewer opens one session at a time; large + sessions are handled by capping + lazy subagent loading, below). +- No cross-session full-text search inside transcripts. +- If the raw file has been deleted, the viewer degrades to vouch's compact + observations (below) rather than failing. + +## Scope + +In scope for v1: + +- Agents: **Claude Code** (`~/.claude/projects//.jsonl`) and + **Codex** (rollouts under `$CODEX_HOME/sessions/...`), on the **same machine** + as the vouch server. +- A new read-only RPC `kb.session_transcript`. +- A new frontend **Sessions** tab (master–detail) that lists sessions and + renders a selected session's transcript at full fidelity. + +Out of scope for v1: + +- Remote / multi-machine sessions whose raw files are not on the server host + (would require a transcript-upload pipeline). +- Persisting or indexing transcripts; cross-session search. +- Editing, pinning, exporting, or analytics over transcripts. + +## Backend + +### New RPC: `kb.session_transcript` + +Params: + +- `session_id` (string, required) — the captured session id. +- `agent` (string, optional) — `"claude"` | `"codex"`. When omitted, the + locator auto-detects by searching both sources. + +Success result (raw file found and parsed): + +```jsonc +{ + "available": true, + "source": { "agent": "claude", "path": "/home/u/.claude/projects/.../.jsonl" }, + "session": { + "id": "…", + "cwd": "…", + "git_branch": "…", + "started_at": "ISO-8601", + "ended_at": "ISO-8601", + "model": "…", + "tokens": { "input": 0, "output": 0, "cache_read": 0, "cache_creation": 0 } + }, + "messages": [ + { + "role": "user" | "assistant", + "id": "…", // message id (assistant), else null + "model": "…", // optional, assistant only + "timestamp": "ISO-8601", // optional + "tokens": { "input": 0, "output": 0, "cache_read": 0, "cache_creation": 0 }, + "blocks": [ + { "type": "text", "text": "…" }, + { "type": "thinking", "text": "…" }, + // tool results are paired into their tool_use block server-side, so + // the frontend renders input + output together (agentsview's ToolBlock). + { "type": "tool_use", "id": "…", "name": "Bash", "input": { }, + "result": { "content": "…", "is_error": false, + "subagent_session_id": null } } // null until paired + ] + } + ], + "truncated": false // true if message cap hit (see limits) +} +``` + +Implementation note: the draft's separate `tool_result` block was dropped in +favor of pairing each `tool_result` into its originating `tool_use` block by id +during the single parse pass. The tool-result-only `user` entries are consumed, +not emitted as standalone messages. This matches agentsview's unified tool +block (input + output shown together) and keeps the frontend renderer simple. + +Degraded result (raw file unavailable — deleted, off-machine, unreadable): + +```jsonc +{ + "available": false, + "reason": "raw transcript not found for session ", + "observations": [ { "ts": "…", "tool": "Edit", "summary": "Edited types.go" } ] +} +``` + +`observations` are read from vouch's existing capture buffer +(`capture._read_observations`) when one still exists for the session; otherwise +an empty list. This is the honest fallback: it is agentsview's *rendering* +degraded to vouch's *data*. + +Errors (structured JSONL envelope, matching every other handler): + +- missing `session_id` → `missing_param` (a bare `p["session_id"]` KeyError, + mapped by the dispatcher). +- `agent` present but not `claude`/`codex` → `invalid_request` (a raised + `ValueError`). + +Registered on all three surfaces to satisfy vouch's parity invariant +(`test_capabilities`): `jsonl_server.HANDLERS`, `capabilities.METHODS`, and the +`kb_session_transcript` MCP tool in `server.py`. `http_server` reuses +`jsonl_server.handle_request`, so it is covered by the JSONL registration. + +Locator env overrides (used by tests, honored in prod): `VOUCH_CLAUDE_PROJECTS_DIR` +re-roots the Claude search; `CODEX_HOME` re-roots the Codex rollout search. + +### New module: `src/vouch/transcript.py` (pure, fixture-tested) + +Responsibilities, each a small pure function: + +1. **Locate** the raw file for `(session_id, agent?)`: + - Claude Code: glob `~/.claude/projects/*/.jsonl` (the file stem + is the session id). Subagent files live at + `~/.claude/projects/*//subagents/**/*.jsonl`. + - Codex: reuse `codex_rollout.find_rollout_by_session_id`. + - Auto-detect tries Claude then Codex. + - Honor `VOUCH_CLAUDE_PROJECTS_DIR` / `CODEX_HOME` overrides for tests. + - `session_id` is validated against a UUID-shaped pattern before any glob so + a hostile id cannot widen the search or traverse the tree. +2. **Parse + normalize** raw lines into the `messages[]` schema above. + - Claude Code line schema (per JSONL entry): `message.role`, + `message.model`, `message.usage.{input_tokens,output_tokens, + cache_read_input_tokens,cache_creation_input_tokens}`, and + `message.content[]` whose parts have `type` ∈ + `{text, thinking, tool_use, tool_result}`. `tool_use` carries + `{id, name, input}`; `tool_result` (in user entries) carries + `{tool_use_id, content, is_error}`. Session-level `cwd`, `gitBranch`, + `timestamp` come from the entries. Subagent linkage via + `toolUseResult.agentId` maps a `tool_result` to its child session id + (mirrors agentsview's `subagentMap`). + - Codex: `codex_rollout.parse_rollout` is lossy (compact observations only), + so a dedicated `parse_codex_transcript` reads the raw `response_item` + records — the canonical conversation stream: `message` (role user → + `input_text`, assistant → `output_text`; developer/system boilerplate + skipped), `function_call` / `custom_tool_call` + their `*_output` pairs, + and `reasoning` (encrypted, so dropped). `session_meta` supplies + `id`/`cwd`/`git.branch`/`timestamp`. `event_msg` records are UI mirrors and + ignored to avoid duplication. + - Malformed lines are skipped, not fatal (matches the existing stream + parser's tolerance). +3. **Limits** (protect the server + browser): + - Max file size read (config const, e.g. 25 MB); over → degraded result with + reason. + - Max messages returned (config const, e.g. 2000); over → `truncated: true`. + - Per-block content is passed through; very large tool outputs are the + browser's problem to collapse, not the server's to trim (agentsview keeps + full content; we match). + +Subagents are fetched lazily by a **second** `kb.session_transcript` call with +the child `session_id` (the frontend passes `subagent_session_id`). The backend +locator finds `~/.claude/projects/*//subagents/**/.jsonl` as well +as top-level files, so the same RPC serves both. + +### Wiring + +- Handler `_h_session_transcript(p)` registered in + `src/vouch/jsonl_server.py` `HANDLERS` (and the HTTP surface in + `http_server.py` if it maintains its own map). +- Add `"kb.session_transcript"` to `src/vouch/capabilities.py` method list + (the `test_capabilities` drift test enforces this). +- Read-only: never calls `approve`/`propose`; unaffected by the review gate. + +## Frontend (`webapp`, React + Tailwind v4) + +Port agentsview's block vocabulary into React, styled with vouch's existing +semantic tokens (`paper` / `ink` / `accent` / `sepia` / `rule` / `ok`), reusing +the existing `Markdown` component for text blocks and `lucide-react` icons. + +### Route + entry point + +- New route `/sessions` and `/sessions/:id` in `App.tsx`; a **Sessions** entry + in the nav (`Shell`), gated on `hasMethod('kb.session_transcript')` like the + other capability-gated tabs. +- `SessionsView` — master–detail. Left: list from `kb.list_sessions` + (`useFanout`), newest first; rows with a non-null `session_id` are openable, + null ones are shown disabled. Right: `TranscriptView` for the selected id. + +### Components (each maps to an agentsview equivalent) + +| vouch (new) | agentsview reference | behavior | +| --- | --- | --- | +| `TranscriptView` | `MessageList` | fetches `kb.session_transcript(id)`, renders `messages[]`; shows session vitals header (model, tokens, cwd, branch) | +| `MessageBlock` | `MessageContent` | role chrome, model badge, tokens, timestamp; dispatches blocks | +| `ThinkingBlock` | `ThinkingBlock` | collapsible "Thinking" | +| `ToolBlock` | `ToolBlock` | collapsible; per-tool rendering; error styling | +| `DiffView` | ToolBlock diff-view | +/− line rendering for Edit/Write | +| `CodeBlock` | `CodeBlock` | fenced code | +| `TextBlock` | markdown path | reuse existing `Markdown` | + +Per-tool rendering inside `ToolBlock` (parity with agentsview): + +- `Bash` / `run_command` → command line + collapsible stdout. +- `Edit` / `Update` / `MultiEdit` → `DiffView`. +- `Write` → created-file content. +- `Read` / `Grep` / `Glob` → compact summary (path/pattern) + collapsible body. +- `Task` / `Agent` → labeled subagent step; when the paired result carries a + `subagent_session_id`, a **"view subagent"** button pushes the child onto an + in-`TranscriptView` back-stack and re-fetches `kb.session_transcript(child)`. +- Unknown tools (and every tool's raw input) → collapsible pretty-printed JSON. + +Shipped simplification: `Read`/`Grep`/`Glob` render a one-line headline plus the +collapsible output; a dedicated `TodoWrite` checklist renderer was deferred +(TodoWrite falls through to the JSON input view). Easy follow-up if wanted. + +### Degraded rendering + +When `available === false`, `TranscriptView` shows a notice ("original +transcript unavailable — showing captured activity") and renders the +`observations` as a compact tool timeline. + +### Client library + +- `webapp/src/lib/transcript.ts` — types for the normalized schema + a thin + `fetchTranscript(conn, id)` wrapper over `rpc('kb.session_transcript', …)`. + No new transport; reuses `/proxy/rpc`. + +## Error handling summary + +- Unknown / null session id → structured RPC error surfaced as a `Toast` + an + `ErrorCard` in the detail pane. +- File missing/oversized/unreadable → `available:false` degraded result. +- Malformed transcript lines → skipped in the parser. +- Endpoint doesn't advertise the method → nav hidden / disabled (capability + gate), never a hard failure. + +## Testing + +Backend (vouch conventions: `pytest`, `mypy src`, `ruff check`): + +- `tests/test_session_transcript.py` — table/fixture tests over the parser with + small committed **Claude Code** and **Codex** JSONL fixtures: text, thinking, + tool_use→tool_result pairing, is_error, subagent linkage, model/tokens, cwd/ + branch extraction; malformed-line tolerance; size/message caps → `truncated`. +- Locator tests using `VOUCH_CLAUDE_PROJECTS_DIR` / `CODEX_HOME` pointed at + `tmp_path` fixtures; auto-detect order; subagent-file resolution. +- Degradation test: no raw file, buffer present → `available:false` + + observations; no raw file, no buffer → empty observations. +- RPC envelope test `tests/test_session_transcript.py` asserting the JSONL + envelope shape; capabilities drift covered by `test_capabilities`. + +Frontend (`vitest` + Testing Library; one Playwright smoke): + +- Component tests per block (`ToolBlock` per-tool branches incl. `DiffView`, + `ThinkingBlock` collapse, `Task` subagent lazy-load with a mocked rpc), + `TranscriptView` happy path + degraded path, `SessionsView` list + disabled + null rows. +- `webapp/e2e/` smoke: open Sessions, pick a row, see the rendered transcript. + It stubs `/proxy/*` via Playwright `page.route` (health, capabilities, + `kb.list_pending`, `kb.list_sessions`, `kb.session_transcript`) so it drives + the real frontend independent of the backend build — the local `vouch` on + PATH is an editable install of a different checkout without the new RPC. +- Tests assert rendered behavior, not implementation strings + (`testing-without-tautologies`). + +## Conventions / guardrails (this repo) + +- Follow `vouch` `AGENTS.md`, not agentsview's `CLAUDE.md`: conventional + commits `(): …`; run `pytest --ignore=tests/embeddings`, + `mypy src`, `ruff check src tests` before shipping. **No + `Co-Authored-By: ` trailer.** No secrets/paths-as-PII in commits. +- Work on a feature branch (ask before creating it); do not commit to `main` + without permission; do not merge. + +## Phasing + +1. Backend: `transcript.py` (Claude locator + parser) + RPC + capabilities + + tests. +2. Frontend: `SessionsView` + `TranscriptView` + block components + client lib + + tests, wired to phase-1 RPC. +3. Codex source (reuse `codex_rollout`) + subagent lazy expansion + degraded + fallback + e2e smoke. + +## Resolved (as shipped) + +- Entry point: a dedicated **Sessions** tab in the console nav + (`ScrollText` icon), gated per-row on `hasMethod('kb.session_transcript')`. +- Layout: master–detail — session list on the left, transcript on the right. +- Codex shipped in v1 (phase 3) alongside Claude Code, via a dedicated + `response_item` parser (not the lossy `parse_rollout`). + +## Deferred (possible follow-ups) + +- Dedicated `TodoWrite` checklist renderer (currently the JSON fallback). +- Rendering `reasoning`/`thinking` when a session persists plaintext (VSCode/SDK + sessions store only the encrypted signature, so thinking blocks are dropped). +- Remote / multi-machine transcript access (still out of scope). From ac6dd0aa9a5f4618898bd587caa31d0c6caf53b4 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:48:46 +0900 Subject: [PATCH 13/18] refactor(webapp): merge session transcript into Review, drop Sessions tab --- ...ions.spec.ts => review-transcript.spec.ts} | 8 +- webapp/src/App.tsx | 2 - webapp/src/components/Shell.tsx | 6 +- webapp/src/views/ReviewView.test.tsx | 74 +++++++- webapp/src/views/ReviewView.tsx | 175 ++++++++++-------- webapp/src/views/SessionsView.test.tsx | 83 --------- webapp/src/views/SessionsView.tsx | 71 ------- 7 files changed, 169 insertions(+), 250 deletions(-) rename webapp/e2e/{sessions.spec.ts => review-transcript.spec.ts} (91%) delete mode 100644 webapp/src/views/SessionsView.test.tsx delete mode 100644 webapp/src/views/SessionsView.tsx diff --git a/webapp/e2e/sessions.spec.ts b/webapp/e2e/review-transcript.spec.ts similarity index 91% rename from webapp/e2e/sessions.spec.ts rename to webapp/e2e/review-transcript.spec.ts index d6759e8c..03a02464 100644 --- a/webapp/e2e/sessions.spec.ts +++ b/webapp/e2e/review-transcript.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test' -// Drives the real frontend (routing, SessionsView, TranscriptView, block +// Drives the real frontend (routing, ReviewView, TranscriptView, block // renderers) against stubbed /proxy responses, so it is independent of the // backend build. The connection is seeded into localStorage; health and // capabilities are stubbed so the project comes up "ok" with the transcript @@ -10,11 +10,11 @@ const CAPS = { name: 'vouch', version: '1.2.2', level: 3, - methods: ['kb.list_sessions', 'kb.session_transcript'], + methods: ['kb.list_sessions', 'kb.summarize_session', 'kb.session_transcript'], review_gated: true, } -test('sessions tab renders a picked transcript', async ({ page }) => { +test('review tab renders a picked session transcript', async ({ page }) => { await page.addInitScript((endpoint) => { localStorage.setItem( 'vouch-ui.connections.v2', @@ -89,7 +89,7 @@ test('sessions tab renders a picked transcript', async ({ page }) => { return route.fulfill({ json: { ok: true, id: '1', result: {} } }) }) - await page.goto('/sessions') + await page.goto('/review') await page.getByText('e2e session').click() await expect(page.getByText('e2e transcript body')).toBeVisible() }) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index b3d0f228..76860246 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -8,7 +8,6 @@ import { ChatView } from './views/ChatView' import { ClaimsView } from './views/ClaimsView' import { PendingView } from './views/PendingView' import { ReviewView } from './views/ReviewView' -import { SessionsView } from './views/SessionsView' import { StatsView } from './views/StatsView' export default function App() { @@ -25,7 +24,6 @@ export default function App() { } /> } /> } /> - } /> } /> diff --git a/webapp/src/components/Shell.tsx b/webapp/src/components/Shell.tsx index da5edb2d..443f0621 100644 --- a/webapp/src/components/Shell.tsx +++ b/webapp/src/components/Shell.tsx @@ -1,4 +1,4 @@ -import { Activity, BadgeCheck, FileClock, Inbox, Library, MessageSquare, Plug, ScrollText, SunMoon } from 'lucide-react' +import { Activity, BadgeCheck, FileClock, Inbox, Library, MessageSquare, Plug, SunMoon } from 'lucide-react' import { useEffect, useState } from 'react' import { NavLink, Outlet, useLocation } from 'react-router-dom' import { ConnectDialog } from '../connection/ConnectDialog' @@ -14,17 +14,15 @@ const NAV = [ { to: '/pending', label: 'Pending', icon: Inbox }, { to: '/claims', label: 'Claims', icon: BadgeCheck }, { to: '/browse', label: 'Browse', icon: Library }, - { to: '/sessions', label: 'Sessions', icon: ScrollText }, { to: '/stats', label: 'Stats', icon: Activity }, ] const TITLES: Record = { '/chat': 'Chat', - '/review': 'Review — sessions to summarize', + '/review': 'Review — session transcripts & summaries', '/pending': 'Pending review', '/claims': 'Approved claims', '/browse': 'Knowledge', - '/sessions': 'Session transcripts', '/stats': 'Stats & health', } diff --git a/webapp/src/views/ReviewView.test.tsx b/webapp/src/views/ReviewView.test.tsx index 143ef6e7..c72014fc 100644 --- a/webapp/src/views/ReviewView.test.tsx +++ b/webapp/src/views/ReviewView.test.tsx @@ -17,6 +17,11 @@ const CAPS = { review_gated: true, } +const CAPS_WITH_TRANSCRIPT = { + ...CAPS, + methods: ['kb.list_sessions', 'kb.summarize_session', 'kb.session_transcript'], +} + const SESSIONS = { sessions: [ { @@ -60,27 +65,78 @@ beforeEach(() => { seedConnection() }) -test('shows the empty state when nothing awaits a summary', async () => { +test('shows the empty state when there are no sessions', async () => { vi.mocked(rpc).mockResolvedValue({ sessions: [] }) renderWithProviders() - expect(await screen.findByText(/no sessions waiting for a summary/i)).toBeInTheDocument() + expect(await screen.findByText(/no captured sessions/i)).toBeInTheDocument() }) -test('lists only unsummarized sessions, with stage badges and metadata', async () => { +test('lists all sessions including already-summarized ones', async () => { vi.mocked(rpc).mockResolvedValue(SESSIONS) renderWithProviders() // title fallback chain: title, then session_id expect(await screen.findByText('session: fix the parser')).toBeInTheDocument() expect(screen.getAllByText('sess-open').length).toBeGreaterThan(0) - expect(screen.queryByText('session: already summarized')).not.toBeInTheDocument() - // stage badges + // summarized sessions are now shown too (viewable, read-only) + expect(screen.getByText('session: already summarized')).toBeInTheDocument() + // stage / summarized badges expect(screen.getByText('open buffer')).toBeInTheDocument() expect(screen.getByText('needs summary')).toBeInTheDocument() + expect(screen.getByText('summarized')).toBeInTheDocument() // observation counts and sliced timestamps expect(screen.getByText(/12 observations/)).toBeInTheDocument() expect(screen.getByText(/2026-07-04 11:30:00/)).toBeInTheDocument() }) +test('renders the transcript for the selected session and lets you summarize it', async () => { + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS_WITH_TRANSCRIPT) + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.list_sessions') return SESSIONS + if (method === 'kb.session_transcript') { + return { + available: true, + source: { agent: 'claude', path: '/x' }, + session: { + id: 'sess-filed', + agent: 'claude', + cwd: '/repo', + git_branch: 'main', + title: 'session: fix the parser', + started_at: null, + ended_at: null, + model: 'claude-opus-4-8', + tokens: { input: 1, output: 1, cache_read: 0, cache_creation: 0 }, + }, + messages: [ + { + role: 'assistant', + id: 'm1', + model: 'claude-opus-4-8', + timestamp: null, + tokens: null, + blocks: [{ type: 'text', text: 'transcript in review pane' }], + }, + ], + truncated: false, + } + } + if (method === 'kb.summarize_session') + return { session_id: 'sess-filed', summarized: true, proposal_id: 'prop-1' } + throw new Error(`unexpected ${method}`) + }) + renderWithProviders() + await userEvent.click(await screen.findByText('session: fix the parser')) + // transcript renders in the detail pane... + expect(await screen.findByText('transcript in review pane')).toBeInTheDocument() + // ...and Summarize still works from the same pane + await userEvent.click(screen.getByRole('button', { name: 'Summarize' })) + await waitFor(() => + expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.summarize_session', { + session_id: 'sess-filed', + }), + ) +}) + test('Summarize calls kb.summarize_session for the selected session and refetches', async () => { vi.mocked(rpc).mockImplementation(async (_c, method) => { if (method === 'kb.list_sessions') return SESSIONS @@ -90,7 +146,7 @@ test('Summarize calls kb.summarize_session for the selected session and refetche }) renderWithProviders() await userEvent.click(await screen.findByText('session: fix the parser')) - await userEvent.click(screen.getByRole('button', { name: /summarize/i })) + await userEvent.click(screen.getByRole('button', { name: 'Summarize' })) await waitFor(() => expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.summarize_session', { session_id: 'sess-filed', @@ -114,7 +170,7 @@ test('a skipped result surfaces an error toast naming the reason', async () => { }) renderWithProviders() await userEvent.click(await screen.findByText('session: fix the parser')) - await userEvent.click(screen.getByRole('button', { name: /summarize/i })) + await userEvent.click(screen.getByRole('button', { name: 'Summarize' })) expect( await screen.findByText(/not-configured: set capture\.summary_llm_cmd/i), ).toBeInTheDocument() @@ -129,7 +185,7 @@ test('an rpc error surfaces an error toast', async () => { }) renderWithProviders() await userEvent.click(await screen.findByText('session: fix the parser')) - await userEvent.click(screen.getByRole('button', { name: /summarize/i })) + await userEvent.click(screen.getByRole('button', { name: 'Summarize' })) expect(await screen.findByText(/internal_error: summary command exited 1/i)).toBeInTheDocument() }) @@ -145,6 +201,6 @@ test('hides the Summarize button when kb.summarize_session is not advertised', a vi.mocked(rpc).mockResolvedValue(SESSIONS) renderWithProviders() await userEvent.click(await screen.findByText('session: fix the parser')) - expect(screen.queryByRole('button', { name: /summarize/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Summarize' })).not.toBeInTheDocument() expect(screen.getByText(/kb\.summarize_session is not advertised/i)).toBeInTheDocument() }) diff --git a/webapp/src/views/ReviewView.tsx b/webapp/src/views/ReviewView.tsx index 30f14094..378f812b 100644 --- a/webapp/src/views/ReviewView.tsx +++ b/webapp/src/views/ReviewView.tsx @@ -9,12 +9,18 @@ import type { ProjectState } from '../connection/ConnectionContext' import { useFanout } from '../lib/fanout' import { rpc, VouchRpcError } from '../lib/rpc' import type { SessionEntry } from '../lib/types' +import { TranscriptView } from './TranscriptView' const STAGE_LABEL: Record = { buffer: 'open buffer', pending: 'needs summary', } +/** Badge text for a row: summarized wins over the raw capture stage. */ +function stageLabel(s: SessionEntry): string { + return s.summarized ? 'summarized' : STAGE_LABEL[s.stage] +} + /** Hints for the skip reasons kb.summarize_session can return instead of a summary. */ const SKIP_HINTS: Record = { 'not-configured': 'set capture.summary_llm_cmd in .vouch/config.yaml', @@ -47,12 +53,18 @@ export function ReviewView() { }) useErrorToast(sessions.errors.length > 0, sessions.errors[0]?.error) - const rows: Row[] = sessions.rows - .flatMap((r) => (r.data?.sessions ?? []).map((s) => ({ project: r.project, s }))) - .filter((r) => !r.s.summarized) + const rows: Row[] = sessions.rows.flatMap((r) => + (r.data?.sessions ?? []).map((s) => ({ project: r.project, s })), + ) const selected = rows.find((r) => rowKey(r) === selectedKey) ?? null const canSummarize = - !!selected && hasMethod('kb.summarize_session', selected.project.conn.endpoint) + !!selected && + !selected.s.summarized && + hasMethod('kb.summarize_session', selected.project.conn.endpoint) + const canTranscript = + !!selected && + !!selected.s.session_id && + hasMethod('kb.session_transcript', selected.project.conn.endpoint) const summarize = useMutation({ mutationFn: (row: Row) => @@ -104,8 +116,8 @@ export function ReviewView() { if (rows.length === 0) { return ( ) } @@ -129,7 +141,7 @@ export function ReviewView() { )} - {STAGE_LABEL[r.s.stage]} + {stageLabel(r.s)} {r.s.observations !== null && ( {r.s.observations} observations @@ -146,84 +158,93 @@ export function ReviewView() { -
+
{!selected ? ( - +
+ +
) : ( -
-
- {aggregated && ( - - {selected.project.label} + <> +
+
+ {aggregated && ( + + {selected.project.label} + + )} + + {stageLabel(selected.s)} - )} - - {STAGE_LABEL[selected.s.stage]} - - - {selected.s.session_id ?? '(no session id)'} - -
+ + {selected.s.session_id ?? '(no session id)'} + + {selected.s.observations !== null && ( + {selected.s.observations} observations + )} + {selected.s.last_activity && ( + + {selected.s.last_activity.slice(0, 19).replace('T', ' ')} + + )} +
-

{rowTitle(selected.s)}

+

{rowTitle(selected.s)}

-
-
-
stage
-
{STAGE_LABEL[selected.s.stage]}
-
- {selected.s.proposal_id && ( -
-
proposal
-
{selected.s.proposal_id}
-
- )} - {selected.s.observations !== null && ( -
-
observations
-
{selected.s.observations}
-
- )} - {selected.s.last_activity && ( -
-
last activity
-
{selected.s.last_activity.slice(0, 19).replace('T', ' ')}
+ {selected.s.summarized ? ( +

+ Already summarized — its proposal is in Pending for review. +

+ ) : canSummarize ? ( +
+ + + {summarize.isPending + ? 'running the configured LLM — this can take a minute' + : selected.s.session_id + ? 'runs the configured LLM over the capture; the summary lands in Pending' + : 'no session id recorded — this capture cannot be summarized'} +
+ ) : ( +

+ This endpoint cannot summarize — kb.summarize_session is not advertised. +

)} -
- - {!canSummarize ? ( -

- This endpoint cannot summarize — kb.summarize_session is not advertised. -

- ) : ( - <> - -

- {summarize.isPending - ? 'running the configured LLM — this can take a minute' - : selected.s.session_id - ? 'runs the configured LLM over the capture; the summary lands in Pending' - : 'no session id recorded — this capture cannot be summarized'} +

+ +
+ {canTranscript ? ( + + ) : ( +

+ {selected.s.session_id + ? 'Transcript view is not available on this endpoint — kb.session_transcript is not advertised.' + : 'No transcript — this capture has no recorded session id.'}

- - )} -
+ )} +
+ )}
diff --git a/webapp/src/views/SessionsView.test.tsx b/webapp/src/views/SessionsView.test.tsx deleted file mode 100644 index 62d128d8..00000000 --- a/webapp/src/views/SessionsView.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('../lib/rpc', async () => { - const actual = await vi.importActual('../lib/rpc') - return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } -}) -import { fetchCapabilities, fetchHealth, rpc } from '../lib/rpc' -import { renderWithProviders, seedConnection } from '../test/utils' -import { SessionsView } from './SessionsView' - -const CAPS = { - name: 'vouch', - version: '1', - level: 3, - methods: ['kb.list_sessions', 'kb.session_transcript'], - review_gated: true, -} - -beforeEach(() => { - localStorage.clear() - vi.clearAllMocks() - vi.mocked(fetchHealth).mockResolvedValue(true) - vi.mocked(fetchCapabilities).mockResolvedValue(CAPS as never) - seedConnection() -}) - -describe('SessionsView', () => { - it('lists sessions and renders a picked transcript', async () => { - vi.mocked(rpc).mockImplementation(async (_c, method) => { - if (method === 'kb.list_sessions') { - return { - sessions: [ - { - session_id: 'sid-1', - stage: 'buffer', - proposal_id: null, - kind: null, - title: 'Fix parser', - summarized: false, - observations: 3, - last_activity: '2026-07-10T00:00:00Z', - }, - ], - } - } - if (method === 'kb.session_transcript') { - return { - available: true, - source: { agent: 'claude', path: '/x' }, - session: { - id: 'sid-1', - agent: 'claude', - cwd: '/repo', - git_branch: 'main', - title: 'Fix parser', - started_at: null, - ended_at: null, - model: 'claude-opus-4-8', - tokens: { input: 1, output: 1, cache_read: 0, cache_creation: 0 }, - }, - messages: [ - { - role: 'assistant', - id: 'm1', - model: 'claude-opus-4-8', - timestamp: null, - tokens: null, - blocks: [{ type: 'text', text: 'hello from claude' }], - }, - ], - truncated: false, - } - } - return {} - }) - renderWithProviders(, { route: '/sessions' }) - await waitFor(() => expect(screen.getByText('Fix parser')).toBeInTheDocument()) - await userEvent.click(screen.getByText('Fix parser')) - await waitFor(() => expect(screen.getByText('hello from claude')).toBeInTheDocument()) - }) -}) diff --git a/webapp/src/views/SessionsView.tsx b/webapp/src/views/SessionsView.tsx deleted file mode 100644 index 2f2cc60b..00000000 --- a/webapp/src/views/SessionsView.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useState } from 'react' -import { EmptyState } from '../components/EmptyState' -import { useConnection } from '../connection/ConnectionContext' -import { useFanout } from '../lib/fanout' -import type { SessionEntry, VouchConnectionInfo } from '../lib/types' -import { TranscriptView } from './TranscriptView' - -interface Row { - conn: VouchConnectionInfo - label: string - s: SessionEntry -} - -export function SessionsView() { - const { hasMethod } = useConnection() - const sessions = useFanout<{ sessions: SessionEntry[] }>(['sessions'], 'kb.list_sessions', {}, { - refetchInterval: 10_000, - }) - const rows: Row[] = sessions.rows.flatMap((r) => - (r.data?.sessions ?? []).map((s) => ({ conn: r.project.conn, label: r.project.label, s })), - ) - const [sel, setSel] = useState(null) - - return ( -
- -
- {sel && sel.s.session_id ? ( - - ) : ( -
- -
- )} -
-
- ) -} From 776bffb90fd478bc16c74faf445cbd48769cbcf6 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:50:04 +0900 Subject: [PATCH 14/18] docs(transcript): note the merge of the viewer into Review --- ...-07-10-session-transcript-viewer-design.md | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md b/docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md index 5fcb87d0..63d65bef 100644 --- a/docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md +++ b/docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md @@ -59,8 +59,10 @@ In scope for v1: **Codex** (rollouts under `$CODEX_HOME/sessions/...`), on the **same machine** as the vouch server. - A new read-only RPC `kb.session_transcript`. -- A new frontend **Sessions** tab (master–detail) that lists sessions and - renders a selected session's transcript at full fidelity. +- Full-fidelity transcript rendering in the console's **Review** page + (master–detail): the session list plus the selected session's transcript and + its `Summarize` action, side by side. (Originally shipped as a standalone + **Sessions** tab, then merged into Review — see the update note below.) Out of scope for v1: @@ -216,12 +218,18 @@ the existing `Markdown` component for text blocks and `lucide-react` icons. ### Route + entry point -- New route `/sessions` and `/sessions/:id` in `App.tsx`; a **Sessions** entry - in the nav (`Shell`), gated on `hasMethod('kb.session_transcript')` like the - other capability-gated tabs. -- `SessionsView` — master–detail. Left: list from `kb.list_sessions` - (`useFanout`), newest first; rows with a non-null `session_id` are openable, - null ones are shown disabled. Right: `TranscriptView` for the selected id. +Post-merge (see the update note below), the viewer lives in the existing +**Review** page rather than a separate tab: + +- No new route or nav item. `ReviewView` (`/review`) is the single sessions + surface. Left: all sessions from `kb.list_sessions` (`useFanout`) — the + earlier `!summarized` filter was dropped so every session's transcript stays + viewable. Right (detail pane): a compact header with the session's stage / + ids / `Summarize` action, and below it `TranscriptView` for the selected + session — gated on `hasMethod('kb.session_transcript')` and a non-null + `session_id`, degrading to a note otherwise. +- The `Summarize` action shows only for sessions that still need it + (`!summarized`); already-summarized rows render read-only. ### Components (each maps to an agentsview equivalent) @@ -290,8 +298,9 @@ Frontend (`vitest` + Testing Library; one Playwright smoke): - Component tests per block (`ToolBlock` per-tool branches incl. `DiffView`, `ThinkingBlock` collapse, `Task` subagent lazy-load with a mocked rpc), - `TranscriptView` happy path + degraded path, `SessionsView` list + disabled - null rows. + `TranscriptView` happy path + degraded path + subagent drill-down, + `ReviewView` (all sessions listed incl. summarized, transcript in the detail + pane, `Summarize` still works alongside it). - `webapp/e2e/` smoke: open Sessions, pick a row, see the rendered transcript. It stubs `/proxy/*` via Playwright `page.route` (health, capabilities, `kb.list_pending`, `kb.list_sessions`, `kb.session_transcript`) so it drives @@ -313,16 +322,25 @@ Frontend (`vitest` + Testing Library; one Playwright smoke): 1. Backend: `transcript.py` (Claude locator + parser) + RPC + capabilities + tests. -2. Frontend: `SessionsView` + `TranscriptView` + block components + client lib - + tests, wired to phase-1 RPC. +2. Frontend: `TranscriptView` + block components + client lib + tests, wired to + phase-1 RPC. (Initially surfaced via a `SessionsView` tab.) 3. Codex source (reuse `codex_rollout`) + subagent lazy expansion + degraded fallback + e2e smoke. +4. Merge: fold the transcript into `ReviewView`, drop the `!summarized` filter, + and remove the standalone Sessions tab (this iteration). ## Resolved (as shipped) -- Entry point: a dedicated **Sessions** tab in the console nav - (`ScrollText` icon), gated per-row on `hasMethod('kb.session_transcript')`. -- Layout: master–detail — session list on the left, transcript on the right. +- Entry point: **merged into the Review page** (`/review`). The initial cut + shipped a standalone **Sessions** tab, but Review already listed the same + `kb.list_sessions` sessions (to summarize them) with no transcript, so the two + were redundant. Review now renders the transcript in its detail pane next to + the `Summarize` action; the Sessions tab/route/view were removed. +- List scope: Review shows **all** sessions (its `!summarized` filter was + dropped), so transcripts of already-summarized sessions stay viewable; the + `Summarize` action is contextual (only for sessions that still need it). +- Layout: master–detail — session list on the left, transcript + summarize on + the right. - Codex shipped in v1 (phase 3) alongside Claude Code, via a dedicated `response_item` parser (not the lossy `parse_rollout`). From b7ea9f5f9c8fd0040698a1683fa20606b16fdced Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:53:20 +0900 Subject: [PATCH 15/18] feat(activity): expose kb.activity audit buckets across all surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit one pass over the audit log returning per-day counts (with proposal/decision breakdowns), an hour-of-week matrix, and actor/event histograms — the data a dashboard needs that kb.audit's tail-limited raw events and kb.stats' review aggregates don't cover. windowed in viewer-local calendar days so the oldest heatmap cell is never partially counted (days=0 for all-time, negatives rejected). local bucketing prefers an iana tz name (dst-correct across the year) and falls back to a clamped fixed utc offset. scope-filtered through the same viewer context as kb.audit so multi-project kbs don't leak actor names or activity timing across project boundaries. registered at all four sites (mcp tool, jsonl handler, capabilities methods, vouch activity cli) plus trust read methods. --- CHANGELOG.md | 9 +++ src/vouch/capabilities.py | 1 + src/vouch/cli.py | 53 ++++++++++++++ src/vouch/jsonl_server.py | 17 ++++- src/vouch/server.py | 28 ++++++- src/vouch/stats.py | 104 +++++++++++++++++++++++++- src/vouch/trust.py | 1 + tests/test_stats.py | 149 ++++++++++++++++++++++++++++++++++++++ tests/test_trust.py | 1 + 9 files changed, 360 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 986dcb0e..68f01b10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- `kb.activity` read method (+ `vouch activity` CLI mirror): audit-log + activity buckets for dashboards — per-day counts with proposal/decision + breakdowns, an hour-of-week matrix, and actor/event histograms. windowed + in viewer-local calendar days (IANA `tz` or a fixed utc offset), scope- + 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`. + ## [1.2.2] — 2026-07-07 ### Packaging diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 0d8528a5..1f101219 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -31,6 +31,7 @@ "kb.capabilities", "kb.status", "kb.stats", + "kb.activity", "kb.digest", "kb.search", "kb.neighbors", diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 5034ea8b..0267852a 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -319,6 +319,59 @@ def stats(days: int, as_json: bool) -> None: _echo(f" invalid: {cites['invalid_claim']}, broken: {cites['broken_citation']}") +@cli.command() +@click.option( + "--days", + default=365, + show_default=True, + type=click.IntRange(min=0), + help="Window (local calendar days). Use 0 for all-time.", +) +@click.option( + "--tz-offset-minutes", + default=0, + show_default=True, + type=int, + help="Viewer's UTC offset in minutes for local-time bucketing.", +) +@click.option("--tz", default=None, help="IANA zone for local-time bucketing (wins over offset).") +@click.option("--project", default=None, help="Viewer project for audit scope filtering.") +@click.option("--agent", default=None, help="Viewer agent for audit scope filtering.") +@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of a table.") +def activity( + days: int, + tz_offset_minutes: int, + tz: str | None, + project: str | None, + agent: str | None, + as_json: bool, +) -> None: + """Audit activity buckets: per-day counts, hour-of-week matrix, actors.""" + from .scoping import viewer_from + + store = _load_store() + viewer = viewer_from( + config_path=store.config_path, + project=project, + agent=agent, + ) + body = stats_mod.collect_activity( + store, days=days, tz_offset_minutes=tz_offset_minutes, tz=tz, viewer=viewer, + ) + if as_json: + _emit_json(body) + return + window = "all time" if body["window_days"] is None else f"last {body['window_days']}d" + _echo( + f"activity ({window}): {_style(str(body['total_events']), fg='cyan')} events " + f"on {body['active_days']} day(s)" + ) + if body["first_event_day"]: + _echo(f" span: {body['first_event_day']} → {body['last_event_day']}") + for actor, count in list(body["by_actor"].items())[:8]: + _echo(f" {actor}: {count}") + + @cli.command(name="digest") @click.option( "--since", diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 6f34c9cf..85be9cd5 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -55,7 +55,7 @@ reject, reject_auto_extracted, ) -from .stats import collect_stats +from .stats import collect_activity, collect_stats from .storage import ( ArtifactNotFoundError, KBNotFoundError, @@ -102,6 +102,20 @@ def _h_stats(p: dict) -> dict: return collect_stats(_store(), since_days=since) +def _h_activity(p: dict) -> dict: + from .scoping import viewer_from_params + + s = _store() + viewer = viewer_from_params(s, p) + return collect_activity( + s, + days=int(p.get("days", 365)), + tz_offset_minutes=int(p.get("tz_offset_minutes", 0)), + tz=p.get("tz"), + viewer=viewer, + ) + + def _h_digest(p: dict) -> dict: d = digest_mod.build( _store(), @@ -770,6 +784,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.capabilities": _h_capabilities, "kb.status": _h_status, "kb.stats": _h_stats, + "kb.activity": _h_activity, "kb.digest": _h_digest, "kb.search": _h_search, "kb.neighbors": _h_neighbors, diff --git a/src/vouch/server.py b/src/vouch/server.py index 6bfd8b35..ff25a4b9 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -47,7 +47,7 @@ reject_auto_extracted, ) from .scoping import filter_hits, scoped_fetch_limit, viewer_from -from .stats import collect_stats +from .stats import collect_activity, collect_stats from .storage import ( ArtifactNotFoundError, KBNotFoundError, @@ -97,6 +97,32 @@ def kb_stats(*, days: int = 30) -> dict[str, Any]: return collect_stats(_store(), since_days=since) +@mcp.tool() +def kb_activity( + *, + days: int = 365, + tz_offset_minutes: int = 0, + tz: str | None = None, + project: str | None = None, + agent: str | None = None, +) -> dict[str, Any]: + """Audit activity buckets for dashboards: per-day counts, hour-of-week + matrix, actor and event histograms. Scope-filtered like kb.audit. + + days: window in local calendar days; 0 means all-time. + tz: IANA zone for local-time bucketing; falls back to tz_offset_minutes. + """ + store = _store() + viewer = viewer_from( + config_path=store.config_path, + project=project, + agent=agent, + ) + return collect_activity( + store, days=days, tz_offset_minutes=tz_offset_minutes, tz=tz, viewer=viewer, + ) + + @mcp.tool() def kb_digest( *, diff --git a/src/vouch/stats.py b/src/vouch/stats.py index 5e5ca35c..db30754f 100644 --- a/src/vouch/stats.py +++ b/src/vouch/stats.py @@ -7,14 +7,19 @@ from __future__ import annotations import statistics +from collections.abc import Callable from datetime import UTC, datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING, Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from . import audit, health from .models import Proposal, ProposalStatus from .proposals import EXPIRE_REASON from .storage import KBStore, _yaml_load +if TYPE_CHECKING: + from .scoping import ViewerContext + def _utc(dt: datetime) -> datetime: if dt.tzinfo is None: @@ -186,6 +191,103 @@ def bump(agent: str, bucket: str) -> None: } +# Real-world UTC offsets span -12:00..+14:00; clamp so a bad client can't +# shift events into arbitrary buckets. +_MAX_TZ_OFFSET_MINUTES = 14 * 60 + + +def _is_proposal_create(event: str) -> bool: + return event.startswith("proposal.") and event.endswith(".create") + + +def _local_clock(tz: str | None, offset_minutes: int) -> Callable[[datetime], datetime]: + """Viewer-local conversion: an IANA zone when resolvable (DST-correct), + otherwise the fixed offset.""" + if tz: + try: + zone = ZoneInfo(tz) + except (ZoneInfoNotFoundError, ValueError): + pass + else: + return lambda dt: _utc(dt).astimezone(zone) + shift = timedelta(minutes=offset_minutes) + return lambda dt: _utc(dt) + shift + + +def collect_activity( + store: KBStore, + *, + days: int = 365, + tz_offset_minutes: int = 0, + tz: str | None = None, + viewer: ViewerContext | None = None, +) -> dict[str, Any]: + """Bucket audit events for the console dashboard — `kb.activity`. + + One pass over the audit log: per-day totals (with proposal/decision + breakdowns), an hour-of-week matrix, and actor/event histograms. + ``days=0`` means all-time; otherwise the window is the last ``days`` + viewer-local calendar days including today, so the oldest day in a + dashboard heatmap is never partially counted. ``tz`` (IANA name) wins + over ``tz_offset_minutes`` for local bucketing. ``viewer`` applies the + same scope filtering as `kb.audit`. + """ + if days < 0: + raise ValueError("days must be >= 0") + window = None if days == 0 else days + offset = max(-_MAX_TZ_OFFSET_MINUTES, min(_MAX_TZ_OFFSET_MINUTES, tz_offset_minutes)) + to_local = _local_clock(tz, offset) + cutoff_day = ( + None + if window is None + else to_local(datetime.now(UTC)).date() - timedelta(days=window - 1) + ) + + by_day: dict[str, dict[str, int]] = {} + # by_hour[weekday][hour], Monday = 0 — matches datetime.weekday(). + by_hour = [[0] * 24 for _ in range(7)] + by_actor: dict[str, int] = {} + by_event: dict[str, int] = {} + total = 0 + + for ev in audit.read_events(store.kb_dir, store=store, viewer=viewer): + local = to_local(ev.created_at) + if cutoff_day is not None and local.date() < cutoff_day: + continue + day = by_day.setdefault( + local.date().isoformat(), + {"total": 0, "proposals": 0, "decisions": 0}, + ) + day["total"] += 1 + if _is_proposal_create(ev.event): + day["proposals"] += 1 + elif _audit_decision_kind(ev.event) is not None: + day["decisions"] += 1 + by_hour[local.weekday()][local.hour] += 1 + by_actor[ev.actor] = by_actor.get(ev.actor, 0) + 1 + by_event[ev.event] = by_event.get(ev.event, 0) + 1 + total += 1 + + days_seen = sorted(by_day) + return { + "generated_at": datetime.now(UTC).isoformat(), + "window_days": window, + "tz_offset_minutes": offset, + "viewer": { + "project": viewer.project if viewer else None, + "agent": viewer.agent if viewer else None, + }, + "total_events": total, + "active_days": len(by_day), + "first_event_day": days_seen[0] if days_seen else None, + "last_event_day": days_seen[-1] if days_seen else None, + "by_day": {d: by_day[d] for d in days_seen}, + "by_hour": by_hour, + "by_actor": dict(sorted(by_actor.items(), key=lambda kv: (-kv[1], kv[0]))), + "by_event": dict(sorted(by_event.items(), key=lambda kv: (-kv[1], kv[0]))), + } + + def collect_stats(store: KBStore, *, since_days: int | None = 30) -> dict[str, Any]: """Aggregate observability metrics for the KB at ``store``.""" return { diff --git a/src/vouch/trust.py b/src/vouch/trust.py index 3bda4e8a..8d764d50 100644 --- a/src/vouch/trust.py +++ b/src/vouch/trust.py @@ -31,6 +31,7 @@ "kb.capabilities", "kb.status", "kb.stats", + "kb.activity", "kb.search", "kb.context", "kb.read_page", diff --git a/tests/test_stats.py b/tests/test_stats.py index 08aa5da5..fdda64f4 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -136,3 +136,152 @@ def test_stats_marks_expired_decisions(store: KBStore) -> None: review = stats.review_summary(store, since_days=None) assert review["expired"] == 1 assert review["by_agent"]["a"]["expired"] == 1 + + +# --- kb.activity ----------------------------------------------------------- + + +def _append_event(store: KBStore, *, event: str, actor: str, created_at: str) -> None: + import json + + line = { + "id": "0" * 32, + "event": event, + "actor": actor, + "created_at": created_at, + "object_ids": [], + "dry_run": False, + "reversible": True, + "data": {}, + "prev_hash": None, + "hash": None, + } + with (store.kb_dir / "audit.log.jsonl").open("a", encoding="utf-8") as f: + f.write(json.dumps(line) + "\n") + + +def test_collect_activity_buckets_events(store: KBStore) -> None: + src = store.put_source(b"x") + p1 = propose_claim(store, text="a", evidence=[src.id], proposed_by="agent-a") + propose_claim(store, text="b", evidence=[src.id], proposed_by="agent-b") + approve(store, p1.id, approved_by="human") + + body = stats.collect_activity(store) + assert body["by_event"]["proposal.claim.create"] == 2 + assert body["by_event"]["proposal.claim.approve"] == 1 + assert body["active_days"] == 1 + day = body["by_day"][body["first_event_day"]] + assert day["proposals"] == 2 + assert day["decisions"] == 1 + assert day["total"] == body["total_events"] + assert len(body["by_hour"]) == 7 + assert all(len(row) == 24 for row in body["by_hour"]) + assert sum(sum(row) for row in body["by_hour"]) == body["total_events"] + assert body["by_actor"] + + +def test_collect_activity_tz_offset_shifts_buckets(store: KBStore) -> None: + from vouch import audit as audit_mod + + store.put_source(b"x") + events = list(audit_mod.read_events(store.kb_dir)) + for offset in (840, -840): + body = stats.collect_activity(store, tz_offset_minutes=offset) + expected = { + (stats._utc(e.created_at) + timedelta(minutes=offset)).date().isoformat() + for e in events + } + assert set(body["by_day"]) == expected + + +def test_collect_activity_clamps_tz_offset(store: KBStore) -> None: + body = stats.collect_activity(store, tz_offset_minutes=10_000) + assert body["tz_offset_minutes"] == 840 + + +def test_collect_activity_window_excludes_old_events(store: KBStore) -> None: + _append_event( + store, event="proposal.claim.create", actor="ancient", + created_at=(datetime.now(UTC) - timedelta(days=400)).isoformat(), + ) + + windowed = stats.collect_activity(store, days=365) + all_time = stats.collect_activity(store, days=0) + assert "ancient" not in windowed["by_actor"] + assert all_time["by_actor"]["ancient"] == 1 + assert all_time["window_days"] is None + + +def test_collect_activity_rejects_negative_days(store: KBStore) -> None: + with pytest.raises(ValueError, match="days"): + stats.collect_activity(store, days=-5) + + +def test_collect_activity_iana_tz_is_dst_correct(store: KBStore) -> None: + # 23:30 UTC on a January Thursday is 17:30 the same Thursday in Chicago + # (CST, UTC-6); the viewer's *current* summer offset (-5) would put it at + # 18:30 — the IANA path must use the offset in effect at the event. + _append_event( + store, event="kb.init", actor="winter", + created_at="2026-01-15T23:30:00+00:00", + ) + body = stats.collect_activity(store, days=0, tz="America/Chicago") + assert body["by_day"] == {"2026-01-15": {"total": 1, "proposals": 0, "decisions": 0}} + assert body["by_hour"][3][17] == 1 # Thursday row, 17:00 column + + +def test_collect_activity_bad_tz_falls_back_to_offset(store: KBStore) -> None: + _append_event( + store, event="kb.init", actor="x", + created_at="2026-01-15T23:30:00+00:00", + ) + body = stats.collect_activity(store, days=0, tz="Not/AZone", tz_offset_minutes=60) + assert body["by_day"] == {"2026-01-16": {"total": 1, "proposals": 0, "decisions": 0}} + + +def test_collect_activity_respects_viewer_scope(store: KBStore) -> None: + from vouch.models import ArtifactScope, Visibility + from vouch.scoping import ViewerContext + + src = store.put_source(b"evidence") + store.put_claim(Claim( + id="billing-secret", + text="billing", + evidence=[src.id], + scope=ArtifactScope(visibility=Visibility.PROJECT, project="billing"), + )) + from vouch import audit as audit_mod + + audit_mod.log_event( + store.kb_dir, event="claim.create", actor="billing-bot", + object_ids=["billing-secret"], + ) + + unscoped = stats.collect_activity(store, days=0) + scoped = stats.collect_activity( + store, days=0, viewer=ViewerContext(project="design"), + ) + assert "billing-bot" in unscoped["by_actor"] + assert "billing-bot" not in scoped["by_actor"] + assert scoped["viewer"] == {"project": "design", "agent": None} + + +def test_jsonl_kb_activity(store: KBStore) -> None: + src = store.put_source(b"x") + propose_claim(store, text="a", evidence=[src.id], proposed_by="agent-a") + resp = handle_request({"id": "1", "method": "kb.activity", "params": {"days": 0}}) + assert resp["ok"] is True + assert resp["result"]["total_events"] >= 1 + assert len(resp["result"]["by_hour"]) == 7 + + +def test_cli_activity_json(store: KBStore) -> None: + import json + + src = store.put_source(b"x") + propose_claim(store, text="a", evidence=[src.id], proposed_by="agent-a") + result = CliRunner().invoke(cli, ["activity", "--json"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["total_events"] >= 1 + assert data["active_days"] >= 1 diff --git a/tests/test_trust.py b/tests/test_trust.py index 848d6148..60182e7c 100644 --- a/tests/test_trust.py +++ b/tests/test_trust.py @@ -181,6 +181,7 @@ def test_read_methods_constant_covers_declared_reads() -> None: if m.startswith(read_prefixes) or m in { "kb.capabilities", "kb.stats", + "kb.activity", "kb.audit", "kb.why", "kb.trace", From bc75fcb0b9b1539a67c3f848fcefdf2d07cccf57 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:53:46 +0900 Subject: [PATCH 16/18] feat(webapp): dashboard view of kb activity, landing on / new /dashboard view driven by kb.activity: a 12-month activity calendar (ember heat ramp themed for dark and light via --heat-* css vars), last-30-days bars, an hour-of-week heatmap, and top-actor / event-mix bar lists, with stat tiles fed by kb.status and kb.stats. the calendar fetches 371 days so its oldest drawn column always sits inside the server window, sends the browser's iana timezone for dst-correct bucketing, and degrades cleanly: endpoints that don't advertise kb.activity get an upgrade hint, empty audit logs get an empty state, and a malformed stats payload no longer crashes the view. / now redirects to the dashboard instead of chat and the tab leads the sidebar; the e2e smoke flow clicks through to chat accordingly. --- webapp/e2e/smoke.spec.ts | 4 + webapp/src/App.test.tsx | 35 +- webapp/src/App.tsx | 4 +- webapp/src/components/Shell.tsx | 4 +- webapp/src/lib/types.ts | 18 + webapp/src/styles.css | 12 + webapp/src/views/DashboardView.test.tsx | 166 ++++++++ webapp/src/views/DashboardView.tsx | 513 ++++++++++++++++++++++++ 8 files changed, 752 insertions(+), 4 deletions(-) create mode 100644 webapp/src/views/DashboardView.test.tsx create mode 100644 webapp/src/views/DashboardView.tsx diff --git a/webapp/e2e/smoke.spec.ts b/webapp/e2e/smoke.spec.ts index 6c02d90b..ff7ac2cd 100644 --- a/webapp/e2e/smoke.spec.ts +++ b/webapp/e2e/smoke.spec.ts @@ -10,6 +10,10 @@ test('connect → ask → citation drawer → review approve', async ({ page }) await page.getByRole('button', { name: /connect/i }).click() await expect(page.getByText('127.0.0.1:8971')).toBeVisible() + // / lands on the Dashboard; move to Chat for the ask flow + await expect(page.getByRole('heading', { name: /dashboard — kb activity/i })).toBeVisible() + await page.getByRole('link', { name: /chat/i }).click() + // Chat: cited answer from the approved claim await page.getByPlaceholder(/ask the kb/i).fill('what does the vouch http server bind') await page.keyboard.press('Enter') diff --git a/webapp/src/App.test.tsx b/webapp/src/App.test.tsx index e9db04da..18b98fd1 100644 --- a/webapp/src/App.test.tsx +++ b/webapp/src/App.test.tsx @@ -1,10 +1,41 @@ import { render, screen } from '@testing-library/react' -import { beforeEach, expect, test } from 'vitest' +import { beforeEach, expect, test, vi } from 'vitest' + +vi.mock('./lib/rpc', async () => { + const actual = await vi.importActual('./lib/rpc') + return { + ...actual, + rpc: vi.fn().mockResolvedValue([]), + fetchHealth: vi.fn(), + fetchCapabilities: vi.fn(), + } +}) import App from './App' +import { fetchCapabilities, fetchHealth } from './lib/rpc' +import { seedConnection } from './test/utils' -beforeEach(() => localStorage.clear()) +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + window.history.replaceState(null, '', '/') +}) test('boots to the connect dialog when no endpoint is stored', () => { render() expect(screen.getByText(/connect to your knowledge base/i)).toBeInTheDocument() }) + +test('boots to the dashboard when connected', async () => { + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue({ + name: 'vouch', + level: 3, + methods: ['kb.list_pending'], + review_gated: true, + }) + seedConnection() + render() + expect( + await screen.findByRole('heading', { name: /dashboard — kb activity/i }), + ).toBeInTheDocument() +}) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 76860246..7b7278c8 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -6,6 +6,7 @@ import { ConnectionProvider } from './connection/ConnectionContext' import { BrowseView } from './views/BrowseView' import { ChatView } from './views/ChatView' import { ClaimsView } from './views/ClaimsView' +import { DashboardView } from './views/DashboardView' import { PendingView } from './views/PendingView' import { ReviewView } from './views/ReviewView' import { StatsView } from './views/StatsView' @@ -18,12 +19,13 @@ export default function App() { }> - } /> + } /> } /> } /> } /> } /> } /> + } /> } /> diff --git a/webapp/src/components/Shell.tsx b/webapp/src/components/Shell.tsx index 443f0621..f0d53b37 100644 --- a/webapp/src/components/Shell.tsx +++ b/webapp/src/components/Shell.tsx @@ -1,4 +1,4 @@ -import { Activity, BadgeCheck, FileClock, Inbox, Library, MessageSquare, Plug, SunMoon } from 'lucide-react' +import { Activity, BadgeCheck, FileClock, Inbox, LayoutDashboard, Library, MessageSquare, Plug, SunMoon } from 'lucide-react' import { useEffect, useState } from 'react' import { NavLink, Outlet, useLocation } from 'react-router-dom' import { ConnectDialog } from '../connection/ConnectDialog' @@ -9,6 +9,7 @@ import type { Proposal, SessionEntry } from '../lib/types' const THEME_KEY = 'vouch-ui.theme' const NAV = [ + { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, { to: '/chat', label: 'Chat', icon: MessageSquare }, { to: '/review', label: 'Review', icon: FileClock }, { to: '/pending', label: 'Pending', icon: Inbox }, @@ -23,6 +24,7 @@ const TITLES: Record = { '/pending': 'Pending review', '/claims': 'Approved claims', '/browse': 'Knowledge', + '/dashboard': 'Dashboard — KB activity', '/stats': 'Stats & health', } diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 6bbed659..ae3bd673 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -147,6 +147,24 @@ export interface KbStats { } } +/** kb.activity — audit-log buckets for the Dashboard view. */ +export interface KbActivity { + generated_at: string + window_days: number | null + tz_offset_minutes: number + viewer?: { project: string | null; agent: string | null } + total_events: number + active_days: number + first_event_day: string | null + last_event_day: string | null + /** Keyed by local date "YYYY-MM-DD" (per tz_offset_minutes). */ + by_day: Record + /** [weekday][hour] counts, weekday 0 = Monday, hour 0-23 local. */ + by_hour: number[][] + by_actor: Record + by_event: Record +} + export interface Capabilities { name: string level: number diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 1115e53a..ccababe7 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -11,6 +11,13 @@ --accent-2: #ff7c60; --rule: #2a2320; --ok: #86c06c; + /* Sequential "ember" ramp for activity heatmaps (Dashboard). One hue, + monotonic lightness against the dark paper surface; step 0 = no data. */ + --heat-0: #201917; + --heat-1: #4a2018; + --heat-2: #7d3020; + --heat-3: #b84428; + --heat-4: #ff5a3d; } :root[data-theme="light"] { @@ -24,6 +31,11 @@ --accent-2: #ff5a3d; --rule: #e5ddd2; --ok: #4e8a37; + --heat-0: #f0e9e0; + --heat-1: #ffd9cf; + --heat-2: #ff9e85; + --heat-3: #f06542; + --heat-4: #c23a1d; } @theme inline { diff --git a/webapp/src/views/DashboardView.test.tsx b/webapp/src/views/DashboardView.test.tsx new file mode 100644 index 00000000..1571d681 --- /dev/null +++ b/webapp/src/views/DashboardView.test.tsx @@ -0,0 +1,166 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, expect, test, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { fetchCapabilities, fetchHealth, rpc } from '../lib/rpc' +import { renderWithProviders, seedConnection } from '../test/utils' +import { DashboardView } from './DashboardView' + +const CAPS = { + name: 'vouch', + level: 3, + methods: ['kb.status', 'kb.stats', 'kb.activity'], + review_gated: true, +} + +// Numbers chosen to be unique on the page — the hour axis renders +// 0,3,6,…,21 as text, so fixture counts must avoid those. +const STATUS = { + kb_dir: '/tmp/demo/.vouch', + claims: 13, + pages: 3, + sources: 5, + entities: 4, + relations: 2, + evidence: 1, + sessions: 6, + pending_proposals: 2, + audit_events: 40, + index_present: true, +} + +const STATS = { + generated_at: '2026-07-04T02:18:21+00:00', + counts: STATUS, + pending: { total: 2, by_agent: { 'agent-a': 2 }, age_days: { median: 1, max: 3, oldest_id: 'x' } }, + review: { + window_days: 30, + decided_in_window: 10, + approved: 8, + rejected: 2, + expired: 0, + approval_rate: 0.8, + by_agent: { 'agent-a': { approved: 8, rejected: 2, expired: 0, pending: 2 } }, + }, + citations: { claims_total: 12, claims_with_valid_citation: 11, broken_citation: 1, invalid_claim: 0, coverage_rate: 0.9167 }, +} + +/** Local "YYYY-MM-DD" for n days ago — must match the view's bucketing. */ +function localKey(daysAgo: number): string { + const d = new Date() + d.setHours(0, 0, 0, 0) + d.setDate(d.getDate() - daysAgo) + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${d.getFullYear()}-${m}-${day}` +} + +const BY_HOUR = Array.from({ length: 7 }, () => Array(24).fill(0)) +BY_HOUR[1][14] = 3 + +const ACTIVITY = { + generated_at: '2026-07-10T08:00:00+00:00', + window_days: 365, + tz_offset_minutes: 0, + total_events: 41, + active_days: 2, + first_event_day: localKey(7), + last_event_day: localKey(0), + by_day: { + [localKey(7)]: { total: 15, proposals: 9, decisions: 2 }, + [localKey(0)]: { total: 26, proposals: 5, decisions: 4 }, + }, + by_hour: BY_HOUR, + by_actor: { 'wiki-compiler': 23, a: 11, 'vouch-capture': 8 }, + by_event: { 'proposal.page.create': 14, 'proposal.page.approve': 5 }, +} + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS) + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.status') return STATUS + if (method === 'kb.stats') return STATS + if (method === 'kb.activity') return ACTIVITY + throw new Error(`unexpected ${method}`) + }) + seedConnection() +}) + +test('renders tiles from kb.activity, kb.status and kb.stats', async () => { + renderWithProviders() + expect(await screen.findByText('41')).toBeInTheDocument() // events · 12 mo + expect(screen.getByText('events · 12 mo')).toBeInTheDocument() + expect(screen.getByText('active days')).toBeInTheDocument() + expect(await screen.findByText('13')).toBeInTheDocument() // claims + expect(await screen.findByText('80%')).toBeInTheDocument() // approval rate +}) + +test('renders the activity calendar with a metric toggle', async () => { + renderWithProviders() + expect( + await screen.findByRole('img', { + name: /activity calendar, last 12 months: 41 events across 2 active days/i, + }), + ).toBeInTheDocument() + expect(vi.mocked(rpc)).toHaveBeenCalledWith( + expect.anything(), + 'kb.activity', + expect.objectContaining({ days: 371, tz: expect.any(String) }), + ) + const proposals = screen.getByRole('button', { name: 'Proposals' }) + expect(proposals).toHaveAttribute('aria-pressed', 'false') + await userEvent.click(proposals) + expect(proposals).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: 'All events' })).toHaveAttribute('aria-pressed', 'false') +}) + +test('renders hour-of-week heatmap and 30-day bars', async () => { + renderWithProviders() + expect(await screen.findByRole('img', { name: /events by hour of week/i })).toBeInTheDocument() + expect(screen.getByRole('img', { name: /events per day, last 30 days/i })).toBeInTheDocument() + expect(screen.getByText('today')).toBeInTheDocument() +}) + +test('renders top actors and event mix with counts', async () => { + renderWithProviders() + expect(await screen.findByText('wiki-compiler')).toBeInTheDocument() + expect(screen.getByText('23')).toBeInTheDocument() + expect(screen.getByText('proposal.page.create')).toBeInTheDocument() + expect(screen.getByText('14')).toBeInTheDocument() +}) + +test('shows an upgrade hint when the endpoint lacks kb.activity', async () => { + vi.mocked(fetchCapabilities).mockResolvedValue({ ...CAPS, methods: ['kb.status', 'kb.stats'] }) + renderWithProviders() + expect(await screen.findByText(/doesn't advertise kb.activity/i)).toBeInTheDocument() + expect(vi.mocked(rpc)).not.toHaveBeenCalledWith(expect.anything(), 'kb.activity', expect.anything()) +}) + +test('shows an empty state when the audit log has no events', async () => { + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.status') return STATUS + if (method === 'kb.stats') return STATS + if (method === 'kb.activity') + return { + ...ACTIVITY, + total_events: 0, + active_days: 0, + first_event_day: null, + last_event_day: null, + by_day: {}, + by_hour: Array.from({ length: 7 }, () => Array(24).fill(0)), + by_actor: {}, + by_event: {}, + } + throw new Error(`unexpected ${method}`) + }) + renderWithProviders() + expect(await screen.findByText(/no audit activity yet/i)).toBeInTheDocument() +}) diff --git a/webapp/src/views/DashboardView.tsx b/webapp/src/views/DashboardView.tsx new file mode 100644 index 00000000..9c583a52 --- /dev/null +++ b/webapp/src/views/DashboardView.tsx @@ -0,0 +1,513 @@ +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { EmptyState } from '../components/EmptyState' +import { ErrorCard } from '../components/ErrorCard' +import { useErrorToast } from '../components/Toast' +import { useConnection } from '../connection/ConnectionContext' +import type { ProjectState } from '../connection/ConnectionContext' +import { rpc } from '../lib/rpc' +import type { KbActivity, KbStats, KbStatus } from '../lib/types' + +type DayMetric = 'total' | 'proposals' | 'decisions' + +const METRICS: { key: DayMetric; label: string; noun: string }[] = [ + { key: 'total', label: 'All events', noun: 'event' }, + { key: 'proposals', label: 'Proposals', noun: 'proposal' }, + { key: 'decisions', label: 'Decisions', noun: 'decision' }, +] + +const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + +// Steps of the ember ramp defined in styles.css — theme-aware via CSS vars. +const HEAT = [0, 1, 2, 3, 4].map((i) => `var(--heat-${i})`) + +/** Quartile bucket into the 5-step ramp; 0 is reserved for "no events". */ +function heatLevel(count: number, max: number): number { + if (count <= 0 || max <= 0) return 0 + const t = max / 4 + if (count > 3 * t) return 4 + if (count > 2 * t) return 3 + if (count > t) return 2 + return 1 +} + +/** Local-calendar key matching the server's by_day bucketing. */ +function dateKey(d: Date): string { + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${d.getFullYear()}-${m}-${day}` +} + +function daysAgo(base: Date, n: number): Date { + const d = new Date(base) + d.setDate(d.getDate() - n) + return d +} + +function localToday(): Date { + const d = new Date() + d.setHours(0, 0, 0, 0) + return d +} + +/** Monday-first weekday index, matching by_hour rows. */ +function weekdayRow(d: Date): number { + return (d.getDay() + 6) % 7 +} + +type Tip = { x: number; y: number; text: string } | null + +function ChartTip({ tip }: { tip: Tip }) { + if (!tip) return null + return ( +
+ {tip.text} +
+ ) +} + +function Tile({ label, value }: { label: string; value: string | number }) { + return ( +
+

{value}

+

{label}

+
+ ) +} + +function Card({ + title, + right, + children, +}: { + title: string + right?: React.ReactNode + children: React.ReactNode +}) { + return ( +
+
+

{title}

+ {right} +
+ {children} +
+ ) +} + +const CELL = 11 +const STEP = 13 +const CAL_WEEKS = 53 +const CAL_GUTTER_X = 30 +const CAL_GUTTER_Y = 16 + +/** GitHub-style year calendar over by_day, colored with the ember ramp. */ +function CalendarHeatmap({ byDay, metric }: { byDay: KbActivity['by_day']; metric: DayMetric }) { + const [tip, setTip] = useState(null) + const today = localToday() + const start = daysAgo(today, (CAL_WEEKS - 1) * 7 + weekdayRow(today)) + + const cells: { w: number; r: number; date: Date; count: number }[] = [] + let max = 0 + for (let w = 0; w < CAL_WEEKS; w++) { + for (let r = 0; r < 7; r++) { + const date = new Date(start) + date.setDate(start.getDate() + w * 7 + r) + if (date.getTime() > today.getTime()) break + const count = byDay[dateKey(date)]?.[metric] ?? 0 + if (count > max) max = count + cells.push({ w, r, date, count }) + } + } + + const boundaries: { w: number; label: string }[] = [] + let prevMonth = -1 + for (let w = 0; w < CAL_WEEKS; w++) { + const monday = new Date(start) + monday.setDate(start.getDate() + w * 7) + if (monday.getTime() > today.getTime()) break + if (monday.getMonth() !== prevMonth) { + prevMonth = monday.getMonth() + boundaries.push({ w, label: monday.toLocaleDateString(undefined, { month: 'short' }) }) + } + } + // The grid usually starts mid-month; labeling that sliver would put the + // wrong month over the first real columns, so drop it (GitHub does too). + if (boundaries.length > 1 && boundaries[1].w - boundaries[0].w < 3) boundaries.shift() + const monthLabels = boundaries.map((b) => ({ x: CAL_GUTTER_X + b.w * STEP, label: b.label })) + + const active = cells.filter((c) => c.count > 0).length + const shown = cells.reduce((sum, c) => sum + c.count, 0) + const noun = METRICS.find((m) => m.key === metric)?.noun ?? 'event' + const busiest = cells.reduce((top, c) => (c.count > top.count ? c : top), cells[0]) + const summary = + `Activity calendar, last 12 months: ${shown} ${noun}${shown === 1 ? '' : 's'} ` + + `across ${active} active day${active === 1 ? '' : 's'}` + + (busiest && busiest.count > 0 + ? `, busiest ${busiest.date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} with ${busiest.count}` + : '') + const width = CAL_GUTTER_X + CAL_WEEKS * STEP + const height = CAL_GUTTER_Y + 7 * STEP + + return ( +
+ + {monthLabels.map((m) => ( + + {m.label} + + ))} + {[0, 2, 4].map((r) => ( + + {WEEKDAYS[r]} + + ))} + {cells.map(({ w, r, date, count }) => ( + + setTip({ + x: e.clientX, + y: e.clientY, + text: `${count} ${noun}${count === 1 ? '' : 's'} — ${date.toLocaleDateString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + })}`, + }) + } + onMouseLeave={() => setTip(null)} + /> + ))} + + +
+ ) +} + +function RampLegend() { + return ( +
+ less + {HEAT.map((c) => ( + + ))} + more +
+ ) +} + +const HOUR_CELL = 12 +const HOUR_STEP = 14 +const HOUR_GUTTER_X = 30 +const HOUR_GUTTER_Y = 14 + +/** Hour-of-week matrix (Mon-first rows, 24 hour columns). */ +function HourHeatmap({ byHour }: { byHour: number[][] }) { + const [tip, setTip] = useState(null) + const max = Math.max(0, ...byHour.flat()) + let peak = '' + if (max > 0) { + const r = byHour.findIndex((row) => row.includes(max)) + peak = `; busiest ${WEEKDAYS[r]} ${String(byHour[r].indexOf(max)).padStart(2, '0')}:00 with ${max}` + } + const width = HOUR_GUTTER_X + 24 * HOUR_STEP + const height = HOUR_GUTTER_Y + 7 * HOUR_STEP + + return ( +
+ + {[0, 3, 6, 9, 12, 15, 18, 21].map((h) => ( + + {h} + + ))} + {WEEKDAYS.map((d, r) => ( + + {d} + + ))} + {byHour.slice(0, 7).map((row, r) => + row.slice(0, 24).map((count, h) => ( + + setTip({ + x: e.clientX, + y: e.clientY, + text: `${count} event${count === 1 ? '' : 's'} — ${WEEKDAYS[r]} ${String(h).padStart(2, '0')}:00`, + }) + } + onMouseLeave={() => setTip(null)} + /> + )), + )} + + +
+ ) +} + +/** Last 30 days as thin baseline-anchored bars. */ +function DailyBars({ byDay }: { byDay: KbActivity['by_day'] }) { + const [tip, setTip] = useState(null) + const today = localToday() + const days = Array.from({ length: 30 }, (_, i) => { + const date = daysAgo(today, 29 - i) + return { date, count: byDay[dateKey(date)]?.total ?? 0 } + }) + const max = Math.max(0, ...days.map((d) => d.count)) + const total = days.reduce((sum, d) => sum + d.count, 0) + + return ( +
+
+ {days.map(({ date, count }) => ( +
0 && count > 0 ? `${Math.max(4, (count / max) * 100)}%` : '2px', + background: count > 0 ? 'var(--accent)' : 'var(--paper-3)', + }} + onMouseMove={(e) => + setTip({ + x: e.clientX, + y: e.clientY, + text: `${count} event${count === 1 ? '' : 's'} — ${date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + })}`, + }) + } + onMouseLeave={() => setTip(null)} + /> + ))} +
+
+ {daysAgo(today, 29).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} + today +
+ +
+ ) +} + +/** Ranked label + magnitude bar + count rows (top actors, event mix). */ +function BarList({ items, empty }: { items: [string, number][]; empty: string }) { + if (items.length === 0) return

{empty}

+ const max = Math.max(...items.map(([, n]) => n)) + return ( +
    + {items.map(([name, count]) => ( +
  • + + {name} + + + + + {count} +
  • + ))} +
+ ) +} + +function pct(rate: number | null): string { + return rate === null ? '—' : `${Math.round(rate * 100)}%` +} + +/** One project's dashboard — the aggregated page stacks one per project. */ +function ProjectDashboard({ project, titled }: { project: ProjectState; titled: boolean }) { + const { conn, caps, label } = project + const endpoint = conn.endpoint + const [metric, setMetric] = useState('total') + // null = capabilities still loading; don't call or complain until known. + const supportsActivity = caps === null ? null : caps.methods.includes('kb.activity') + + const status = useQuery({ + queryKey: ['status', endpoint], + queryFn: () => rpc(conn, 'kb.status'), + refetchInterval: 15_000, + }) + const stats = useQuery({ + queryKey: ['stats', endpoint], + queryFn: () => rpc(conn, 'kb.stats', { days: 30 }), + refetchInterval: 30_000, + }) + const activity = useQuery({ + queryKey: ['activity', endpoint], + queryFn: () => + rpc(conn, 'kb.activity', { + // 53 weeks so the oldest drawn calendar column is always inside the window. + days: 371, + tz_offset_minutes: -new Date().getTimezoneOffset(), + tz: Intl.DateTimeFormat().resolvedOptions().timeZone, + }), + // kb.activity scans the whole audit log server-side — poll gently. + refetchInterval: 120_000, + enabled: supportsActivity === true, + }) + useErrorToast(status.isError, status.error) + useErrorToast(stats.isError, stats.error) + useErrorToast(activity.isError, activity.error) + + if (status.isError) + return ( + + ) + + const s = status.data + const t = stats.data + const a = activity.data + + return ( +
+ {titled && ( +

+ + {label} + + {endpoint} +

+ )} + +
+ + + {s && } + {s && } + {s && } + {t?.review && } +
+ + {supportsActivity === false && ( + + + + )} + + {activity.isError && ( + + )} + + {a && a.total_events === 0 && ( + + + + )} + + {a && a.total_events > 0 && ( + <> + +
+ {METRICS.map((m) => ( + + ))} +
+ +
+ } + > + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + )} +
+ ) +} + +export function DashboardView() { + const { scoped, aggregated } = useConnection() + return ( +
+ {scoped.map((p) => ( + + ))} +
+ ) +} From c7ec4e14c6f2a4c2ab699405f21f416bd39f637c Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:19:25 +0900 Subject: [PATCH 17/18] feat(console): add vouch-ui console, demo setup, and web integration add web-based console with dashboard view of kb activity and session management. integrate vouch-ui webapp, docker-compose demo environment, and llm-backed actions (compile + summarize). includes hatch build script and full test coverage. --- .dockerignore | 8 + .github/workflows/release.yml | 11 +- .gitignore | 2 + .vouch/audit.log.jsonl | 18 ++ ...8a6df051-6c56-4d86-b0ed-66f6638ecfdc.jsonl | 301 ------------------ .vouch/config.yaml | 2 + CHANGELOG.md | 6 + Makefile | 21 +- README.md | 23 +- demo/.env.example | 18 ++ demo/README.md | 121 +++++++ demo/docker-compose.yml | 39 +++ demo/vouch-llm.py | 100 ++++++ hatch_build.py | 27 ++ openclaw.plugin.json | 2 +- package.json | 2 +- pyproject.toml | 10 +- scripts/console.sh | 84 +++++ src/vouch/__init__.py | 2 +- src/vouch/cli.py | 65 ++++ src/vouch/web/__init__.py | 20 ++ src/vouch/web/console.py | 174 ++++++++++ tests/test_console.py | 238 ++++++++++++++ webapp/src/views/ReviewView.test.tsx | 12 +- webapp/src/views/ReviewView.tsx | 28 +- 25 files changed, 1003 insertions(+), 331 deletions(-) delete mode 100644 .vouch/captures/8a6df051-6c56-4d86-b0ed-66f6638ecfdc.jsonl create mode 100644 demo/.env.example create mode 100644 demo/README.md create mode 100644 demo/docker-compose.yml create mode 100644 demo/vouch-llm.py create mode 100644 hatch_build.py create mode 100755 scripts/console.sh create mode 100644 src/vouch/web/console.py create mode 100644 tests/test_console.py diff --git a/.dockerignore b/.dockerignore index 1d81d13e..a3b11f57 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,5 +15,13 @@ tests benchmarks skills proposals +# webapp/ is copied by demo/Dockerfile — ship only its source, never the +# host's node_modules (breaks cross-arch: npm ci reinstalls in-image) or the +# generated dist/. The root vouch image ignores webapp entirely. +webapp/node_modules +webapp/dist +webapp/test-results +webapp/playwright-report +webapp/.superpowers **/__pycache__ **/*.py[cod] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 482b5eb2..bd3c7d5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,11 +23,20 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + # Build the React console so the wheel can bundle it as vouch/web/console + # (hatch_build.py force-includes webapp/dist when present). + - uses: actions/setup-node@v4 + with: + node-version: "20" + - run: npm --prefix webapp ci && npm --prefix webapp run build - uses: actions/setup-python@v5 with: python-version: "3.12" - run: python -m pip install --upgrade build - - run: python -m build + # sdist is source-only; the wheel is built from the working tree (not the + # sdist) so the freshly-built, gitignored webapp/dist rides inside it. + - run: python -m build --sdist + - run: python -m build --wheel - uses: actions/upload-artifact@v7 with: name: dist diff --git a/.gitignore b/.gitignore index ee98bd96..b59e1ca0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ build/ # and adapters/claude-code/.claude stay tracked) /web/ /.claude/ +.vouch +docs \ No newline at end of file diff --git a/.vouch/audit.log.jsonl b/.vouch/audit.log.jsonl index 3306eae3..73a27d42 100644 --- a/.vouch/audit.log.jsonl +++ b/.vouch/audit.log.jsonl @@ -7,3 +7,21 @@ {"actor":"a","created_at":"2026-05-21T06:05:54.067884Z","data":{},"dry_run":false,"event":"source.add","id":"0b451622d85246089a1d04c45d56c007","object_ids":["67478e72acfb8fac3a059143e95c95f5cc6f7e8d4dccc05fbcea8dbccb8a4eba"],"reversible":true} {"actor":"a","created_at":"2026-05-21T06:06:58.649209Z","data":{},"dry_run":false,"event":"source.add","id":"b72feb84d842486490bb7616c616a08c","object_ids":["67478e72acfb8fac3a059143e95c95f5cc6f7e8d4dccc05fbcea8dbccb8a4eba"],"reversible":true} {"actor":"a","created_at":"2026-05-21T06:07:08.973490Z","data":{"slug_hint":"vouch-enforces-a-review-gate-on-agent-writes"},"dry_run":false,"event":"proposal.claim.create","id":"9ef38f6a9d8c4e02ad3fd196c49bcc6f","object_ids":["20260521-060708-2b81cdab"],"reversible":true} +{"actor":"vouch-capture","created_at":"2026-07-10T06:51:08.777382Z","data":{"slug_hint":"session-71-file-s"},"dry_run":false,"event":"proposal.page.create","hash":"76845f95a24cc02ab36c491b8b121340d21291954e35d52a960bbebc85e30cdc","id":"d7b136387184484e8811038ab33cf68b","object_ids":["20260710-065108-54f0c393"],"prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","reversible":true} +{"actor":"session-split","created_at":"2026-07-10T06:53:35.234458Z","data":{"slug_hint":"scaffolded-inbounds-detection-engine-package-with-typescript"},"dry_run":false,"event":"proposal.page.create","hash":"6d65bcafe2c0f48f78ea5a8b52d76a924e49d80d9195b928cda6ae3a2dd6bb3b","id":"b1d08c345681419297ce580018ae4a6c","object_ids":["20260710-065335-f6053bee"],"prev_hash":"76845f95a24cc02ab36c491b8b121340d21291954e35d52a960bbebc85e30cdc","reversible":true} +{"actor":"session-split","created_at":"2026-07-10T06:53:35.239312Z","data":{"slug_hint":"defined-core-domain-types-and-initial-type-level-test-covera"},"dry_run":false,"event":"proposal.page.create","hash":"c99d094fed530bed419a65cec72c63a515910cf0761aa69ef3bb2a76be4b5e14","id":"709f36729bd84a94a9d260d71eeeab24","object_ids":["20260710-065335-bc8ede6c"],"prev_hash":"6d65bcafe2c0f48f78ea5a8b52d76a924e49d80d9195b928cda6ae3a2dd6bb3b","reversible":true} +{"actor":"session-split","created_at":"2026-07-10T06:53:35.242918Z","data":{"slug_hint":"built-the-github-adapter-layer-fetch-normalize-handle-action"},"dry_run":false,"event":"proposal.page.create","hash":"ac62f3954014810ff07fa2fc80a8852ae8c10571015b80c5318092dd2bb1ff08","id":"f4110c94f0404fc5b7be15e8cb8f6c56","object_ids":["20260710-065335-a882803f"],"prev_hash":"c99d094fed530bed419a65cec72c63a515910cf0761aa69ef3bb2a76be4b5e14","reversible":true} +{"actor":"session-split","created_at":"2026-07-10T06:53:35.247145Z","data":{"slug_hint":"implemented-deterministic-scoring-module-with-unit-tests"},"dry_run":false,"event":"proposal.page.create","hash":"ceeb8483f50f9a6b383c0be2347fedce07941c7a70bc79e2eb924a7accc2d047","id":"0a718c0581484802a4c76038a1fb700f","object_ids":["20260710-065335-eb4a7247"],"prev_hash":"ac62f3954014810ff07fa2fc80a8852ae8c10571015b80c5318092dd2bb1ff08","reversible":true} +{"actor":"session-split","created_at":"2026-07-10T06:53:35.250920Z","data":{"slug_hint":"wired-evaluate-and-cli-entry-points"},"dry_run":false,"event":"proposal.page.create","hash":"3fc88df87d56cbdb5ace39b6df14afba28748b0c1512e39eebfcf06509c07bd1","id":"f9a5694f054c4ce09c69f1feb50e497b","object_ids":["20260710-065335-142a3abd"],"prev_hash":"ceeb8483f50f9a6b383c0be2347fedce07941c7a70bc79e2eb924a7accc2d047","reversible":true} +{"actor":"session-split","created_at":"2026-07-10T06:53:35.254094Z","data":{"slug_hint":"created-json-fixture-files-for-scenario-driven-testing"},"dry_run":false,"event":"proposal.page.create","hash":"0660c585390b276a3255617710e8105fe572ee23b4aed8aa54b519d3c93dc1a8","id":"c3f3d0583f8e4272b949705f977e41cc","object_ids":["20260710-065335-a8161813"],"prev_hash":"3fc88df87d56cbdb5ace39b6df14afba28748b0c1512e39eebfcf06509c07bd1","reversible":true} +{"actor":"session-split","created_at":"2026-07-10T06:53:35.263211Z","data":{"reason":"superseded by llm narrative summary"},"dry_run":false,"event":"proposal.page.reject","hash":"270b31129cdf9096dbdc4717f7bfd1ebb6a0a4d6ade2e9df467b8989465f88dd","id":"900e8f66fcdf4babbdea394716afa54a","object_ids":["20260710-065108-54f0c393"],"prev_hash":"0660c585390b276a3255617710e8105fe572ee23b4aed8aa54b519d3c93dc1a8","reversible":true} +{"actor":"session-split","created_at":"2026-07-10T06:53:35.265122Z","data":{"dropped":0,"observations":0,"proposed":6,"truncated":false},"dry_run":false,"event":"session.split","hash":"234acadc3c644da01487935d5f117a25892e7b46fa1ca8dbc188ea3b77cc1c3d","id":"a98c8540c53b4021b1adc938def2335e","object_ids":["20260710-065335-f6053bee","20260710-065335-bc8ede6c","20260710-065335-a882803f","20260710-065335-eb4a7247","20260710-065335-142a3abd","20260710-065335-a8161813"],"prev_hash":"270b31129cdf9096dbdc4717f7bfd1ebb6a0a4d6ade2e9df467b8989465f88dd","reversible":true} +{"actor":"unknown-agent","created_at":"2026-07-10T07:24:02.966238Z","data":{"reason":null},"dry_run":false,"event":"proposal.page.approve","hash":"0a979813ec3ce3f3909e5f8519527157052ccdb85a5dea3fd12184aae1697130","id":"71fece2560d040a493b8f89534730775","object_ids":["20260710-065335-bc8ede6c","defined-core-domain-types-and-initial-type-level-test-covera"],"prev_hash":"234acadc3c644da01487935d5f117a25892e7b46fa1ca8dbc188ea3b77cc1c3d","reversible":true} +{"actor":"wiki-compiler","created_at":"2026-07-10T07:24:33.615350Z","data":{"slug_hint":"review-gated-knowledge-proposal-workflow"},"dry_run":false,"event":"proposal.page.create","hash":"dd18e31fc67152a2135b2ebb5e241b9e6a04d1fb19c4d77b2b91fbbcb5de775b","id":"fa3ba361a4fa4094ba52c07624c924b7","object_ids":["20260710-072433-ffff6671"],"prev_hash":"0a979813ec3ce3f3909e5f8519527157052ccdb85a5dea3fd12184aae1697130","reversible":true} +{"actor":"wiki-compiler","created_at":"2026-07-10T07:24:33.617853Z","data":{"slug_hint":"repository-as-knowledge-store"},"dry_run":false,"event":"proposal.page.create","hash":"8b35e84a4e0ef89e89c244568de6158f33117a1a64bc131542b3a94072291f60","id":"daa78f7818a1442496c0b69071378fbe","object_ids":["20260710-072433-123db33a"],"prev_hash":"dd18e31fc67152a2135b2ebb5e241b9e6a04d1fb19c4d77b2b91fbbcb5de775b","reversible":true} +{"actor":"unknown-agent","created_at":"2026-07-10T07:24:33.618337Z","data":{"dropped":0,"proposed":2,"proposer":"wiki-compiler"},"dry_run":false,"event":"compile.run","hash":"6aa930c42142624a0ca4753564efaaf5bfbf6e19ced80e7f0b0fd4c09ee66aed","id":"83373b1dda3c45f19748753b2bc9de88","object_ids":["20260710-072433-ffff6671","20260710-072433-123db33a"],"prev_hash":"8b35e84a4e0ef89e89c244568de6158f33117a1a64bc131542b3a94072291f60","reversible":true} +{"actor":"vouch-extractor","created_at":"2026-07-10T07:25:41.876231Z","data":{"slug_hint":"review-gated-knowledge-proposal-workflow-mentions-reviewed-k"},"dry_run":false,"event":"proposal.relation.create","hash":"f35167409cbe540e24293b348e0127e60553e171a35d32fb8b5b71c33317bfce","id":"e73a022074444ddb9e30407963f20907","object_ids":["20260710-072541-96ce439f"],"prev_hash":"6aa930c42142624a0ca4753564efaaf5bfbf6e19ced80e7f0b0fd4c09ee66aed","reversible":true} +{"actor":"unknown-agent","created_at":"2026-07-10T07:25:41.877790Z","data":{"reason":null},"dry_run":false,"event":"proposal.page.approve","hash":"1a2d24c3a512bc4c0171efb61c9631f4882477170e152aad4199e732f829fc5e","id":"34b056e066f7444d819f0486bc5a0584","object_ids":["20260710-072433-ffff6671","review-gated-knowledge-proposal-workflow"],"prev_hash":"f35167409cbe540e24293b348e0127e60553e171a35d32fb8b5b71c33317bfce","reversible":true} +{"actor":"unknown-agent","created_at":"2026-07-10T07:25:59.154361Z","data":{"reason":null},"dry_run":false,"event":"proposal.page.approve","hash":"b4cc6e9d79da47b096917cdeefd5d5b0910bda7b7bc06a7e146d8dd9d0038310","id":"b8c6719b8d44472883e40b016f8faad0","object_ids":["20260710-072433-123db33a","repository-as-knowledge-store"],"prev_hash":"1a2d24c3a512bc4c0171efb61c9631f4882477170e152aad4199e732f829fc5e","reversible":true} +{"actor":"unknown-agent","created_at":"2026-07-10T09:48:36.518125Z","data":{"reason":null},"dry_run":false,"event":"proposal.page.approve","hash":"9f2e75b6d37082ee754475878d52da597cc2cffaf42627b832adcf16a642df4c","id":"d509714619ff4e36b46e7c6360614f26","object_ids":["20260710-065335-eb4a7247","implemented-deterministic-scoring-module-with-unit-tests"],"prev_hash":"b4cc6e9d79da47b096917cdeefd5d5b0910bda7b7bc06a7e146d8dd9d0038310","reversible":true} +{"actor":"unknown-agent","created_at":"2026-07-10T09:48:48.002564Z","data":{"reason":null},"dry_run":false,"event":"proposal.page.approve","hash":"dbb2d71c150fec0055c3e1c825c9503d6e2bcf638fcb57c33f71f46e8cb8b630","id":"ff3b16ca8ebc484fb57bcd75700dcb7e","object_ids":["20260710-065335-142a3abd","wired-evaluate-and-cli-entry-points"],"prev_hash":"9f2e75b6d37082ee754475878d52da597cc2cffaf42627b832adcf16a642df4c","reversible":true} diff --git a/.vouch/captures/8a6df051-6c56-4d86-b0ed-66f6638ecfdc.jsonl b/.vouch/captures/8a6df051-6c56-4d86-b0ed-66f6638ecfdc.jsonl deleted file mode 100644 index db6ea81b..00000000 --- a/.vouch/captures/8a6df051-6c56-4d86-b0ed-66f6638ecfdc.jsonl +++ /dev/null @@ -1,301 +0,0 @@ -{"cmd": "cd /home/a/Dev/inbounds && npm install @anthropic-ai/sdk && npm install -D typescript vitest tsx @types/node", "summary": "Ran: cd /home/a/Dev/inbounds && npm install @anthropic-ai/sdk && ", "tool": "Bash", "ts": 1783415910.6256332} -{"files": ["/home/a/Dev/inbounds/package.json"], "summary": "Read package.json", "tool": "Read", "ts": 1783415915.6190112} -{"files": ["/home/a/Dev/inbounds/package.json"], "summary": "Edited package.json", "tool": "Edit", "ts": 1783415929.238057} -{"files": ["/home/a/Dev/inbounds/tsconfig.json"], "summary": "Created tsconfig.json", "tool": "Write", "ts": 1783415935.2588933} -{"files": ["/home/a/Dev/inbounds/vitest.config.ts"], "summary": "Created vitest.config.ts", "tool": "Write", "ts": 1783415941.2361197} -{"files": ["/home/a/Dev/inbounds/.gitignore"], "summary": "Created .gitignore", "tool": "Write", "ts": 1783415945.6708295} -{"files": ["/home/a/Dev/inbounds/tests/types.test.ts"], "summary": "Created types.test.ts", "tool": "Write", "ts": 1783415954.255305} -{"files": ["/home/a/Dev/inbounds/src/types.ts"], "summary": "Created types.ts", "tool": "Write", "ts": 1783415980.5372076} -{"cmd": "cd /home/a/Dev/inbounds && npm test 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npm test 2>&1", "tool": "Bash", "ts": 1783415989.0359955} -{"cmd": "cd /home/a/Dev/inbounds && git status", "summary": "Ran: cd /home/a/Dev/inbounds && git status", "tool": "Bash", "ts": 1783416009.2342477} -{"cmd": "cd /home/a/Dev/inbounds && git add package.json package-lock.json tsconfig.json vitest.config.ts .gitignore src/types.ts tests/types.test.ts", "summary": "Ran: cd /home/a/Dev/inbounds && git add package.json package-lock", "tool": "Bash", "ts": 1783416018.3577073} -{"cmd": "printf 'chore: scaffold detection-engine package and core types\\n' > /tmp/inb-msg.txt && cd /home/a/Dev/inbounds && git commit -F /tmp/inb-msg.txt", "summary": "Ran: printf 'chore: scaffold detection-engine package and core ty", "tool": "Bash", "ts": 1783416034.696153} -{"cmd": "cd /home/a/Dev/inbounds && git log --oneline -2", "summary": "Ran: cd /home/a/Dev/inbounds && git log --oneline -2", "tool": "Bash", "ts": 1783416040.2233598} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-1-report.md"], "summary": "Created task-1-report.md", "tool": "Write", "ts": 1783416069.5363379} -{"cmd": "IN=/home/a/Dev/inbounds\nART=/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd\nBASE=a2aba08\n{\n echo \"# Task 1 review package\"\n echo\n echo \"## Revisi", "summary": "Ran: IN=/home/a/Dev/inbounds", "tool": "Bash", "ts": 1783416137.4659147} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-1-brief.md"], "summary": "Read task-1-brief.md", "tool": "Read", "ts": 1783416222.2542078} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-1-report.md"], "summary": "Read task-1-report.md", "tool": "Read", "ts": 1783416222.2690136} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-1-review.md"], "summary": "Read task-1-review.md", "tool": "Read", "ts": 1783416222.3714423} -{"cmd": "test -d /home/a/Dev/inbounds && echo \"exists\" || echo \"missing\"", "summary": "Ran: test -d /home/a/Dev/inbounds && echo \"exists\" || echo \"missi", "tool": "Bash", "ts": 1783416240.8824131} -{"cmd": "cd /home/a/Dev/inbounds && git log -1 --format='%H%n%s%n---%n%b' 03ae212 2>&1; echo \"===\"; git show --stat 03ae212 2>&1 | head -20", "summary": "Ran: cd /home/a/Dev/inbounds && git log -1 --format='%H%n%s%n---%", "tool": "Bash", "ts": 1783416245.3671453} -{"cmd": "cd /home/a/Dev/inbounds && ls -la && echo \"---node_modules present?---\" && test -d node_modules && echo yes || echo no && echo \"---git status---\" && git status --short", "summary": "Ran: cd /home/a/Dev/inbounds && ls -la && echo \"---node_modules p", "tool": "Bash", "ts": 1783416249.2171223} -{"cmd": "SDD=/home/a/.claude/plugins/cache/claude-plugins-official/superpowers/6.1.1/skills/subagent-driven-development/scripts\nPLAN=/home/a/Dev/inbounds/docs/superpowers/plans/2026-07-07-inbounds-detection-en", "summary": "Ran: SDD=/home/a/.claude/plugins/cache/claude-plugins-official/su", "tool": "Bash", "ts": 1783416342.7055345} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-2-brief.md"], "summary": "Read task-2-brief.md", "tool": "Read", "ts": 1783416392.2121108} -{"cmd": "ls -la /home/a/Dev/inbounds/ | head -20", "summary": "Ran: ls -la /home/a/Dev/inbounds/ | head -20", "tool": "Bash", "ts": 1783416401.8324676} -{"cmd": "ls -la /home/a/Dev/inbounds/src/", "summary": "Ran: ls -la /home/a/Dev/inbounds/src/", "tool": "Bash", "ts": 1783416406.2203987} -{"files": ["/home/a/Dev/inbounds/src/types.ts"], "summary": "Read types.ts", "tool": "Read", "ts": 1783416409.23276} -{"cmd": "ls -la /home/a/Dev/inbounds/tests/", "summary": "Ran: ls -la /home/a/Dev/inbounds/tests/", "tool": "Bash", "ts": 1783416412.8685622} -{"files": ["/home/a/Dev/inbounds/tests/deterministic.test.ts"], "summary": "Created deterministic.test.ts", "tool": "Write", "ts": 1783416425.2343228} -{"files": ["/home/a/Dev/inbounds/src/deterministic.ts"], "summary": "Created deterministic.ts", "tool": "Write", "ts": 1783416446.6751528} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run tests/deterministic.test.ts 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npx vitest run tests/deterministi", "tool": "Bash", "ts": 1783416451.704663} -{"cmd": "printf 'feat: add layer-1 deterministic scope checks\\n' > /tmp/inb-msg.txt && cat /tmp/inb-msg.txt", "summary": "Ran: printf 'feat: add layer-1 deterministic scope checks\\n' > /t", "tool": "Bash", "ts": 1783416458.2608404} -{"cmd": "cd /home/a/Dev/inbounds && git add src/deterministic.ts tests/deterministic.test.ts && git commit -F /tmp/inb-msg.txt 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && git add src/deterministic.ts test", "tool": "Bash", "ts": 1783416462.4859912} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-2-report.md"], "summary": "Created task-2-report.md", "tool": "Write", "ts": 1783416481.576392} -{"cmd": "IN=/home/a/Dev/inbounds\nART=/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd\nBASE=03ae212\n{\n echo \"# Task 2 review package\"; echo\n echo \"## Revisio", "summary": "Ran: IN=/home/a/Dev/inbounds", "tool": "Bash", "ts": 1783416503.0434153} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-2-brief.md"], "summary": "Read task-2-brief.md", "tool": "Read", "ts": 1783416529.7738183} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-2-report.md"], "summary": "Read task-2-report.md", "tool": "Read", "ts": 1783416530.230445} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-2-review.md"], "summary": "Read task-2-review.md", "tool": "Read", "ts": 1783416530.694168} -{"cmd": "ls /home/a/Dev/inbounds 2>/dev/null && echo \"---types---\" && cat /home/a/Dev/inbounds/src/types.ts 2>/dev/null", "summary": "Command failed: ls /home/a/Dev/inbounds 2>/dev/null && echo \"---types---\" &&", "tool": "Bash", "ts": 1783416537.5923088} -{"cmd": "cd /home/a/Dev/inbounds && git log --oneline -10 && echo --- && git show a1557e7 --stat 2>/dev/null | head -20", "summary": "Ran: cd /home/a/Dev/inbounds && git log --oneline -10 && echo ---", "tool": "Bash", "ts": 1783416542.2591913} -{"cmd": "cd /home/a/Dev/inbounds && cat package.json | head -20 && echo --- && cat tsconfig.json", "summary": "Ran: cd /home/a/Dev/inbounds && cat package.json | head -20 && ec", "tool": "Bash", "ts": 1783416547.2602286} -{"cmd": "cd /home/a/Dev/inbounds && ls -la && cat vitest.config.ts 2>/dev/null && echo --- && find . -maxdepth 1 -iname \"*eslint*\" -o -iname \".prettierrc*\" 2>/dev/null", "summary": "Ran: cd /home/a/Dev/inbounds && ls -la && cat vitest.config.ts 2>", "tool": "Bash", "ts": 1783416593.1751} -{"cmd": "SDD=/home/a/.claude/plugins/cache/claude-plugins-official/superpowers/6.1.1/skills/subagent-driven-development/scripts\nPLAN=/home/a/Dev/inbounds/docs/superpowers/plans/2026-07-07-inbounds-detection-en", "summary": "Ran: SDD=/home/a/.claude/plugins/cache/claude-plugins-official/su", "tool": "Bash", "ts": 1783416634.6230276} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-3-brief.md"], "summary": "Read task-3-brief.md", "tool": "Read", "ts": 1783416736.5826159} -{"cmd": "ls -la /home/a/Dev/inbounds/", "summary": "Ran: ls -la /home/a/Dev/inbounds/", "tool": "Bash", "ts": 1783416744.3932507} -{"cmd": "ls -la /home/a/Dev/inbounds/src/ && echo \"---\" && ls -la /home/a/Dev/inbounds/tests/", "summary": "Ran: ls -la /home/a/Dev/inbounds/src/ && echo \"---\" && ls -la /ho", "tool": "Bash", "ts": 1783416751.2726197} -{"files": ["/home/a/Dev/inbounds/tests/prompt.test.ts"], "summary": "Created prompt.test.ts", "tool": "Write", "ts": 1783416766.2703962} -{"files": ["/home/a/Dev/inbounds/src/prompt.ts"], "summary": "Created prompt.ts", "tool": "Write", "ts": 1783416785.5953317} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run tests/prompt.test.ts 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npx vitest run tests/prompt.test.", "tool": "Bash", "ts": 1783416792.6955857} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npx vitest run 2>&1", "tool": "Bash", "ts": 1783416800.7230666} -{"cmd": "printf 'feat: add judge prompt builder\\n' > /tmp/inb-msg.txt && cat /tmp/inb-msg.txt", "summary": "Ran: printf 'feat: add judge prompt builder\\n' > /tmp/inb-msg.txt", "tool": "Bash", "ts": 1783416807.2756615} -{"cmd": "cd /home/a/Dev/inbounds && git add src/prompt.ts tests/prompt.test.ts && git status", "summary": "Ran: cd /home/a/Dev/inbounds && git add src/prompt.ts tests/promp", "tool": "Bash", "ts": 1783416811.2445877} -{"cmd": "cd /home/a/Dev/inbounds && git commit -F /tmp/inb-msg.txt", "summary": "Ran: cd /home/a/Dev/inbounds && git commit -F /tmp/inb-msg.txt", "tool": "Bash", "ts": 1783416817.2601204} -{"cmd": "cd /home/a/Dev/inbounds && git log -1 --format=\"%H %s\"", "summary": "Ran: cd /home/a/Dev/inbounds && git log -1 --format=\"%H %s\"", "tool": "Bash", "ts": 1783416822.2445652} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-3-report.md"], "summary": "Created task-3-report.md", "tool": "Write", "ts": 1783416844.456532} -{"files": ["/home/a/Dev/inbounds/src/prompt.ts"], "summary": "Read prompt.ts", "tool": "Read", "ts": 1783416848.6265783} -{"cmd": "cd /home/a/Dev/inbounds && git log --oneline -3", "summary": "Ran: cd /home/a/Dev/inbounds && git log --oneline -3", "tool": "Bash", "ts": 1783416855.4761815} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-3-report.md"], "summary": "Read task-3-report.md", "tool": "Read", "ts": 1783416860.2940414} -{"cmd": "IN=/home/a/Dev/inbounds\nART=/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd\nBASE=a1557e7\n{\n echo \"# Task 3 review package\"; echo\n echo \"## Revisio", "summary": "Ran: IN=/home/a/Dev/inbounds", "tool": "Bash", "ts": 1783416878.096693} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-3-brief.md"], "summary": "Read task-3-brief.md", "tool": "Read", "ts": 1783416905.5724936} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-3-review.md"], "summary": "Read task-3-review.md", "tool": "Read", "ts": 1783416906.7750812} -{"cmd": "find /home/a/Dev/inbounds -maxdepth 2 2>/dev/null; echo \"---\"; test -f /home/a/Dev/inbounds/src/types.ts && cat /home/a/Dev/inbounds/src/types.ts", "summary": "Command failed: find /home/a/Dev/inbounds -maxdepth 2 2>/dev/null; echo \"---", "tool": "Bash", "ts": 1783416911.8460736} -{"cmd": "cd /home/a/Dev/inbounds && git log --oneline -5 && echo \"---diff head---\" && git show --stat HEAD && echo \"---commit msg---\" && git log -1 --pretty=full", "summary": "Ran: cd /home/a/Dev/inbounds && git log --oneline -5 && echo \"---", "tool": "Bash", "ts": 1783416916.4221568} -{"cmd": "cd /home/a/Dev/inbounds && cat -A tests/prompt.test.ts | head -5 && echo \"---\" && npx tsc --noEmit -p tsconfig.json 2>&1 | head -30", "summary": "Ran: cd /home/a/Dev/inbounds && cat -A tests/prompt.test.ts | hea", "tool": "Bash", "ts": 1783416961.9589152} -{"cmd": "cd /home/a/Dev/inbounds && grep -n -i -A5 -B5 \"prompt\\|judge\\|axis\\|axes\" docs/design.md | head -150", "summary": "Ran: cd /home/a/Dev/inbounds && grep -n -i -A5 -B5 \"prompt\\|judge", "tool": "Bash", "ts": 1783416967.1617446} -{"cmd": "cd /home/a/Dev/inbounds && grep -c \"it(\" tests/types.test.ts tests/deterministic.test.ts tests/prompt.test.ts", "summary": "Ran: cd /home/a/Dev/inbounds && grep -c \"it(\" tests/types.test.ts", "tool": "Bash", "ts": 1783417049.382844} -{"cmd": "SDD=/home/a/.claude/plugins/cache/claude-plugins-official/superpowers/6.1.1/skills/subagent-driven-development/scripts\nPLAN=/home/a/Dev/inbounds/docs/superpowers/plans/2026-07-07-inbounds-detection-en", "summary": "Ran: SDD=/home/a/.claude/plugins/cache/claude-plugins-official/su", "tool": "Bash", "ts": 1783417087.795448} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-4-brief.md"], "summary": "Read task-4-brief.md", "tool": "Read", "ts": 1783417113.249079} -{"cmd": "cd /home/a/Dev/inbounds && find . -type f -name \"*.ts\" -o -name \"*.json\" | grep -E \"(tsconfig|package\\.json|vitest)\" | head -20", "summary": "Ran: cd /home/a/Dev/inbounds && find . -type f -name \"*.ts\" -o -n", "tool": "Bash", "ts": 1783417121.3819} -{"cmd": "cd /home/a/Dev/inbounds && ls -la src/ tests/", "summary": "Ran: cd /home/a/Dev/inbounds && ls -la src/ tests/", "tool": "Bash", "ts": 1783417125.2917376} -{"files": ["/home/a/Dev/inbounds/tests/judge.test.ts"], "summary": "Created judge.test.ts", "tool": "Write", "ts": 1783417139.248484} -{"files": ["/home/a/Dev/inbounds/src/judge.ts"], "summary": "Created judge.ts", "tool": "Write", "ts": 1783417157.6253734} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run tests/judge.test.ts 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npx vitest run tests/judge.test.t", "tool": "Bash", "ts": 1783417179.7191365} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npx vitest run 2>&1", "tool": "Bash", "ts": 1783417188.4834604} -{"cmd": "printf 'feat: add judge interface and claude judge with structured output\\n' > /tmp/inb-msg.txt && cat /tmp/inb-msg.txt", "summary": "Ran: printf 'feat: add judge interface and claude judge with stru", "tool": "Bash", "ts": 1783417204.3377187} -{"cmd": "cd /home/a/Dev/inbounds && git add src/judge.ts tests/judge.test.ts && git -C /home/a/Dev/inbounds commit -F /tmp/inb-msg.txt", "summary": "Ran: cd /home/a/Dev/inbounds && git add src/judge.ts tests/judge.", "tool": "Bash", "ts": 1783417212.380124} -{"cmd": "cd /home/a/Dev/inbounds && git log --oneline -1", "summary": "Ran: cd /home/a/Dev/inbounds && git log --oneline -1", "tool": "Bash", "ts": 1783417219.8747313} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-4-report.md"], "summary": "Created task-4-report.md", "tool": "Write", "ts": 1783417254.730016} -{"cmd": "IN=/home/a/Dev/inbounds\nART=/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd\nBASE=6a2d017\n{\n echo \"# Task 4 review package\"; echo\n echo \"## Revisio", "summary": "Ran: IN=/home/a/Dev/inbounds", "tool": "Bash", "ts": 1783417336.5508144} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-4-brief.md"], "summary": "Read task-4-brief.md", "tool": "Read", "ts": 1783417386.5967603} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-4-report.md"], "summary": "Read task-4-report.md", "tool": "Read", "ts": 1783417387.2867234} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-4-review.md"], "summary": "Read task-4-review.md", "tool": "Read", "ts": 1783417388.2569592} -{"cmd": "ls /home/a/Dev/inbounds 2>&1; echo \"---\"; find /home/a/Dev/inbounds -maxdepth 2 -type f -name \"*.ts\" 2>&1", "summary": "Ran: ls /home/a/Dev/inbounds 2>&1; echo \"---\"; find /home/a/Dev/i", "tool": "Bash", "ts": 1783417449.7897046} -{"cmd": "cd /home/a/Dev/inbounds && echo \"=== git log ===\" && git log --oneline -5 && echo \"=== judge.ts ===\" && cat -A src/judge.ts | grep -c '\\$' && cat src/judge.ts && echo \"=== types.ts (AxisVerdict, Norma", "summary": "Command failed: cd /home/a/Dev/inbounds && echo \"=== git log ===\" && git log", "tool": "Bash", "ts": 1783417459.2770448} -{"cmd": "cd /home/a/Dev/inbounds && echo \"=== AxisVerdict shape ===\" && sed -n '36,60p' src/types.ts && echo \"=== grep for real sdk import ===\" && grep -rn \"@anthropic-ai/sdk\" src tests package.json || echo \"n", "summary": "Ran: cd /home/a/Dev/inbounds && echo \"=== AxisVerdict shape ===\" ", "tool": "Bash", "ts": 1783417477.6673021} -{"cmd": "cd /home/a/Dev/inbounds && sed -n '1,30p' package.json && echo \"===\" && git log --oneline --all -- package.json", "summary": "Ran: cd /home/a/Dev/inbounds && sed -n '1,30p' package.json && ec", "tool": "Bash", "ts": 1783417488.2780344} -{"cmd": "cd /home/a/Dev/inbounds && cat tsconfig.json && echo \"=== eslint config? ===\" && ls -la | grep -i eslint || echo \"no eslint config\"", "summary": "Ran: cd /home/a/Dev/inbounds && cat tsconfig.json && echo \"=== es", "tool": "Bash", "ts": 1783417518.290554} -{"cmd": "cd /home/a/Dev/inbounds && git show -s --format='%B' bbffb48 | cat -A | head -20 && echo \"=== length of subject ===\" && git show -s --format='%s' bbffb48 | wc -c", "summary": "Ran: cd /home/a/Dev/inbounds && git show -s --format='%B' bbffb48", "tool": "Bash", "ts": 1783417541.6805203} -{"cmd": "cd /home/a/Dev/inbounds && git show --stat bbffb48 | cat", "summary": "Ran: cd /home/a/Dev/inbounds && git show --stat bbffb48 | cat", "tool": "Bash", "ts": 1783417555.2718039} -{"cmd": "cd /home/a/Dev/inbounds && grep -rc \"it(\" tests/*.test.ts", "summary": "Ran: cd /home/a/Dev/inbounds && grep -rc \"it(\" tests/*.test.ts", "tool": "Bash", "ts": 1783417561.2758229} -{"cmd": "SDD=/home/a/.claude/plugins/cache/claude-plugins-official/superpowers/6.1.1/skills/subagent-driven-development/scripts\nPLAN=/home/a/Dev/inbounds/docs/superpowers/plans/2026-07-07-inbounds-detection-en", "summary": "Ran: SDD=/home/a/.claude/plugins/cache/claude-plugins-official/su", "tool": "Bash", "ts": 1783417674.3233645} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-5-brief.md"], "summary": "Read task-5-brief.md", "tool": "Read", "ts": 1783417715.3687997} -{"files": ["/home/a/Dev/inbounds/tests/evaluate.test.ts"], "summary": "Created evaluate.test.ts", "tool": "Write", "ts": 1783417728.9409602} -{"files": ["/home/a/Dev/inbounds/src/evaluate.ts"], "summary": "Created evaluate.ts", "tool": "Write", "ts": 1783417757.4019742} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run tests/evaluate.test.ts 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npx vitest run tests/evaluate.tes", "tool": "Bash", "ts": 1783417770.7579238} -{"cmd": "printf 'feat: add aggregator that combines layers and fails open\\n' > /tmp/inb-msg.txt", "summary": "Ran: printf 'feat: add aggregator that combines layers and fails ", "tool": "Bash", "ts": 1783417776.2463677} -{"cmd": "cd /home/a/Dev/inbounds && git add src/evaluate.ts tests/evaluate.test.ts && git commit -F /tmp/inb-msg.txt", "summary": "Ran: cd /home/a/Dev/inbounds && git add src/evaluate.ts tests/eva", "tool": "Bash", "ts": 1783417793.9150033} -{"cmd": "cd /home/a/Dev/inbounds && git log -1 --pretty=format:'%h %s' && npx vitest run tests/evaluate.test.ts 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && git log -1 --pretty=format:'%h %s", "tool": "Bash", "ts": 1783417800.7249212} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-5-report.md"], "summary": "Created task-5-report.md", "tool": "Write", "ts": 1783417816.2526376} -{"cmd": "IN=/home/a/Dev/inbounds\nART=/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd\nBASE=bbffb48\n{\n echo \"# Task 5 review package\"; echo\n echo \"## Revisio", "summary": "Ran: IN=/home/a/Dev/inbounds", "tool": "Bash", "ts": 1783417904.6658409} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-5-brief.md"], "summary": "Read task-5-brief.md", "tool": "Read", "ts": 1783417972.3630705} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-5-report.md"], "summary": "Read task-5-report.md", "tool": "Read", "ts": 1783417972.4011502} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-5-review.md"], "summary": "Read task-5-review.md", "tool": "Read", "ts": 1783417972.773134} -{"cmd": "find / -maxdepth 6 -iname \"evaluate.ts\" -o -iname \"deterministic.ts\" 2>/dev/null | grep -v node_modules", "summary": "Ran: find / -maxdepth 6 -iname \"evaluate.ts\" -o -iname \"determini", "tool": "Bash", "ts": 1783417993.2254045} -{"cmd": "cd /home/a/Dev/inbounds && echo \"--- types.ts ---\" && cat src/types.ts && echo \"--- judge.ts ---\" && cat src/judge.ts && echo \"--- deterministic.ts ---\" && cat src/deterministic.ts && echo \"--- evalua", "summary": "Command failed: cd /home/a/Dev/inbounds && echo \"--- types.ts ---\" && cat sr", "tool": "Bash", "ts": 1783417999.7871332} -{"cmd": "cd /home/a/Dev/inbounds && diff <(sed -n '84,125p' /tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-5-brief.md) src/evaluate.ts\necho \"---test di", "summary": "Ran: cd /home/a/Dev/inbounds && diff <(sed -n '84,125p' /tmp/clau", "tool": "Bash", "ts": 1783418068.653655} -{"cmd": "SDD=/home/a/.claude/plugins/cache/claude-plugins-official/superpowers/6.1.1/skills/subagent-driven-development/scripts\nPLAN=/home/a/Dev/inbounds/docs/superpowers/plans/2026-07-07-inbounds-detection-en", "summary": "Ran: SDD=/home/a/.claude/plugins/cache/claude-plugins-official/su", "tool": "Bash", "ts": 1783418121.553359} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-6-brief.md"], "summary": "Read task-6-brief.md", "tool": "Read", "ts": 1783418168.462984} -{"cmd": "ls -la /home/a/Dev/inbounds", "summary": "Ran: ls -la /home/a/Dev/inbounds", "tool": "Bash", "ts": 1783418183.3739285} -{"cmd": "ls -la /home/a/Dev/inbounds/src", "summary": "Ran: ls -la /home/a/Dev/inbounds/src", "tool": "Bash", "ts": 1783418187.124383} -{"cmd": "mkdir -p /home/a/Dev/inbounds/fixtures", "summary": "Ran: mkdir -p /home/a/Dev/inbounds/fixtures", "tool": "Bash", "ts": 1783418192.355608} -{"files": ["/home/a/Dev/inbounds/fixtures/clean.json"], "summary": "Created clean.json", "tool": "Write", "ts": 1783418201.4619362} -{"files": ["/home/a/Dev/inbounds/fixtures/no-issue.json"], "summary": "Created no-issue.json", "tool": "Write", "ts": 1783418206.6065557} -{"files": ["/home/a/Dev/inbounds/fixtures/protected.json"], "summary": "Created protected.json", "tool": "Write", "ts": 1783418212.2991364} -{"files": ["/home/a/Dev/inbounds/fixtures/breadth.json"], "summary": "Created breadth.json", "tool": "Write", "ts": 1783418218.437932} -{"files": ["/home/a/Dev/inbounds/tests/golden.test.ts"], "summary": "Created golden.test.ts", "tool": "Write", "ts": 1783418228.3554232} -{"files": ["/home/a/Dev/inbounds/src/cli.ts"], "summary": "Created cli.ts", "tool": "Write", "ts": 1783418236.609138} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run tests/golden.test.ts 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npx vitest run tests/golden.test.", "tool": "Bash", "ts": 1783418247.777825} -{"cmd": "cd /home/a/Dev/inbounds && npx tsx src/cli.ts fixtures --offline 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npx tsx src/cli.ts fixtures --off", "tool": "Bash", "ts": 1783418251.7785952} -{"cmd": "cd /home/a/Dev/inbounds && npm test 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npm test 2>&1", "tool": "Bash", "ts": 1783418259.7590957} -{"cmd": "cd /home/a/Dev/inbounds && npm run build 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npm run build 2>&1", "tool": "Bash", "ts": 1783418265.056399} -{"cmd": "git -C /home/a/Dev/inbounds status", "summary": "Ran: git -C /home/a/Dev/inbounds status", "tool": "Bash", "ts": 1783418270.0091422} -{"cmd": "cd /home/a/Dev/inbounds && git add src/cli.ts fixtures/ tests/golden.test.ts && git status", "summary": "Ran: cd /home/a/Dev/inbounds && git add src/cli.ts fixtures/ test", "tool": "Bash", "ts": 1783418275.569174} -{"cmd": "printf 'feat: add cli and golden-fixture harness\\n' > /tmp/inb-msg.txt && cat /tmp/inb-msg.txt", "summary": "Ran: printf 'feat: add cli and golden-fixture harness\\n' > /tmp/i", "tool": "Bash", "ts": 1783418283.9672132} -{"cmd": "git -C /home/a/Dev/inbounds commit -F /tmp/inb-msg.txt", "summary": "Ran: git -C /home/a/Dev/inbounds commit -F /tmp/inb-msg.txt", "tool": "Bash", "ts": 1783418290.6186087} -{"cmd": "git -C /home/a/Dev/inbounds log --oneline -3", "summary": "Ran: git -C /home/a/Dev/inbounds log --oneline -3", "tool": "Bash", "ts": 1783418296.3143127} -{"cmd": "cd /home/a/Dev/inbounds && npm test 2>&1 && echo \"---\" && npm run build 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npm test 2>&1 && echo \"---\" && np", "tool": "Bash", "ts": 1783418302.842776} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-6-report.md"], "summary": "Created task-6-report.md", "tool": "Write", "ts": 1783418324.1436946} -{"cmd": "IN=/home/a/Dev/inbounds\nART=/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd\nBASE=f4ca93f\n{\n echo \"# Task 6 review package\"; echo\n echo \"## Revisio", "summary": "Ran: IN=/home/a/Dev/inbounds", "tool": "Bash", "ts": 1783418368.5507593} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-6-brief.md"], "summary": "Read task-6-brief.md", "tool": "Read", "ts": 1783418403.5365922} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-6-report.md"], "summary": "Read task-6-report.md", "tool": "Read", "ts": 1783418405.0488389} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/task-6-review.md"], "summary": "Read task-6-review.md", "tool": "Read", "ts": 1783418405.0554109} -{"cmd": "find / -maxdepth 6 -iname \"*inbounds*\" -not -path \"*/node_modules/*\" 2>/dev/null", "summary": "Ran: find / -maxdepth 6 -iname \"*inbounds*\" -not -path \"*/node_mo", "tool": "Bash", "ts": 1783418411.6603458} -{"cmd": "cd /home/a/Dev/inbounds && git log --oneline -15 && echo --- && git status && echo --- && ls src tests fixtures 2>/dev/null", "summary": "Ran: cd /home/a/Dev/inbounds && git log --oneline -15 && echo ---", "tool": "Bash", "ts": 1783418416.3486888} -{"files": ["/home/a/Dev/inbounds/src/deterministic.ts"], "summary": "Read deterministic.ts", "tool": "Read", "ts": 1783418422.7800493} -{"files": ["/home/a/Dev/inbounds/src/evaluate.ts"], "summary": "Read evaluate.ts", "tool": "Read", "ts": 1783418423.1292017} -{"files": ["/home/a/Dev/inbounds/src/types.ts"], "summary": "Read types.ts", "tool": "Read", "ts": 1783418423.4669974} -{"files": ["/home/a/Dev/inbounds/src/judge.ts"], "summary": "Read judge.ts", "tool": "Read", "ts": 1783418424.2882023} -{"files": ["/home/a/Dev/inbounds/tests/golden.test.ts"], "summary": "Read golden.test.ts", "tool": "Read", "ts": 1783418457.356214} -{"cmd": "cd /home/a/Dev/inbounds && grep -rn \"@anthropic-ai/sdk\" src tests package.json 2>/dev/null", "summary": "Ran: cd /home/a/Dev/inbounds && grep -rn \"@anthropic-ai/sdk\" src ", "tool": "Bash", "ts": 1783418457.397151} -{"files": ["/home/a/Dev/inbounds/src/cli.ts"], "summary": "Read cli.ts", "tool": "Read", "ts": 1783418457.6758199} -{"cmd": "cd /home/a/Dev/inbounds && cat package.json && echo --- && cat tsconfig.json 2>/dev/null && echo --- && ls dist 2>/dev/null | head -20", "summary": "Ran: cd /home/a/Dev/inbounds && cat package.json && echo --- && c", "tool": "Bash", "ts": 1783418465.7725816} -{"cmd": "cd /home/a/Dev/inbounds && ls *.config.* 2>/dev/null; cat vitest.config.ts 2>/dev/null; cat vitest.config.mts 2>/dev/null; echo \"---dist/cli.js---\"; cat dist/cli.js", "summary": "Command failed: cd /home/a/Dev/inbounds && ls *.config.* 2>/dev/null; cat vi", "tool": "Bash", "ts": 1783418476.665614} -{"cmd": "cd /home/a/Dev/inbounds && grep -rEo \"\\bit\\(|\\bit\\.each|\\btest\\(\" tests/*.test.ts | sort | uniq -c\necho \"---golden loop cases---\"\ngrep -c \"cases\\[\" tests/golden.test.ts 2>/dev/null\necho \"--- per file ", "summary": "Ran: cd /home/a/Dev/inbounds && grep -rEo \"\\bit\\(|\\bit\\.each|\\bte", "tool": "Bash", "ts": 1783419019.1731465} -{"cmd": "cd /home/a/Dev/inbounds && git log --oneline -- package.json | head -5 && echo --- && git show 03ae212:package.json | grep -A2 '\"bin\"'", "summary": "Ran: cd /home/a/Dev/inbounds && git log --oneline -- package.json", "tool": "Bash", "ts": 1783419071.1063383} -{"cmd": "cd /home/a/Dev/inbounds && git show -s --format='%s%n%n%b' 7f66d6e", "summary": "Ran: cd /home/a/Dev/inbounds && git show -s --format='%s%n%n%b' 7", "tool": "Bash", "ts": 1783419078.0958009} -{"cmd": "IN=/home/a/Dev/inbounds\nART=/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd\nLEDGER=/home/a/Dev/inbounds/.superpowers/sdd/progress.md\nMB=a2aba08\nprin", "summary": "Ran: IN=/home/a/Dev/inbounds", "tool": "Bash", "ts": 1783419735.7975485} -{"files": ["/home/a/Dev/inbounds/docs/design.md"], "summary": "Read design.md", "tool": "Read", "ts": 1783419786.421825} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/final-review.md"], "summary": "Read final-review.md", "tool": "Read", "ts": 1783419787.600499} -{"files": ["/home/a/Dev/inbounds/package.json"], "summary": "Read package.json", "tool": "Read", "ts": 1783419796.4176745} -{"cmd": "cd /home/a/Dev/inbounds && ls -la && echo \"---SRC---\" && ls -la src/ && echo \"---TESTS---\" && ls -la tests/ && echo \"---FIXTURES---\" && ls -la fixtures/", "summary": "Ran: cd /home/a/Dev/inbounds && ls -la && echo \"---SRC---\" && ls ", "tool": "Bash", "ts": 1783419796.4456437} -{"cmd": "cd /home/a/Dev/inbounds && echo \"=== installed SDK version ===\" && cat node_modules/@anthropic-ai/sdk/package.json 2>/dev/null | grep '\"version\"'\necho \"=== grep for output_config in SDK types ===\" && ", "summary": "Ran: cd /home/a/Dev/inbounds && echo \"=== installed SDK version =", "tool": "Bash", "ts": 1783419855.4253898} -{"cmd": "cd /home/a/Dev/inbounds && echo \"=== output_config in non-beta MessageCreateParams? ===\" && grep -n \"output_config\" node_modules/@anthropic-ai/sdk/resources/messages/messages.d.ts | head\necho \"=== doe", "summary": "Command failed: cd /home/a/Dev/inbounds && echo \"=== output_config in non-be", "tool": "Bash", "ts": 1783419871.343842} -{"cmd": "cd /home/a/Dev/inbounds && echo \"=== smallChangeLineThreshold usages in src ===\" && grep -rn \"smallChangeLineThreshold\" src/\necho \"=== main/module/exports/types/files fields in package.json ===\" && gr", "summary": "Ran: cd /home/a/Dev/inbounds && echo \"=== smallChangeLineThreshol", "tool": "Bash", "ts": 1783419885.494994} -{"cmd": "cd /home/a/Dev/inbounds && echo \"=== .gitignore ===\" && cat .gitignore\necho \"=== is there an .npmignore? ===\" && ls -la .npmignore 2>/dev/null || echo \"(no .npmignore)\"\necho \"=== dry-run what npm woul", "summary": "Ran: cd /home/a/Dev/inbounds && echo \"=== .gitignore ===\" && cat ", "tool": "Bash", "ts": 1783419957.8191385} -{"cmd": "cd /home/a/Dev/inbounds && echo \"=== evaluate.ts (numbered) ===\" && grep -n \"out_of_bounds\\|uncertain\\|fails\\|degraded\" src/evaluate.ts\necho \"=== judge.ts parse + enum lines ===\" && grep -n \"enum:\\|JS", "summary": "Command failed: cd /home/a/Dev/inbounds && echo \"=== evaluate.ts (numbered) ", "tool": "Bash", "ts": 1783420035.4184225} -{"cmd": "mkdir -p /tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd && cd /home/a/Dev/inbounds && git status && echo --- && git log --oneline -10 && echo --- &", "summary": "Ran: mkdir -p /tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6", "tool": "Bash", "ts": 1783420156.7054434} -{"files": ["/home/a/Dev/inbounds/src/types.ts"], "summary": "Read types.ts", "tool": "Read", "ts": 1783420162.1454127} -{"files": ["/home/a/Dev/inbounds/src/prompt.ts"], "summary": "Read prompt.ts", "tool": "Read", "ts": 1783420163.0295596} -{"files": ["/home/a/Dev/inbounds/src/judge.ts"], "summary": "Read judge.ts", "tool": "Read", "ts": 1783420163.4510021} -{"files": ["/home/a/Dev/inbounds/src/evaluate.ts"], "summary": "Read evaluate.ts", "tool": "Read", "ts": 1783420163.5578465} -{"files": ["/home/a/Dev/inbounds/src/deterministic.ts"], "summary": "Read deterministic.ts", "tool": "Read", "ts": 1783420164.0287323} -{"files": ["/home/a/Dev/inbounds/src/cli.ts"], "summary": "Read cli.ts", "tool": "Read", "ts": 1783420164.325256} -{"files": ["/home/a/Dev/inbounds/package.json"], "summary": "Read package.json", "tool": "Read", "ts": 1783420164.6965108} -{"files": ["/home/a/Dev/inbounds/tests/prompt.test.ts"], "summary": "Read prompt.test.ts", "tool": "Read", "ts": 1783420168.64271} -{"files": ["/home/a/Dev/inbounds/tests/evaluate.test.ts"], "summary": "Read evaluate.test.ts", "tool": "Read", "ts": 1783420168.9906507} -{"cmd": "cd /home/a/Dev/inbounds && cat tsconfig.json 2>/dev/null && echo --- && npm test 2>&1 | tail -30", "summary": "Ran: cd /home/a/Dev/inbounds && cat tsconfig.json 2>/dev/null && ", "tool": "Bash", "ts": 1783420170.5260797} -{"files": ["/home/a/Dev/inbounds/src/index.ts"], "summary": "Created index.ts", "tool": "Write", "ts": 1783420176.2784586} -{"files": ["/home/a/Dev/inbounds/package.json"], "summary": "Edited package.json", "tool": "Edit", "ts": 1783420185.5531945} -{"files": ["/home/a/Dev/inbounds/src/prompt.ts"], "summary": "Edited prompt.ts", "tool": "Edit", "ts": 1783420193.9762306} -{"files": ["/home/a/Dev/inbounds/tests/prompt.test.ts"], "summary": "Edited prompt.test.ts", "tool": "Edit", "ts": 1783420202.1721857} -{"files": ["/home/a/Dev/inbounds/tests/evaluate.test.ts"], "summary": "Edited evaluate.test.ts", "tool": "Edit", "ts": 1783420211.1030302} -{"files": ["/home/a/Dev/inbounds/tests/provider-isolation.test.ts"], "summary": "Created provider-isolation.test.ts", "tool": "Write", "ts": 1783420219.792938} -{"cmd": "cd /home/a/Dev/inbounds && npm test 2>&1 | tail -50", "summary": "Ran: cd /home/a/Dev/inbounds && npm test 2>&1 | tail -50", "tool": "Bash", "ts": 1783420228.1588392} -{"cmd": "cd /home/a/Dev/inbounds && npm run build 2>&1 | tail -50", "summary": "Ran: cd /home/a/Dev/inbounds && npm run build 2>&1 | tail -50", "tool": "Bash", "ts": 1783420250.1681535} -{"cmd": "cd /home/a/Dev/inbounds && ls dist/ && echo --- && cat dist/index.d.ts && echo --- && cat dist/index.js", "summary": "Ran: cd /home/a/Dev/inbounds && ls dist/ && echo --- && cat dist/", "tool": "Bash", "ts": 1783420255.250392} -{"cmd": "cd /home/a/Dev/inbounds && git status && echo --- && git diff --stat && echo --- && ls .git/hooks/ | grep -i pre-commit", "summary": "Ran: cd /home/a/Dev/inbounds && git status && echo --- && git dif", "tool": "Bash", "ts": 1783420259.926969} -{"cmd": "cd /home/a/Dev/inbounds && cat .gitignore 2>/dev/null && echo --- && git status --ignored=matching 2>&1 | grep -i dist", "summary": "Ran: cd /home/a/Dev/inbounds && cat .gitignore 2>/dev/null && ech", "tool": "Bash", "ts": 1783420266.2951334} -{"cmd": "cd /home/a/Dev/inbounds && git add src/index.ts package.json && git status", "summary": "Ran: cd /home/a/Dev/inbounds && git add src/index.ts package.json", "tool": "Bash", "ts": 1783420269.793463} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/msg1.txt"], "summary": "Created msg1.txt", "tool": "Write", "ts": 1783420276.8041077} -{"cmd": "cd /home/a/Dev/inbounds && git commit -F /tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/msg1.txt", "summary": "Ran: cd /home/a/Dev/inbounds && git commit -F /tmp/claude-1000/-h", "tool": "Bash", "ts": 1783420290.0875385} -{"cmd": "cd /home/a/Dev/inbounds && git add src/prompt.ts tests/prompt.test.ts tests/evaluate.test.ts tests/provider-isolation.test.ts && git status", "summary": "Ran: cd /home/a/Dev/inbounds && git add src/prompt.ts tests/promp", "tool": "Bash", "ts": 1783420295.8399503} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/msg2.txt"], "summary": "Created msg2.txt", "tool": "Write", "ts": 1783420301.555548} -{"cmd": "cd /home/a/Dev/inbounds && git log --oneline -5 && echo --- && git status", "summary": "Ran: cd /home/a/Dev/inbounds && git log --oneline -5 && echo --- ", "tool": "Bash", "ts": 1783420329.2464528} -{"cmd": "cd /home/a/Dev/inbounds && git log --oneline -3 && echo === && git status && echo === && npm test 2>&1 | tail -30 && echo === && npm run build 2>&1 | tail -30 && echo BUILD_OK", "summary": "Ran: cd /home/a/Dev/inbounds && git log --oneline -3 && echo === ", "tool": "Bash", "ts": 1783420349.385285} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/final-fix-report.md"], "summary": "Created final-fix-report.md", "tool": "Write", "ts": 1783420375.4199936} -{"cmd": "cd /home/a/Dev/inbounds && echo \"=== npm test ===\" && npm test 2>&1 | tail -20 && echo \"=== npm run build ===\" && npm run build 2>&1 | tail -5 && echo \"=== CLI offline smoke ===\" && npx tsx src/cli.ts", "summary": "Ran: cd /home/a/Dev/inbounds && echo \"=== npm test ===\" && npm te", "tool": "Bash", "ts": 1783420475.2663918} -{"cmd": "IN=/home/a/Dev/inbounds\nprintf '.superpowers/\\n' >> \"$IN/.gitignore\"\nprintf 'chore: ignore sdd scratch dir\\n' > /tmp/inb-cleanup-msg.txt\ngit -C \"$IN\" add .gitignore\ngit -C \"$IN\" commit -F /tmp/inb-cle", "summary": "Ran: IN=/home/a/Dev/inbounds", "tool": "Bash", "ts": 1783420537.9853327} -{"files": ["/home/a/Dev/inbounds/docs/superpowers/plans/2026-07-07-inbounds-github-adapter.md"], "summary": "Created 2026-07-07-inbounds-github-adapter.md", "tool": "Write", "ts": 1783420965.1679232} -{"cmd": "SDD=/home/a/.claude/plugins/cache/claude-plugins-official/superpowers/6.1.1/skills/subagent-driven-development/scripts\nPLAN=/home/a/Dev/inbounds/docs/superpowers/plans/2026-07-07-inbounds-github-adapt", "summary": "Ran: SDD=/home/a/.claude/plugins/cache/claude-plugins-official/su", "tool": "Bash", "ts": 1783422642.3636897} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-1-brief.md"], "summary": "Read adapter-task-1-brief.md", "tool": "Read", "ts": 1783422679.4245253} -{"cmd": "ls -la /home/a/Dev/inbounds 2>&1 | head -30", "summary": "Ran: ls -la /home/a/Dev/inbounds 2>&1 | head -30", "tool": "Bash", "ts": 1783422687.3650367} -{"cmd": "find /home/a/Dev/inbounds/src -type f -name \"*.ts\" | head -20", "summary": "Ran: find /home/a/Dev/inbounds/src -type f -name \"*.ts\" | head -2", "tool": "Bash", "ts": 1783422692.5753276} -{"files": ["/home/a/Dev/inbounds/src/types.ts"], "summary": "Read types.ts", "tool": "Read", "ts": 1783422699.2413843} -{"cmd": "mkdir -p /home/a/Dev/inbounds/src/github /home/a/Dev/inbounds/tests/github", "summary": "Ran: mkdir -p /home/a/Dev/inbounds/src/github /home/a/Dev/inbound", "tool": "Bash", "ts": 1783422705.2351148} -{"files": ["/home/a/Dev/inbounds/src/github/types.ts"], "summary": "Created types.ts", "tool": "Write", "ts": 1783422721.2243207} -{"files": ["/home/a/Dev/inbounds/tests/github/linked-issue.test.ts"], "summary": "Created linked-issue.test.ts", "tool": "Write", "ts": 1783422730.2215722} -{"files": ["/home/a/Dev/inbounds/src/github/linked-issue.ts"], "summary": "Created linked-issue.ts", "tool": "Write", "ts": 1783422741.2531548} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run tests/github/linked-issue.test.ts 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npx vitest run tests/github/linke", "tool": "Bash", "ts": 1783422771.4399524} -{"files": ["/home/a/Dev/inbounds/tests/github/normalize.test.ts"], "summary": "Created normalize.test.ts", "tool": "Write", "ts": 1783422786.5260482} -{"files": ["/home/a/Dev/inbounds/src/github/normalize.ts"], "summary": "Created normalize.ts", "tool": "Write", "ts": 1783422800.302056} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run tests/github/normalize.test.ts 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npx vitest run tests/github/norma", "tool": "Bash", "ts": 1783422805.703636} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run tests/github/ 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npx vitest run tests/github/ 2>&1", "tool": "Bash", "ts": 1783422809.8835173} -{"cmd": "printf 'feat(github): add adapter types, linked-issue parsing, and toNormalizedPR\\n' > /tmp/inb-msg.txt && cat /tmp/inb-msg.txt", "summary": "Ran: printf 'feat(github): add adapter types, linked-issue parsin", "tool": "Bash", "ts": 1783422814.5391488} -{"cmd": "cd /home/a/Dev/inbounds && git add src/github/types.ts src/github/linked-issue.ts src/github/normalize.ts tests/github/linked-issue.test.ts tests/github/normalize.test.ts && git commit -F /tmp/inb-msg", "summary": "Ran: cd /home/a/Dev/inbounds && git add src/github/types.ts src/g", "tool": "Bash", "ts": 1783422844.8926334} -{"cmd": "cd /home/a/Dev/inbounds && git show --stat 4fa0b0f", "summary": "Ran: cd /home/a/Dev/inbounds && git show --stat 4fa0b0f", "tool": "Bash", "ts": 1783422848.2827122} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-1-report.md"], "summary": "Created adapter-task-1-report.md", "tool": "Write", "ts": 1783422879.2865112} -{"cmd": "cd /home/a/Dev/inbounds && git show 4fa0b0f", "summary": "Ran: cd /home/a/Dev/inbounds && git show 4fa0b0f", "tool": "Bash", "ts": 1783422883.3353586} -{"cmd": "cd /home/a/Dev/inbounds && git log --oneline -3", "summary": "Ran: cd /home/a/Dev/inbounds && git log --oneline -3", "tool": "Bash", "ts": 1783422887.2138398} -{"cmd": "IN=/home/a/Dev/inbounds\nART=/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd\nBASE=1fff9a2\n{\n echo \"# Adapter Task 1 review package\"; echo\n echo \"##", "summary": "Ran: IN=/home/a/Dev/inbounds", "tool": "Bash", "ts": 1783422915.876145} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-1-brief.md"], "summary": "Read adapter-task-1-brief.md", "tool": "Read", "ts": 1783422946.5066552} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-1-report.md"], "summary": "Read adapter-task-1-report.md", "tool": "Read", "ts": 1783422947.2442498} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-1-review.md"], "summary": "Read adapter-task-1-review.md", "tool": "Read", "ts": 1783422947.7945092} -{"cmd": "ls /home/a/Dev/inbounds 2>&1 | head -50", "summary": "Ran: ls /home/a/Dev/inbounds 2>&1 | head -50", "tool": "Bash", "ts": 1783422954.5349944} -{"cmd": "cat /home/a/Dev/inbounds/src/types.ts", "summary": "Command failed: cat /home/a/Dev/inbounds/src/types.ts", "tool": "Bash", "ts": 1783422958.8705308} -{"cmd": "cd /home/a/Dev/inbounds && git log --oneline -5 && git show --stat HEAD && ls src/github tests/github 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && git log --oneline -5 && git show ", "tool": "Bash", "ts": 1783422959.6168149} -{"cmd": "cd /home/a/Dev/inbounds && cat package.json | head -30 && echo --- && cat tsconfig.json", "summary": "Ran: cd /home/a/Dev/inbounds && cat package.json | head -30 && ec", "tool": "Bash", "ts": 1783422967.5836208} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/re-check.mjs"], "summary": "Created re-check.mjs", "tool": "Write", "ts": 1783422984.3077579} -{"cmd": "node /tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/re-check.mjs", "summary": "Ran: node /tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df05", "tool": "Bash", "ts": 1783422989.5319002} -{"cmd": "cd /home/a/Dev/inbounds && npx tsc --noEmit -p tsconfig.json 2>&1 | head -60", "summary": "Ran: cd /home/a/Dev/inbounds && npx tsc --noEmit -p tsconfig.json", "tool": "Bash", "ts": 1783423025.6787412} -{"cmd": "cd /home/a/Dev/inbounds && git show HEAD -- src/github tests/github | diff - <(sed -n '15,245p' /tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adap", "summary": "Ran: cd /home/a/Dev/inbounds && git show HEAD -- src/github tests", "tool": "Bash", "ts": 1783423033.8129914} -{"cmd": "cd /home/a/Dev/inbounds && git log --oneline -1 -- src/github tests/github && echo \"--- commit message check ---\" && git log -1 --format=%B HEAD | cat -A | head -5", "summary": "Ran: cd /home/a/Dev/inbounds && git log --oneline -1 -- src/githu", "tool": "Bash", "ts": 1783423039.340116} -{"cmd": "rm -f /tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/re-check.mjs", "summary": "Ran: rm -f /tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df0", "tool": "Bash", "ts": 1783423043.3197775} -{"cmd": "SDD=/home/a/.claude/plugins/cache/claude-plugins-official/superpowers/6.1.1/skills/subagent-driven-development/scripts\nPLAN=/home/a/Dev/inbounds/docs/superpowers/plans/2026-07-07-inbounds-github-adapt", "summary": "Ran: SDD=/home/a/.claude/plugins/cache/claude-plugins-official/su", "tool": "Bash", "ts": 1783423081.1593359} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-2-brief.md"], "summary": "Read adapter-task-2-brief.md", "tool": "Read", "ts": 1783423113.5563684} -{"cmd": "find /home/a/Dev/inbounds -type f -name \"*.ts\" | head -20", "summary": "Ran: find /home/a/Dev/inbounds -type f -name \"*.ts\" | head -20", "tool": "Bash", "ts": 1783423121.8317823} -{"cmd": "ls -la /home/a/Dev/inbounds/src/github/ 2>&1 || echo \"Directory does not exist yet\"", "summary": "Ran: ls -la /home/a/Dev/inbounds/src/github/ 2>&1 || echo \"Direct", "tool": "Bash", "ts": 1783423125.010858} -{"files": ["/home/a/Dev/inbounds/src/github/types.ts"], "summary": "Read types.ts", "tool": "Read", "ts": 1783423127.7791176} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run tests/github/fix-comment.test.ts", "summary": "Ran: cd /home/a/Dev/inbounds && npx vitest run tests/github/fix-c", "tool": "Bash", "ts": 1783423158.2762535} -{"files": ["/home/a/Dev/inbounds/tests/github/actions.test.ts"], "summary": "Created actions.test.ts", "tool": "Write", "ts": 1783423169.1408412} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run tests/github/fetch.test.ts 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npx vitest run tests/github/fetch", "tool": "Bash", "ts": 1783423620.3734612} -{"cmd": "printf 'feat(github): fetch pull-request context via injected octokit\\n' > /tmp/inb-msg.txt && cat /tmp/inb-msg.txt", "summary": "Ran: printf 'feat(github): fetch pull-request context via injecte", "tool": "Bash", "ts": 1783423625.1618564} -{"cmd": "git -C /home/a/Dev/inbounds add src/github/fetch.ts tests/github/fetch.test.ts && git -C /home/a/Dev/inbounds commit -F /tmp/inb-msg.txt", "summary": "Ran: git -C /home/a/Dev/inbounds add src/github/fetch.ts tests/gi", "tool": "Bash", "ts": 1783423628.1704118} -{"cmd": "git -C /home/a/Dev/inbounds log -1 --oneline && git -C /home/a/Dev/inbounds rev-parse HEAD", "summary": "Ran: git -C /home/a/Dev/inbounds log -1 --oneline && git -C /home", "tool": "Bash", "ts": 1783423631.3561735} -{"cmd": "find /home/a/Dev/inbounds -type f -name \"*.ts\" | grep -i github | grep -v node_modules", "summary": "Ran: find /home/a/Dev/inbounds -type f -name \"*.ts\" | grep -i git", "tool": "Bash", "ts": 1783423741.9463854} -{"cmd": "echo -n \"0572f4405dd348932b52239b95ca8a089ea1a0d1\" | wc -c\ncd /home/a/Dev/inbounds && git log --oneline -5\ngit show --stat 0572f44 2>&1 | head -20\ngit cat-file -t 0572f4405dd348932b52239b95ca8a089ea1a", "summary": "Ran: echo -n \"0572f4405dd348932b52239b95ca8a089ea1a0d1\" | wc -c", "tool": "Bash", "ts": 1783423779.0612946} -{"cmd": "cd /home/a/Dev/inbounds && git log -1 --format='%B' 0572f44\necho \"---\"\ngit show 0572f44 --stat\necho \"--- check for real octokit import anywhere in repo src ---\"\ngrep -rn \"@octokit\" src/ package.json 2", "summary": "Ran: cd /home/a/Dev/inbounds && git log -1 --format='%B' 0572f44", "tool": "Bash", "ts": 1783423784.4904497} -{"cmd": "SDD=/home/a/.claude/plugins/cache/claude-plugins-official/superpowers/6.1.1/skills/subagent-driven-development/scripts\nPLAN=/home/a/Dev/inbounds/docs/superpowers/plans/2026-07-07-inbounds-github-adapt", "summary": "Ran: SDD=/home/a/.claude/plugins/cache/claude-plugins-official/su", "tool": "Bash", "ts": 1783423814.8843272} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-4-brief.md"], "summary": "Read adapter-task-4-brief.md", "tool": "Read", "ts": 1783423842.3412526} -{"cmd": "ls -la /home/a/Dev/inbounds | head -20", "summary": "Ran: ls -la /home/a/Dev/inbounds | head -20", "tool": "Bash", "ts": 1783423849.0050857} -{"cmd": "ls -la /home/a/Dev/inbounds/tests/", "summary": "Ran: ls -la /home/a/Dev/inbounds/tests/", "tool": "Bash", "ts": 1783423852.1530366} -{"cmd": "ls -la /home/a/Dev/inbounds/src/github/", "summary": "Ran: ls -la /home/a/Dev/inbounds/src/github/", "tool": "Bash", "ts": 1783423856.4175448} -{"cmd": "ls -la /home/a/Dev/inbounds/tests/github/", "summary": "Ran: ls -la /home/a/Dev/inbounds/tests/github/", "tool": "Bash", "ts": 1783423859.386153} -{"files": ["/home/a/Dev/inbounds/tests/github/execute.test.ts"], "summary": "Created execute.test.ts", "tool": "Write", "ts": 1783423872.4413724} -{"files": ["/home/a/Dev/inbounds/src/github/execute.ts"], "summary": "Created execute.ts", "tool": "Write", "ts": 1783423884.47291} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run tests/github/execute.test.ts 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npx vitest run tests/github/execu", "tool": "Bash", "ts": 1783423890.3603024} -{"cmd": "printf 'feat(github): execute github actions via injected octokit\\n' > /tmp/inb-msg.txt && cat /tmp/inb-msg.txt", "summary": "Ran: printf 'feat(github): execute github actions via injected oc", "tool": "Bash", "ts": 1783423909.2512841} -{"cmd": "cd /home/a/Dev/inbounds && git add src/github/execute.ts tests/github/execute.test.ts && git commit -F /tmp/inb-msg.txt 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && git add src/github/execute.ts tes", "tool": "Bash", "ts": 1783423923.4540262} -{"cmd": "cd /home/a/Dev/inbounds && git log --oneline -1 && echo \"---\" && git show HEAD --stat", "summary": "Ran: cd /home/a/Dev/inbounds && git log --oneline -1 && echo \"---", "tool": "Bash", "ts": 1783423927.4781008} -{"files": ["/home/a/Dev/inbounds/src/github/types.ts"], "summary": "Read types.ts", "tool": "Read", "ts": 1783423932.2706916} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run tests/github/ 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npx vitest run tests/github/ 2>&1", "tool": "Bash", "ts": 1783423940.3399734} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-4-report.md"], "summary": "Created adapter-task-4-report.md", "tool": "Write", "ts": 1783423959.4145343} -{"cmd": "IN=/home/a/Dev/inbounds\nART=/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd\nBASE=0572f44\n{\n echo \"# Adapter Task 4 review package\"; echo\n echo \"##", "summary": "Ran: IN=/home/a/Dev/inbounds", "tool": "Bash", "ts": 1783423978.386918} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-4-brief.md"], "summary": "Read adapter-task-4-brief.md", "tool": "Read", "ts": 1783423999.0120962} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-4-report.md"], "summary": "Read adapter-task-4-report.md", "tool": "Read", "ts": 1783423999.7362893} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-4-review.md"], "summary": "Read adapter-task-4-review.md", "tool": "Read", "ts": 1783424000.3425949} -{"cmd": "cd /home/a/Dev/inbounds && cat src/github/types.ts 2>/dev/null | sed -n '1,200p'", "summary": "Ran: cd /home/a/Dev/inbounds && cat src/github/types.ts 2>/dev/nu", "tool": "Bash", "ts": 1783424005.1113763} -{"cmd": "cd /home/a/Dev/inbounds && git log --oneline -5 -- src/github/execute.ts && echo --- && cat src/github/execute.ts && echo --- && cat tests/github/execute.test.ts && echo --- && grep -rn \"@octokit\" pac", "summary": "Ran: cd /home/a/Dev/inbounds && git log --oneline -5 -- src/githu", "tool": "Bash", "ts": 1783424011.0359697} -{"cmd": "SDD=/home/a/.claude/plugins/cache/claude-plugins-official/superpowers/6.1.1/skills/subagent-driven-development/scripts\nPLAN=/home/a/Dev/inbounds/docs/superpowers/plans/2026-07-07-inbounds-github-adapt", "summary": "Ran: SDD=/home/a/.claude/plugins/cache/claude-plugins-official/su", "tool": "Bash", "ts": 1783424048.4650106} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-5-brief.md"], "summary": "Read adapter-task-5-brief.md", "tool": "Read", "ts": 1783424076.234309} -{"cmd": "ls -la /home/a/Dev/inbounds/src/github/ | head -20", "summary": "Ran: ls -la /home/a/Dev/inbounds/src/github/ | head -20", "tool": "Bash", "ts": 1783424094.091917} -{"files": ["/home/a/Dev/inbounds/tests/github/handle.test.ts"], "summary": "Created handle.test.ts", "tool": "Write", "ts": 1783424109.6816263} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run tests/github/handle.test.ts 2>&1 | tee /tmp/test-step2-output.txt", "summary": "Command failed: cd /home/a/Dev/inbounds && npx vitest run tests/github/handl", "tool": "Bash", "ts": 1783424114.972796} -{"files": ["/home/a/Dev/inbounds/src/github/handle.ts"], "summary": "Created handle.ts", "tool": "Write", "ts": 1783424121.213879} -{"cmd": "cd /home/a/Dev/inbounds && npx vitest run tests/github/handle.test.ts 2>&1 | tee /tmp/test-step4-output.txt", "summary": "Ran: cd /home/a/Dev/inbounds && npx vitest run tests/github/handl", "tool": "Bash", "ts": 1783424128.9388483} -{"files": ["/home/a/Dev/inbounds/src/github/index.ts"], "summary": "Created index.ts", "tool": "Write", "ts": 1783424133.3599753} -{"files": ["/home/a/Dev/inbounds/src/index.ts"], "summary": "Read index.ts", "tool": "Read", "ts": 1783424136.9467685} -{"files": ["/home/a/Dev/inbounds/src/index.ts"], "summary": "Edited index.ts", "tool": "Edit", "ts": 1783424141.41149} -{"cmd": "cd /home/a/Dev/inbounds && npm test 2>&1 | tail -50", "summary": "Ran: cd /home/a/Dev/inbounds && npm test 2>&1 | tail -50", "tool": "Bash", "ts": 1783424146.8370144} -{"cmd": "cd /home/a/Dev/inbounds && npm run build 2>&1", "summary": "Ran: cd /home/a/Dev/inbounds && npm run build 2>&1", "tool": "Bash", "ts": 1783424151.2090828} -{"cmd": "printf 'feat(github): add pull-request orchestrator and adapter exports\\n' > /tmp/inb-msg.txt && cat /tmp/inb-msg.txt", "summary": "Ran: printf 'feat(github): add pull-request orchestrator and adap", "tool": "Bash", "ts": 1783424156.400318} -{"cmd": "cd /home/a/Dev/inbounds && git add src/github/handle.ts src/github/index.ts src/index.ts tests/github/handle.test.ts && git status", "summary": "Ran: cd /home/a/Dev/inbounds && git add src/github/handle.ts src/", "tool": "Bash", "ts": 1783424159.694651} -{"cmd": "cd /home/a/Dev/inbounds && git commit -F /tmp/inb-msg.txt", "summary": "Ran: cd /home/a/Dev/inbounds && git commit -F /tmp/inb-msg.txt", "tool": "Bash", "ts": 1783424162.9298768} -{"cmd": "cd /home/a/Dev/inbounds && git log --oneline -1 && git log -1 --pretty=format:\"%H\"", "summary": "Ran: cd /home/a/Dev/inbounds && git log --oneline -1 && git log -", "tool": "Bash", "ts": 1783424166.121336} -{"cmd": "cd /home/a/Dev/inbounds && npm test 2>&1 | grep -E \"Test Files|Tests|Duration\"", "summary": "Ran: cd /home/a/Dev/inbounds && npm test 2>&1 | grep -E \"Test Fil", "tool": "Bash", "ts": 1783424171.9182262} -{"cmd": "cd /home/a/Dev/inbounds && npm run build 2>&1 | grep -E \"error|Error|warning|Warning\" || echo \"Build successful (no errors or warnings)\"", "summary": "Command failed: cd /home/a/Dev/inbounds && npm run build 2>&1 | grep -E \"err", "tool": "Bash", "ts": 1783424176.8497171} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-5-report.md"], "summary": "Created adapter-task-5-report.md", "tool": "Write", "ts": 1783424195.183078} -{"cmd": "IN=/home/a/Dev/inbounds\nART=/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd\nBASE=8abc3eb\n{\n echo \"# Adapter Task 5 review package\"; echo\n echo \"##", "summary": "Ran: IN=/home/a/Dev/inbounds", "tool": "Bash", "ts": 1783424216.8826635} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-5-brief.md"], "summary": "Read adapter-task-5-brief.md", "tool": "Read", "ts": 1783424236.1217268} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-5-report.md"], "summary": "Read adapter-task-5-report.md", "tool": "Read", "ts": 1783424236.836857} -{"files": ["/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd/adapter-task-5-review.md"], "summary": "Read adapter-task-5-review.md", "tool": "Read", "ts": 1783424237.4072833} -{"cmd": "ls /home/a/Dev/inbounds 2>&1 | head -50", "summary": "Ran: ls /home/a/Dev/inbounds 2>&1 | head -50", "tool": "Bash", "ts": 1783424242.6297667} -{"cmd": "cd /home/a/Dev/inbounds && echo \"=== src/github dir ===\" && ls -la src/github/ && echo \"=== src/index.ts ===\" && cat src/index.ts && echo \"=== git log ===\" && git log --oneline -10", "summary": "Ran: cd /home/a/Dev/inbounds && echo \"=== src/github dir ===\" && ", "tool": "Bash", "ts": 1783424246.4498498} -{"files": ["/home/a/Dev/inbounds/src/github/types.ts"], "summary": "Read types.ts", "tool": "Read", "ts": 1783424252.2995496} -{"files": ["/home/a/Dev/inbounds/src/github/fetch.ts"], "summary": "Read fetch.ts", "tool": "Read", "ts": 1783424252.672344} -{"files": ["/home/a/Dev/inbounds/src/github/normalize.ts"], "summary": "Read normalize.ts", "tool": "Read", "ts": 1783424252.9992075} -{"files": ["/home/a/Dev/inbounds/src/github/actions.ts"], "summary": "Read actions.ts", "tool": "Read", "ts": 1783424253.408263} -{"files": ["/home/a/Dev/inbounds/src/github/execute.ts"], "summary": "Read execute.ts", "tool": "Read", "ts": 1783424253.732933} -{"files": ["/home/a/Dev/inbounds/src/github/handle.ts"], "summary": "Read handle.ts", "tool": "Read", "ts": 1783424254.0860415} -{"files": ["/home/a/Dev/inbounds/src/github/index.ts"], "summary": "Read index.ts", "tool": "Read", "ts": 1783424254.4758308} -{"files": ["/home/a/Dev/inbounds/src/github/linked-issue.ts"], "summary": "Read linked-issue.ts", "tool": "Read", "ts": 1783424254.9012418} -{"files": ["/home/a/Dev/inbounds/src/github/fix-comment.ts"], "summary": "Read fix-comment.ts", "tool": "Read", "ts": 1783424255.211981} -{"files": ["/home/a/Dev/inbounds/src/evaluate.ts"], "summary": "Read evaluate.ts", "tool": "Read", "ts": 1783424255.5471842} -{"files": ["/home/a/Dev/inbounds/src/judge.ts"], "summary": "Read judge.ts", "tool": "Read", "ts": 1783424255.9111428} -{"cmd": "cd /home/a/Dev/inbounds && cat src/deterministic.ts 2>&1 | head -80", "summary": "Ran: cd /home/a/Dev/inbounds && cat src/deterministic.ts 2>&1 | h", "tool": "Bash", "ts": 1783424261.1245732} -{"files": ["/home/a/Dev/inbounds/tests/github/handle.test.ts"], "summary": "Read handle.test.ts", "tool": "Read", "ts": 1783424261.479609} -{"cmd": "cd /home/a/Dev/inbounds && ls tests/github/ && echo \"---\" && grep -c \"it(\" tests/github/*.test.ts tests/*.test.ts 2>/dev/null | awk -F: '{sum+=$2; print} END{print \"TOTAL(approx, ignores it.each/descr", "summary": "Ran: cd /home/a/Dev/inbounds && ls tests/github/ && echo \"---\" &&", "tool": "Bash", "ts": 1783424317.1311903} -{"cmd": "cd /home/a/Dev/inbounds && cat package.json | grep -A2 '\"engines\"' ; grep -E '\"module\"|\"moduleResolution\"|\"target\"' tsconfig.json", "summary": "Ran: cd /home/a/Dev/inbounds && cat package.json | grep -A2 '\"eng", "tool": "Bash", "ts": 1783424323.6752326} -{"cmd": "IN=/home/a/Dev/inbounds\nART=/tmp/claude-1000/-home-a-Dev-plind-junior-vouch/8a6df051-6c56-4d86-b0ed-66f6638ecfdc/scratchpad/sdd\nLEDGER=/home/a/Dev/inbounds/.superpowers/sdd/progress.md\nMB=1fff9a2\nprin", "summary": "Ran: IN=/home/a/Dev/inbounds", "tool": "Bash", "ts": 1783424374.0569892} -{"cmd": "cd /home/a/Dev/inbounds\necho \"=== grep @octokit across src ===\"; grep -rn \"@octokit\" src/ 2>/dev/null || echo \"(none)\"\necho \"=== grep octokit imports (real) in src/github ===\"; grep -rn \"from '@octoki", "summary": "Ran: cd /home/a/Dev/inbounds", "tool": "Bash", "ts": 1783424571.2246087} -{"files": ["/home/a/Dev/inbounds/src/github/handle.ts"], "summary": "Read handle.ts", "tool": "Read", "ts": 1783424614.1671793} -{"files": ["/home/a/Dev/inbounds/src/github/fetch.ts"], "summary": "Read fetch.ts", "tool": "Read", "ts": 1783424614.1681373} diff --git a/.vouch/config.yaml b/.vouch/config.yaml index 108d471b..43c0ec1e 100644 --- a/.vouch/config.yaml +++ b/.vouch/config.yaml @@ -5,3 +5,5 @@ retrieval: backends: - fts5 - substring +compile: + llm_cmd: "claude -p --model sonnet" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f01b10..bb2ec563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Added +- `vouch console`: serve the vendored React web console straight from the + installed package — a same-origin `/proxy` bridge (loopback-guarded) to + `vouch serve --transport http` backends, reimplementing the vite dev-proxy + in python. the built SPA is bundled into the wheel as `vouch/web/console` + (conditionally, via a hatch build hook), so `pip install 'vouch-kb[web]'` + then `vouch console` needs no node and no repo clone. - `kb.activity` read method (+ `vouch activity` CLI mirror): audit-log activity buckets for dashboards — per-day counts with proposal/decision breakdowns, an hour-of-week matrix, and actor/event histograms. windowed diff --git a/Makefile b/Makefile index 021738a6..523e443f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install dev test test-cov bench lint format type check build clean examples-screenshots smoke-capture smoke-recall +.PHONY: help install dev test test-cov bench lint format type check build clean examples-screenshots smoke-capture smoke-recall console webapp-build PY ?= python PIP ?= $(PY) -m pip @@ -19,6 +19,7 @@ help: @echo " make examples-screenshots re-render docs/img/examples/*.svg" @echo " make smoke-capture end-to-end check of session auto-capture" @echo " make smoke-recall end-to-end check of session-start recall" + @echo " make console run the vouch backend + vouch-ui web console together" install: $(PIP) install -e '.[dev]' @@ -58,9 +59,23 @@ smoke-capture: smoke-recall: VOUCH="$(PY) -m vouch" bash scripts/smoke-recall.sh -build: +# Run the vouch HTTP backend and the vouch-ui web console together (Ctrl-C stops +# both). Installs the web console's node deps on first run. +console: + bash scripts/console.sh + +# Build the React console into webapp/dist so the wheel can bundle it as +# vouch/web/console (hatch_build.py force-includes it when present). Installs +# the web console's node deps on first run. +webapp-build: + cd webapp && { [ -d node_modules ] || npm ci; } && npm run build + +# sdist is source-only; the wheel is built from the working tree (not the +# sdist) so the freshly-built, gitignored webapp/dist rides inside it. +build: webapp-build $(PY) -m pip install --upgrade build - $(PY) -m build + $(PY) -m build --sdist + $(PY) -m build --wheel clean: rm -rf build dist *.egg-info src/*.egg-info \ diff --git a/README.md b/README.md index 8624883d..a8ce7a5c 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,16 @@ docker run --rm -p 127.0.0.1:5173:5173 -v vouch-demo-data:/data ghcr.io/plind-ju Pre-seeded KB + full webapp console, zero setup. Pass `-e ANTHROPIC_API_KEY=sk-ant-...` to enable LLM features. +**For the full UI without Docker** — Python only, no clone, no node: + +```bash +pipx install 'vouch-kb[web]' # the browser console ships inside the wheel +vouch serve --transport http --port 8731 & # a backend for the current .vouch/ +vouch console # console at http://localhost:5173 — connect it to :8731 +``` + +`vouch console` serves the same React console as the Docker demo, straight from the installed package. + **For CLI + Claude Code integration** (most common ongoing workflow): ```bash @@ -93,9 +103,10 @@ compile: vouch review # walk pending proposals one at a time ``` -**Want a browser UI for reviewing and proposing?** The video shows the **vouch webapp** — chat, review queue, claims, and stats. You have three options: +**Want a browser UI for reviewing and proposing?** The video shows the **vouch webapp** — chat, review queue, claims, and stats. You have four options: - **No setup**: Use the Docker demo (recommended) +- **pip, no clone**: `pipx install 'vouch-kb[web]'` then `vouch console` — serves the same React console from the installed package (Python only, no Docker, no node), open http://localhost:5173 - **Local development**: Clone the repo, run `make console`, open http://localhost:5173 - **CLI-only**: Use `vouch review`, `vouch show `, `vouch approve ` commands instead @@ -113,7 +124,13 @@ docker run --rm -p 127.0.0.1:5173:5173 \ # then open http://localhost:5173 ``` -Or to skip Docker entirely and use the CLI tools: +Or serve that same console with no Docker — `vouch console` in place of Terminal 2 (needs the `[web]` extra), then add the `:8731` backend in the connect dialog: + +```bash +vouch console # http://localhost:5173, proxying to the server above +``` + +Or to skip the browser entirely and use the CLI tools: ```bash vouch review # walk pending proposals @@ -122,7 +139,7 @@ vouch approve # approve a proposal vouch reject --reason "…" # reject with feedback ``` -Lighter alternatives ship with vouch itself: `vouch review-ui` (a built-in browser queue; `pipx install 'vouch-kb[web]'` for the extra), or piecemeal `vouch pending`, `vouch show `, `vouch approve `, `vouch reject --reason "…"`. +Both browser UIs ship with vouch under the `[web]` extra (`pipx install 'vouch-kb[web]'`): `vouch console` is the full React console shown in the video; `vouch review-ui` is a lighter built-in review queue. Or go piecemeal: `vouch pending`, `vouch show `, `vouch approve `, `vouch reject --reason "…"`. **5. Compile the wiki.** diff --git a/demo/.env.example b/demo/.env.example new file mode 100644 index 00000000..f4d675ba --- /dev/null +++ b/demo/.env.example @@ -0,0 +1,18 @@ +# vouch demo — environment (optional). Copy to .env to override defaults. +# +# cp .env.example .env + +# Bearer token vouch requires on its HTTP transport. The console injects it +# server-side, so the browser never sees it. Any non-empty value works for the +# local demo; change it if you like. +VOUCH_HTTP_TOKEN=vouch-demo + +# Optional — your own Anthropic API key. Set it to turn on the two LLM-backed +# actions in the console: "Compile" (approved claims -> topic pages) and +# "Summarize session". Leave it blank and the demo still runs; those two just +# report "not configured". The key stays in this container, calls go straight +# to Anthropic, and nothing is committed. +ANTHROPIC_API_KEY= + +# Optional — override the model the shim uses (defaults to a current Sonnet). +# ANTHROPIC_MODEL=claude-sonnet-4-5 diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 00000000..e47eaaa7 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,121 @@ +# vouch demo — try it in one command + +A self-contained Docker demo of [vouch](https://github.com/vouchdev/vouch), the +git-native, **review-gated** knowledge base for LLM agents. One image bundles +the vouch server and the vouch-ui web console, and seeds a starter knowledge +base on first run — so you open the browser and immediately have something to +explore. + +## Run it (no clone needed) + +One command pulls the published image and starts everything: + +```bash +docker run --rm -p 127.0.0.1:5173:5173 -v vouch-demo-data:/data \ + ghcr.io/plind-junior/vouch-demo +``` + +Then open **http://localhost:5173** and connect with the pre-filled endpoint. + +That's it. The first run seeds a starter KB (a claim, a page, a source), so the +console opens onto a populated, review-gated knowledge base — not an empty one. +Your data persists in the `vouch-demo-data` volume between runs; `Ctrl-C` stops +the demo (the `--rm` removes only the container, never the volume). + +## Update to the latest + +The image is updated in place, so pulling gets you the newest build (and any +fixes). Stop the demo, then: + +```bash +docker pull ghcr.io/plind-junior/vouch-demo # fetch the latest image +``` + +Re-run the command from "Run it" — Docker now starts the updated image, and +your `vouch-demo-data` volume carries over. To start completely fresh instead, +reset the data with `docker volume rm vouch-demo-data` before running. + +## Build from source (to hack on it) + +Working in a clone of this repo? Build the image from the checkout instead of +pulling — it picks up your local changes to `webapp/` and `src/`: + +```bash +cd demo +cp .env.example .env # optional: edit VOUCH_HTTP_TOKEN, add ANTHROPIC_API_KEY +docker compose up --build +``` + +## Turn on the LLM features (optional) + +Two console actions call a language model: **Compile** (turn approved claims +into topic pages) and **Summarize session**. They're off by default because +the demo ships no API key. Everything else — browsing, propose → approve, +delete / archive / supersede, clear queue — works without one. + +To switch them on, give the demo *your own* Anthropic key. With the pulled +image, pass it in with `-e`: + +```bash +docker run --rm -p 127.0.0.1:5173:5173 -v vouch-demo-data:/data \ + -e ANTHROPIC_API_KEY=sk-ant-... \ + ghcr.io/plind-junior/vouch-demo +``` + +Building from source? Put it in `.env` instead: + +```bash +cp .env.example .env # then set ANTHROPIC_API_KEY=sk-ant-... +docker compose up --build +``` + +The key never reaches the browser — vouch runs a tiny stdlib shim +(`vouch-llm`) inside the container that calls the Anthropic Messages API +directly. Override the model with `ANTHROPIC_MODEL` if you want a newer Sonnet. +Without a key, Compile / Summarize simply report "not configured" — that's the +review gate telling you the step is unavailable, not a crash. + +## What you can do in the console + +- **Browse / Claims** — the seeded knowledge, with citations and provenance + ("why does this claim exist?"). +- **Pending** — the propose → approve review gate, made visible. +- **Delete / Archive / Supersede** — retire a claim through the gate: delete + files a review-gated proposal (refused if other pages still cite it, which is + the point), archive hides it from retrieval, supersede replaces it. +- **Clear queue** — reject the whole pending queue at once. + +## How it works + +One container runs two processes (managed by `entrypoint.sh`): + +1. `vouch serve --transport http` on `127.0.0.1:8731` inside the container, with + a bearer token. +2. the console via `vite preview`, whose `/proxy/*` middleware forwards to the + in-container vouch endpoint. The token is **pinned and injected server-side** + (`VOUCH_TARGET` / `VOUCH_HTTP_TOKEN`), so the browser never sees it. + +Only the console port is published, and only on `127.0.0.1` — nothing leaves +your machine. Your KB lives in the `vouch-demo-data` volume: + +```bash +# pulled image (docker run): +Ctrl-C # stop (keeps your data) +docker volume rm vouch-demo-data # reset the demo KB + +# built from source (docker compose): +docker compose down # stop (keeps your data) +docker compose down -v # stop and reset the demo KB +``` + +## Notes + +- The published image (`ghcr.io/plind-junior/vouch-demo`) carries the newest + `kb.*` surface, including delete / archive / supersede — a released `vouch` + from PyPI or `ghcr.io/vouchdev/vouch` would not yet advertise those. Building + from source picks up whatever is in your checkout. +- The LLM-backed actions (Compile, Summarize session) need an + `ANTHROPIC_API_KEY` — see "Turn on the LLM features" above. The rest of the + console is fully functional without one. +- The console's "Chat / Claude Code" mode is a dev-server feature and is not + wired up in this preview build; everything else works. diff --git a/demo/docker-compose.yml b/demo/docker-compose.yml new file mode 100644 index 00000000..8b116b13 --- /dev/null +++ b/demo/docker-compose.yml @@ -0,0 +1,39 @@ +# Try vouch in one command: +# +# cd demo +# docker compose up --build # then open http://localhost:5173 +# +# A starter, review-gated knowledge base is seeded on first run. Your data +# persists in the `vouch-demo-data` volume; `docker compose down -v` resets it. +# Only the console port is published, and only on 127.0.0.1 — nothing leaves +# your machine. +name: vouch-demo + +services: + vouch-demo: + build: + context: .. + dockerfile: demo/Dockerfile + image: vouch-demo:latest + ports: + - "127.0.0.1:5173:5173" + environment: + # in-container bearer token; injected server-side, never sent to the browser + VOUCH_HTTP_TOKEN: ${VOUCH_HTTP_TOKEN:-vouch-demo} + # optional: your own Anthropic key turns on page compile & session + # summaries (Claude). Left empty, the demo still runs — those two actions + # just report "not configured". Never commit a real key. + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-claude-sonnet-4-5} + volumes: + - vouch-demo-data:/data + healthcheck: + test: ["CMD", "curl", "-fsS", "-m", "3", "http://127.0.0.1:5173/proxy/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + restart: unless-stopped + +volumes: + vouch-demo-data: diff --git a/demo/vouch-llm.py b/demo/vouch-llm.py new file mode 100644 index 00000000..ead3a180 --- /dev/null +++ b/demo/vouch-llm.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Minimal Anthropic Messages shim for the vouch demo image. + +vouch's LLM-backed features (page compile, session summaries) don't call an +API directly — they shell out to a deployment-configured command +(`compile.llm_cmd` in .vouch/config.yaml) with the prompt on stdin, and read +the model's reply from stdout. In a normal install that command is the local +`claude` CLI. The demo image has no CLI and no baked-in key, so this shim is +the `llm_cmd`: it reads the prompt on stdin and calls the Anthropic Messages +API using a key the *user* supplies via ANTHROPIC_API_KEY. + +Stdlib only (urllib) — no extra pip dependency, mirroring vouch's own client +in src/vouch/pr_cache.py. Emits only the model's text on stdout so vouch's +`parse_drafts` sees a clean JSON array; all diagnostics go to stderr, and a +non-zero exit lets vouch surface a clean "compile.llm_cmd failed" message. + +Env: + ANTHROPIC_API_KEY required — user's key; absent => exit 3, features off. + ANTHROPIC_MODEL default claude-sonnet-4-5 (override for a newer Sonnet). + ANTHROPIC_BASE_URL default https://api.anthropic.com + ANTHROPIC_MAX_TOKENS default 8192 (compile/split return multi-page JSON). + ANTHROPIC_TIMEOUT default 150 (seconds; below vouch's own subprocess cap). +""" +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request + + +def main() -> int: + key = os.environ.get("ANTHROPIC_API_KEY", "").strip() + if not key: + sys.stderr.write( + "ANTHROPIC_API_KEY is not set — this demo's LLM features " + "(page compile, session summaries) are disabled. Set the key and " + "restart to enable Claude.\n" + ) + return 3 + + model = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5").strip() + base = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com").rstrip("/") + try: + max_tokens = int(os.environ.get("ANTHROPIC_MAX_TOKENS", "8192")) + timeout = float(os.environ.get("ANTHROPIC_TIMEOUT", "150")) + except ValueError as e: + sys.stderr.write(f"invalid ANTHROPIC_MAX_TOKENS/ANTHROPIC_TIMEOUT: {e}\n") + return 2 + + prompt = sys.stdin.read() + payload = json.dumps({ + "model": model, + "max_tokens": max_tokens, + "messages": [{"role": "user", "content": prompt}], + }).encode() + req = urllib.request.Request( + f"{base}/v1/messages", + data=payload, + method="POST", + headers={ + "content-type": "application/json", + "x-api-key": key, + "anthropic-version": "2023-06-01", + }, + ) + + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read() + except urllib.error.HTTPError as e: + detail = (e.read().decode("utf-8", "replace") or "").strip()[:400] + sys.stderr.write(f"anthropic API {e.code}: {detail}\n") + return 1 + except (urllib.error.URLError, TimeoutError) as e: + sys.stderr.write(f"anthropic API call failed: {e}\n") + return 1 + + try: + data = json.loads(body) + except json.JSONDecodeError: + sys.stderr.write(f"anthropic API returned non-JSON: {body[:200]!r}\n") + return 1 + + text = "".join( + block.get("text", "") + for block in (data.get("content") or []) + if isinstance(block, dict) and block.get("type") == "text" + ) + if not text.strip(): + sys.stderr.write(f"anthropic API returned no text content: {body[:200]!r}\n") + return 1 + + sys.stdout.write(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 00000000..c47df8d6 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,27 @@ +"""Bundle the built React console (webapp/dist) into the wheel. + +When webapp/dist has been built, ship it as ``vouch/web/console`` so +``pip install 'vouch-kb[web]'`` + ``vouch console`` serves it with no node. +When it has NOT been built (a fresh checkout, or the sdist -> wheel path where +the gitignored dist isn't present), skip it silently: the wheel still builds, +and ``vouch console`` reports the missing console cleanly. This is why the +include is a hook rather than a static ``force-include``, which hard-errors on +a missing source path. +""" + +from __future__ import annotations + +import os +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class ConsoleBundleHook(BuildHookInterface): + PLUGIN_NAME = "custom" + + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + dist = os.path.join(self.root, "webapp", "dist") + if not os.path.isfile(os.path.join(dist, "index.html")): + return # not built — degrade gracefully rather than fail the build + build_data.setdefault("force_include", {})[dist] = "vouch/web/console" diff --git a/openclaw.plugin.json b/openclaw.plugin.json index ffcd470d..a21192b1 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -1,7 +1,7 @@ { "id": "vouch", "name": "Vouch", - "version": "1.2.2", + "version": "1.3.0", "description": "Git-native, review-gated knowledge base. Registers vouch's context engine (cited retrieval + salience reflex + hot memory) and the vouch skills. The kb.* MCP server is deployment config: `openclaw mcp add vouch -- vouch serve`.", "kind": "context-engine", "skills": [ diff --git a/package.json b/package.json index 9ccee39b..f62c5cb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vouch", - "version": "1.2.2", + "version": "1.3.0", "private": true, "description": "OpenClaw plugin packaging for vouch. The Python package lives in pyproject.toml; this file only tells OpenClaw's plugin loader which entry module to import and which plugin API range the plugin supports.", "openclaw": { diff --git a/pyproject.toml b/pyproject.toml index 624cf336..c15a6260 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vouch-kb" -version = "1.2.2" +version = "1.3.0" description = "Git-native, review-gated knowledge base for LLM agents. MCP server + CLI." readme = "README.md" requires-python = ">=3.11" @@ -82,6 +82,14 @@ packages = ["src/vouch"] [tool.hatch.build.targets.wheel.force-include] "adapters" = "vouch/adapters" +# The built React console (webapp/dist) rides inside the wheel as +# vouch/web/console so `pip install 'vouch-kb[web]'` + `vouch console` serves it +# with no node. It is force-included *conditionally* by hatch_build.py — a plain +# force-include hard-errors when webapp/dist isn't built (fresh checkout, sdist +# → wheel), whereas the hook skips it and `vouch console` reports it cleanly. +[tool.hatch.build.targets.wheel.hooks.custom] +path = "hatch_build.py" + [tool.ruff] line-length = 100 target-version = "py311" diff --git a/scripts/console.sh b/scripts/console.sh new file mode 100755 index 00000000..f6c9f8b2 --- /dev/null +++ b/scripts/console.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# Run the vouch backend and the vouch-ui web console together. +# +# Starts `vouch serve --transport http` (127.0.0.1:8731) and the vouch-ui Vite +# dev server (webapp/, http://localhost:5173) as a pair, and shuts both down on +# Ctrl-C. The dev server proxies the UI's /proxy/* calls to the vouch endpoint +# (via the X-Vouch-Target header), so the browser talks only to the dev server +# and vouch itself stays unmodified and needs no CORS. +# +# Usage: +# make console # from the repo root +# scripts/console.sh # equivalent +# VOUCH=vouch scripts/console.sh # use a `vouch` on PATH +# VOUCH_HOST=0.0.0.0 VOUCH_PORT=8731 scripts/console.sh +# +# Requires: node + npm (for the UI). Dependencies install automatically on the +# first run. Open http://localhost:5173 and connect to http://127.0.0.1:8731. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WEBAPP="$ROOT/webapp" +HOST="${VOUCH_HOST:-127.0.0.1}" +PORT="${VOUCH_PORT:-8731}" + +# Chat's "Claude Code mode" spawns `claude -p` in a workspace; default it to the +# repo root (the vendored bridge's own default of ../vouch is wrong from webapp/). +export VOUCH_PROJECT_DIR="${VOUCH_PROJECT_DIR:-$ROOT}" + +# Prefer the repo virtualenv's vouch, then one on PATH. Override with $VOUCH. +if [ -z "${VOUCH:-}" ]; then + if [ -x "$ROOT/.venv/bin/vouch" ]; then + VOUCH="$ROOT/.venv/bin/vouch" + else + VOUCH="vouch" + fi +fi + +if [ ! -d "$WEBAPP" ]; then + echo "console: $WEBAPP not found — the webapp/ folder is missing" >&2 + exit 1 +fi +if ! command -v npm >/dev/null 2>&1; then + echo "console: npm not found — install Node.js to run the web console" >&2 + exit 1 +fi + +# First run: install the web console's dependencies. +if [ ! -d "$WEBAPP/node_modules" ]; then + echo "[console] installing web console dependencies (first run, may take a minute)…" + (cd "$WEBAPP" && npm install) || { echo "console: npm install failed" >&2; exit 1; } +fi + +pids=() +cleanup() { + trap - EXIT INT TERM HUP + echo + echo "[console] shutting down…" + for pid in "${pids[@]}"; do + # Each service is started with `setsid`, so its pid is a process-group + # leader: kill the whole group (negative pid) to take down grandchildren + # too (e.g. npm -> vite), then fall back to the bare pid. + kill -TERM "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true + done + wait 2>/dev/null || true +} +trap cleanup EXIT INT TERM HUP + +echo "[console] backend : $VOUCH serve --transport http -> http://$HOST:$PORT" +# shellcheck disable=SC2086 +setsid $VOUCH serve --transport http --host "$HOST" --port "$PORT" & +pids+=($!) + +echo "[console] frontend: vouch-ui dev server -> http://localhost:5173" +setsid bash -c 'cd "$1" && exec npm run dev' _ "$WEBAPP" & +pids+=($!) + +echo +echo "[console] both running. Open http://localhost:5173 (endpoint http://$HOST:$PORT)" +echo "[console] press Ctrl-C to stop both." + +# Return (and tear both down via the trap) as soon as either process exits. +wait -n 2>/dev/null || wait diff --git a/src/vouch/__init__.py b/src/vouch/__init__.py index 236c24ff..c0b9743c 100644 --- a/src/vouch/__init__.py +++ b/src/vouch/__init__.py @@ -3,4 +3,4 @@ # One of FOUR version sites kept in lockstep (with pyproject.toml, # openclaw.plugin.json, package.json) — enforced by # tests/test_openclaw_plugin_manifest.py::test_manifest_versions_in_step. -__version__ = "1.2.2" +__version__ = "1.3.0" diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 0267852a..e6a7cea4 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -3793,6 +3793,71 @@ def _resolve_auth_token(auth: str | None) -> str | None: return auth +@cli.command(name="console") +@click.option( + "--bind", + "bind", + default="127.0.0.1:5173", + show_default=True, + help="host:port to bind. A non-loopback host (e.g. 0.0.0.0) also " + "requires --allow-remote so the proxy bridge isn't exposed openly.", +) +@click.option( + "--allow-remote", + is_flag=True, + help="Drop the loopback guard on the /proxy bridge. Only for a deployment " + "behind its own auth — a same-origin page could otherwise drive a " + "local reviewer's backends.", +) +@click.option( + "--open-browser/--no-open-browser", + default=True, + show_default=True, + help="Open the browser to the console on startup.", +) +def console(bind: str, allow_remote: bool, open_browser: bool) -> None: + """Serve the vouch web console (the React review UI) locally. + + Ships the built SPA and a same-origin /proxy bridge to your + `vouch serve --transport http` backends — one `pip install 'vouch-kb[web]'`, + no node. Add a backend from the connect dialog in the UI. + """ + from .web import _require_console_deps + + try: + _require_console_deps() + except ImportError as exc: + raise click.ClickException(str(exc)) from exc + + from .web.console import ConsoleError, resolve_console_dir, serve_console + + host, sep, port_raw = bind.partition(":") + if not sep: + raise click.ClickException(f"invalid --bind {bind!r}; expected host:port") + try: + port = int(port_raw) + except ValueError as exc: + raise click.ClickException(f"invalid port in --bind {bind!r}") from exc + host = host or "127.0.0.1" + if host not in ("127.0.0.1", "::1", "localhost") and not allow_remote: + raise click.ClickException( + f"--bind {bind} is non-loopback; pass --allow-remote to expose the " + "proxy bridge (only behind your own auth)." + ) + + url = f"http://{host}:{port}/" + if open_browser and resolve_console_dir() is not None: + import threading + import webbrowser + + threading.Timer(0.6, lambda: webbrowser.open(url)).start() + click.echo(f"vouch console → {url}") + try: + serve_console(host=host, port=port, allow_remote=allow_remote) + except ConsoleError as exc: + raise click.ClickException(str(exc)) from exc + + @cli.command(name="review-ui") @click.option( "--bind", diff --git a/src/vouch/web/__init__.py b/src/vouch/web/__init__.py index e6c89ab3..ece1cfa5 100644 --- a/src/vouch/web/__init__.py +++ b/src/vouch/web/__init__.py @@ -32,6 +32,26 @@ def _require_web_extra() -> None: ) +def _require_console_deps() -> None: + """Fail with a clean message if the console's serve deps aren't installed. + + The React console needs only starlette (the app) + uvicorn (the server) — + a subset of the [web] extra; jinja2 is not required. + """ + missing: list[str] = [] + for name in ("starlette", "uvicorn"): + try: + __import__(name) + except ImportError: + missing.append(name) + if missing: + raise ImportError( + "vouch console needs the [web] extra. " + "Install with: pip install 'vouch-kb[web]' " + f"(missing: {', '.join(missing)})" + ) + + def create_app( # type: ignore[no-untyped-def] kb_root: str | None = None, *, diff --git a/src/vouch/web/console.py b/src/vouch/web/console.py new file mode 100644 index 00000000..7b5fa8a9 --- /dev/null +++ b/src/vouch/web/console.py @@ -0,0 +1,174 @@ +"""Serve the vendored React review console (the `webapp/` SPA) from vouch. + +The console is a static single-page app. It cannot call vouch cross-origin — +vouch deliberately sends no CORS headers — so it reaches every backend through +a same-origin ``/proxy/*`` bridge, passing the real endpoint in an +``X-Vouch-Target`` header. In dev that bridge is a vite plugin +(`webapp/plugins/vouch-proxy.ts`); this module reimplements it in Python so a +single ``pip install 'vouch-kb[web]'`` can serve the built SPA with no node. + +This is a *viewport*: the bridge only forwards bytes to a `vouch serve +--transport http` backend, which remains the sole path to the review gate. +""" + +from __future__ import annotations + +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +from starlette.applications import Starlette +from starlette.concurrency import run_in_threadpool +from starlette.requests import Request +from starlette.responses import FileResponse, JSONResponse, Response +from starlette.routing import Route + +_MODULE_DIR = Path(__file__).resolve().parent + +# Loopback peers a same-origin browser client can present. The bridge is +# refused to anything else unless the operator explicitly opts in — a +# third-party page must not be able to drive a local reviewer's backends. +_LOOPBACK = frozenset({"127.0.0.1", "::1", "::ffff:127.0.0.1"}) + +# Methods the SPA actually uses are GET (health/capabilities) and POST (rpc); +# accept the common verbs so the bridge stays transparent. +_PROXY_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] + +# A generous ceiling: an rpc that runs the compile/summary LLM is synchronous. +_PROXY_TIMEOUT = 300.0 + + +class ConsoleError(RuntimeError): + """The console cannot be served (no built SPA, or a missing dependency).""" + + +def _default_repo_dist() -> Path: + """`webapp/dist` relative to a source checkout (…/src/vouch/web → repo).""" + return _MODULE_DIR.parents[2] / "webapp" / "dist" + + +def resolve_console_dir( + *, packaged: Path | None = None, repo_dist: Path | None = None +) -> Path | None: + """Locate the built console assets, or ``None`` if none are built. + + Prefers the copy bundled inside the wheel (``vouch/web/console``); falls + back to ``webapp/dist`` in a source checkout. Mirrors how + ``install_adapter`` prefers the repo tree over the packaged copy. + """ + packaged = packaged if packaged is not None else _MODULE_DIR / "console" + if (packaged / "index.html").is_file(): + return packaged + repo_dist = repo_dist if repo_dist is not None else _default_repo_dist() + if (repo_dist / "index.html").is_file(): + return repo_dist + return None + + +def _err(status: int, code: str, message: str) -> JSONResponse: + """The vouch-native error envelope the SPA already understands.""" + return JSONResponse( + {"ok": False, "error": {"code": code, "message": message}}, status_code=status + ) + + +def build_console_app(console_dir: Path, *, allow_remote: bool = False) -> Starlette: + """Build the ASGI app: the ``/proxy/*`` bridge + the static SPA. + + ``allow_remote`` drops the loopback guard on the bridge — only for + deliberately-exposed deployments behind their own auth. + """ + root = console_dir.resolve() + index = root / "index.html" + + async def _proxy(request: Request) -> Response: + client_host = request.client.host if request.client else None + if not allow_remote and client_host not in _LOOPBACK: + return _err(403, "forbidden", "proxy is only available to loopback clients") + + target_raw = request.headers.get("x-vouch-target") + if not target_raw: + return _err(400, "bad_target", "missing X-Vouch-Target header") + parsed = urllib.parse.urlparse(target_raw) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return _err(400, "bad_target", f"not a valid http(s) target: {target_raw}") + + # The path after /proxy is appended to the target's host:port; any path + # on the target itself is dropped, matching vouch-proxy.ts exactly. + sub = request.url.path[len("/proxy") :] or "/" + fwd_url = f"{parsed.scheme}://{parsed.netloc}{sub}" + if request.url.query: + fwd_url += f"?{request.url.query}" + + fwd_headers: dict[str, str] = {} + if request.headers.get("content-type"): + fwd_headers["content-type"] = request.headers["content-type"] + if request.headers.get("authorization"): + fwd_headers["authorization"] = request.headers["authorization"] + body = await request.body() + method = request.method + + def _do() -> tuple[int, str, bytes]: + req = urllib.request.Request( + fwd_url, data=body or None, method=method, headers=fwd_headers + ) + try: + with urllib.request.urlopen(req, timeout=_PROXY_TIMEOUT) as resp: + ctype = resp.headers.get("content-type", "application/json") + return resp.status, ctype, resp.read() + except urllib.error.HTTPError as exc: + # A backend 4xx/5xx is a real answer — pass it through unchanged + # rather than masking it as a proxy 502. + ctype = exc.headers.get("content-type", "application/json") if exc.headers else ( + "application/json" + ) + return exc.code, ctype, exc.read() + + try: + status, ctype, payload = await run_in_threadpool(_do) + except urllib.error.URLError as exc: + return _err(502, "proxy_error", str(exc.reason)) + return Response(content=payload, status_code=status, media_type=ctype) + + async def _spa(request: Request) -> Response: + """Serve a real asset, else index.html so client-side routing works.""" + rel = request.path_params.get("full_path", "") + if rel: + candidate = (root / rel).resolve() + try: + candidate.relative_to(root) # reject path-traversal escapes + except ValueError: + candidate = index + if candidate.is_file(): + return FileResponse(candidate) + return FileResponse(index) + + routes = [ + Route("/proxy", _proxy, methods=_PROXY_METHODS), + Route("/proxy/{path:path}", _proxy, methods=_PROXY_METHODS), + Route("/{full_path:path}", _spa, methods=["GET", "HEAD"]), + ] + return Starlette(routes=routes) + + +def serve_console( + *, + host: str = "127.0.0.1", + port: int = 5173, + allow_remote: bool = False, + console_dir: Path | None = None, +) -> None: + """Serve the console with uvicorn (blocks). Raises ``ConsoleError`` early + if no built SPA can be found, before uvicorn is ever started.""" + resolved = console_dir if console_dir is not None else resolve_console_dir() + if resolved is None: + raise ConsoleError( + "no built vouch console found. from a source checkout run " + "`npm run build` in webapp/; otherwise install a release wheel of " + "vouch-kb[web] (the console ships inside it)." + ) + import uvicorn + + app = build_console_app(resolved, allow_remote=allow_remote) + uvicorn.run(app, host=host, port=port, log_level="info") diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 00000000..c59964d0 --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,238 @@ +"""Tests for `vouch console` — serving the vendored React SPA + /proxy bridge. + +The console is a static single-page app that reaches vouch HTTP backends +through a same-origin ``/proxy/*`` bridge (the browser can't call vouch +cross-origin — vouch deliberately sends no CORS headers). In dev that bridge +is a vite plugin (`webapp/plugins/vouch-proxy.ts`); these cover the Python +reimplementation of it plus the console-directory resolution that lets one +`pip install` serve the built SPA with no node. +""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Any, ClassVar + +import pytest + +pytest.importorskip("starlette", reason="vouch console needs the [web] extra") + +from fastapi.testclient import TestClient + +from vouch.web import console as console_mod +from vouch.web.console import build_console_app, resolve_console_dir + +# --- a stub "vouch serve --transport http" upstream ------------------------- + + +class _StubBackend(BaseHTTPRequestHandler): + """Records what the proxy forwarded; echoes enough to assert on.""" + + seen: ClassVar[list[dict[str, Any]]] = [] + + def _reply(self, status: int, body: dict[str, Any]) -> None: + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def do_GET(self) -> None: + _StubBackend.seen.append( + {"method": "GET", "path": self.path, "auth": self.headers.get("authorization")} + ) + if self.path == "/boom": + self._reply(401, {"ok": False, "error": {"code": "unauthorized"}}) + return + self._reply(200, {"ok": True, "path": self.path}) + + def do_POST(self) -> None: + n = int(self.headers.get("content-length", 0)) + raw = self.rfile.read(n).decode() + _StubBackend.seen.append({"method": "POST", "path": self.path, "body": raw}) + self._reply(200, {"ok": True, "echo": raw}) + + def log_message(self, *_a: Any) -> None: # silence the test log + pass + + +@pytest.fixture +def upstream(): + _StubBackend.seen = [] + srv = HTTPServer(("127.0.0.1", 0), _StubBackend) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + host, port = srv.server_address + try: + yield f"http://{host}:{port}" + finally: + srv.shutdown() + + +@pytest.fixture +def console_dir(tmp_path: Path) -> Path: + d = tmp_path / "console" + (d / "assets").mkdir(parents=True) + (d / "index.html").write_text( + "vouch console", encoding="utf-8" + ) + (d / "assets" / "app.js").write_text("console.log('hi')", encoding="utf-8") + return d + + +def _loopback_client(app) -> TestClient: # type: ignore[no-untyped-def] + """A same-origin loopback browser client.""" + return TestClient(app, client=("127.0.0.1", 54321)) + + +# --- static SPA serving ----------------------------------------------------- + + +def test_serves_the_spa_index_at_root(console_dir: Path) -> None: + res = _loopback_client(build_console_app(console_dir)).get("/") + assert res.status_code == 200 + assert "vouch console" in res.text + + +def test_unknown_route_falls_back_to_index_for_client_routing(console_dir: Path) -> None: + # a SPA deep link the server has no file for must return index.html (200), + # not 404 — client-side routing renders the view. + res = _loopback_client(build_console_app(console_dir)).get("/review") + assert res.status_code == 200 + assert "vouch console" in res.text + + +def test_real_static_asset_is_served(console_dir: Path) -> None: + res = _loopback_client(build_console_app(console_dir)).get("/assets/app.js") + assert res.status_code == 200 + assert "console.log" in res.text + + +# --- the /proxy bridge ------------------------------------------------------ + + +def test_proxy_forwards_get_to_the_target_backend(console_dir: Path, upstream: str) -> None: + client = _loopback_client(build_console_app(console_dir)) + res = client.get("/proxy/health", headers={"X-Vouch-Target": upstream}) + assert res.status_code == 200 + assert res.json()["path"] == "/health" + assert _StubBackend.seen[-1] == {"method": "GET", "path": "/health", "auth": None} + + +def test_proxy_forwards_post_body_and_auth_header(console_dir: Path, upstream: str) -> None: + client = _loopback_client(build_console_app(console_dir)) + res = client.post( + "/proxy/rpc", + headers={ + "X-Vouch-Target": upstream, + "authorization": "Bearer sekret", + "content-type": "application/json", + }, + content=b'{"method":"kb.status"}', + ) + assert res.status_code == 200 + assert _StubBackend.seen[-1]["path"] == "/rpc" + assert _StubBackend.seen[-1]["body"] == '{"method":"kb.status"}' + + +def test_proxy_passes_through_a_backend_error_status(console_dir: Path, upstream: str) -> None: + # a 401 from the backend must reach the browser as 401, not be masked as 502. + client = _loopback_client(build_console_app(console_dir)) + res = client.get("/proxy/boom", headers={"X-Vouch-Target": upstream}) + assert res.status_code == 401 + assert res.json()["error"]["code"] == "unauthorized" + + +def test_proxy_requires_the_target_header(console_dir: Path) -> None: + res = _loopback_client(build_console_app(console_dir)).get("/proxy/health") + assert res.status_code == 400 + assert res.json()["error"]["code"] == "bad_target" + + +def test_proxy_rejects_a_non_http_target(console_dir: Path) -> None: + res = _loopback_client(build_console_app(console_dir)).get( + "/proxy/health", headers={"X-Vouch-Target": "ftp://evil.example/x"} + ) + assert res.status_code == 400 + assert res.json()["error"]["code"] == "bad_target" + + +def test_proxy_rejects_non_loopback_client_by_default(console_dir: Path, upstream: str) -> None: + app = build_console_app(console_dir) # allow_remote defaults False + remote = TestClient(app, client=("10.0.0.9", 1234)) + res = remote.get("/proxy/health", headers={"X-Vouch-Target": upstream}) + assert res.status_code == 403 + assert res.json()["error"]["code"] == "forbidden" + + +def test_proxy_allows_remote_when_opted_in(console_dir: Path, upstream: str) -> None: + app = build_console_app(console_dir, allow_remote=True) + remote = TestClient(app, client=("10.0.0.9", 1234)) + res = remote.get("/proxy/health", headers={"X-Vouch-Target": upstream}) + assert res.status_code == 200 + + +# --- console-directory resolution ------------------------------------------ + + +def test_resolve_prefers_the_packaged_console(tmp_path: Path) -> None: + pkg = tmp_path / "pkg" + pkg.mkdir() + (pkg / "index.html").write_text("packaged", encoding="utf-8") + repo = tmp_path / "repo" + repo.mkdir() + (repo / "index.html").write_text("repo", encoding="utf-8") + assert resolve_console_dir(packaged=pkg, repo_dist=repo) == pkg + + +def test_resolve_falls_back_to_repo_dist(tmp_path: Path) -> None: + pkg = tmp_path / "pkg" # never created + repo = tmp_path / "repo" + repo.mkdir() + (repo / "index.html").write_text("repo", encoding="utf-8") + assert resolve_console_dir(packaged=pkg, repo_dist=repo) == repo + + +def test_resolve_is_none_when_no_console_is_built(tmp_path: Path) -> None: + assert resolve_console_dir(packaged=tmp_path / "a", repo_dist=tmp_path / "b") is None + + +def test_serve_console_errors_clearly_when_no_console_is_built( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # no built SPA anywhere → a clean, actionable error, never a traceback deep + # inside uvicorn (which must not even be reached). + monkeypatch.setattr(console_mod, "resolve_console_dir", lambda **_k: None) + with pytest.raises(console_mod.ConsoleError, match="npm run build"): + console_mod.serve_console() + + +# --- the `vouch console` CLI command --------------------------------------- + + +def test_cli_console_rejects_non_loopback_bind_without_allow_remote() -> None: + from click.testing import CliRunner + + from vouch.cli import cli + + res = CliRunner().invoke(cli, ["console", "--bind", "0.0.0.0:5173", "--no-open-browser"]) + assert res.exit_code != 0 + assert "allow-remote" in res.output.lower() + + +def test_cli_console_errors_cleanly_when_no_console_built( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from click.testing import CliRunner + + from vouch.cli import cli + + # loopback bind, but nothing is built → a clean click error, never a server. + monkeypatch.setattr(console_mod, "resolve_console_dir", lambda **_k: None) + res = CliRunner().invoke(cli, ["console", "--no-open-browser"]) + assert res.exit_code != 0 + assert "npm run build" in res.output diff --git a/webapp/src/views/ReviewView.test.tsx b/webapp/src/views/ReviewView.test.tsx index c72014fc..25784370 100644 --- a/webapp/src/views/ReviewView.test.tsx +++ b/webapp/src/views/ReviewView.test.tsx @@ -68,21 +68,21 @@ beforeEach(() => { test('shows the empty state when there are no sessions', async () => { vi.mocked(rpc).mockResolvedValue({ sessions: [] }) renderWithProviders() - expect(await screen.findByText(/no captured sessions/i)).toBeInTheDocument() + expect(await screen.findByText(/no sessions awaiting a summary/i)).toBeInTheDocument() }) -test('lists all sessions including already-summarized ones', async () => { +test('lists only sessions still awaiting a summary', async () => { vi.mocked(rpc).mockResolvedValue(SESSIONS) renderWithProviders() // title fallback chain: title, then session_id expect(await screen.findByText('session: fix the parser')).toBeInTheDocument() expect(screen.getAllByText('sess-open').length).toBeGreaterThan(0) - // summarized sessions are now shown too (viewable, read-only) - expect(screen.getByText('session: already summarized')).toBeInTheDocument() - // stage / summarized badges + // summarized sessions drop off the queue — their proposal lives in Pending + expect(screen.queryByText('session: already summarized')).not.toBeInTheDocument() + expect(screen.queryByText('summarized')).not.toBeInTheDocument() + // stage badges for the two remaining rows expect(screen.getByText('open buffer')).toBeInTheDocument() expect(screen.getByText('needs summary')).toBeInTheDocument() - expect(screen.getByText('summarized')).toBeInTheDocument() // observation counts and sliced timestamps expect(screen.getByText(/12 observations/)).toBeInTheDocument() expect(screen.getByText(/2026-07-04 11:30:00/)).toBeInTheDocument() diff --git a/webapp/src/views/ReviewView.tsx b/webapp/src/views/ReviewView.tsx index 378f812b..d5d44804 100644 --- a/webapp/src/views/ReviewView.tsx +++ b/webapp/src/views/ReviewView.tsx @@ -16,9 +16,10 @@ const STAGE_LABEL: Record = { pending: 'needs summary', } -/** Badge text for a row: summarized wins over the raw capture stage. */ +/** Badge text for a row. Summarized sessions are filtered out of the queue, + * so only the two live stages reach here. */ function stageLabel(s: SessionEntry): string { - return s.summarized ? 'summarized' : STAGE_LABEL[s.stage] + return STAGE_LABEL[s.stage] } /** Hints for the skip reasons kb.summarize_session can return instead of a summary. */ @@ -53,14 +54,19 @@ export function ReviewView() { }) useErrorToast(sessions.errors.length > 0, sessions.errors[0]?.error) + // Review is the "needs a summary" queue: a session drops off once it has been + // summarized — its summary proposal then lives in Pending. Filtering here also + // collapses the split fan-out: a split session files N summary proposals, each + // surfacing as a summarized row pointing at the same transcript; none of them + // belong in the queue. const rows: Row[] = sessions.rows.flatMap((r) => - (r.data?.sessions ?? []).map((s) => ({ project: r.project, s })), + (r.data?.sessions ?? []) + .filter((s) => !s.summarized) + .map((s) => ({ project: r.project, s })), ) const selected = rows.find((r) => rowKey(r) === selectedKey) ?? null const canSummarize = - !!selected && - !selected.s.summarized && - hasMethod('kb.summarize_session', selected.project.conn.endpoint) + !!selected && hasMethod('kb.summarize_session', selected.project.conn.endpoint) const canTranscript = !!selected && !!selected.s.session_id && @@ -116,8 +122,8 @@ export function ReviewView() { if (rows.length === 0) { return ( ) } @@ -193,11 +199,7 @@ export function ReviewView() {

{rowTitle(selected.s)}

- {selected.s.summarized ? ( -

- Already summarized — its proposal is in Pending for review. -

- ) : canSummarize ? ( + {canSummarize ? (