diff --git a/api/models/jsonl_utils.py b/api/models/jsonl_utils.py index 8b6fe1ee..b426904a 100644 --- a/api/models/jsonl_utils.py +++ b/api/models/jsonl_utils.py @@ -15,6 +15,70 @@ from .message import Message, parse_message +def _is_image_marker_text(text: str) -> bool: + """ + Detect a text block that is an image-attachment marker Claude Code emits + alongside the real image content block. + + Two formats are observed across Claude Code versions: + + - Pre-v2.1.83: ``[Image: source: /var/folders/...]`` + - v2.1.83+: ``[Image #N]`` (may have a trailing space in v2.1.85+) + + Both are redundant because the actual image data is already present in a + sibling ``image`` content block and should be dropped during merge. + """ + if not isinstance(text, str): + return False + return text.startswith("[Image: source:") or text.startswith("[Image #") + + +def _merge_user_message_dicts(base: dict, extra: dict) -> dict: + """ + Merge two raw user message dicts that share the same timestamp. + + Claude Code emits a pair of user messages at the same timestamp when + an image is attached: the first contains the real text + base64 image + block, and the second is a text-only fallback with a marker reference + like ``[Image: source: /var/folders/...]`` (pre-v2.1.83) or + ``[Image #N]`` (v2.1.83+). We merge both into one dict so the + downstream parser sees a single message with the correct content + and image attachment. + + The marker reference parts are dropped because the image data is + already present in the base message's image content block. Any other + real text in the extra message is preserved. + """ + merged = {**base} + + def _get_content(d: dict) -> list: + c = d.get("message", {}).get("content") or d.get("content", []) + return c if isinstance(c, list) else [] + + base_content = _get_content(merged) + extra_content = _get_content(extra) + + # Keep extra parts that are not redundant image-marker text references + real_extra = [ + part + for part in extra_content + if not ( + isinstance(part, dict) + and part.get("type") == "text" + and _is_image_marker_text(part.get("text", "")) + ) + ] + + if real_extra: + combined = base_content + real_extra + if "message" in merged: + merged["message"] = {**merged["message"], "content": combined} + else: + merged["content"] = combined + + return merged + + def iter_messages_from_jsonl(jsonl_path: Path) -> Iterator[Message]: """ Iterate over messages in a JSONL file. @@ -23,6 +87,11 @@ def iter_messages_from_jsonl(jsonl_path: Path) -> Iterator[Message]: parsed Message instances. Handles missing files, empty lines, and malformed JSON gracefully. + Consecutive user messages that share an identical timestamp are merged + into a single message before parsing. Claude Code writes such pairs + when the user attaches an image: one entry with the real text + base64 + image block and a second text-only entry with a file-path reference. + Args: jsonl_path: Path to the JSONL file containing messages. @@ -38,6 +107,8 @@ def iter_messages_from_jsonl(jsonl_path: Path) -> Iterator[Message]: if not jsonl_path.exists(): return + pending: dict | None = None + with open(jsonl_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() @@ -45,7 +116,31 @@ def iter_messages_from_jsonl(jsonl_path: Path) -> Iterator[Message]: continue try: data = json.loads(line) - yield parse_message(data) - except (json.JSONDecodeError, ValueError, KeyError): - # Skip malformed lines + except json.JSONDecodeError: + continue + + # Merge consecutive user messages with the same timestamp into one + if ( + pending is not None + and pending.get("type") == "user" + and data.get("type") == "user" + and pending.get("timestamp") == data.get("timestamp") + ): + pending = _merge_user_message_dicts(pending, data) continue + + # Yield the previously buffered message + if pending is not None: + try: + yield parse_message(pending) + except (ValueError, KeyError): + pass + + pending = data + + # Yield the final buffered message + if pending is not None: + try: + yield parse_message(pending) + except (ValueError, KeyError): + pass diff --git a/api/routers/projects.py b/api/routers/projects.py index c2784c54..b8a4ce0a 100644 --- a/api/routers/projects.py +++ b/api/routers/projects.py @@ -40,10 +40,13 @@ class _FallbackToFilesystem(Exception): from schemas import ( AgentSummary, BranchSummary, + MemoryFileMeta, + MemoryIndexEntry, ProjectAnalytics, ProjectBranchesResponse, ProjectChainsResponse, ProjectDetail, + ProjectMemoryFileResponse, ProjectMemoryResponse, ProjectSummary, SessionChainInfo, @@ -1371,15 +1374,167 @@ def get_project_skills( # Project Memory Endpoint # ============================================================================ +import re as _re + +# Strict basename validator: alphanumerics, dots, underscores, dashes, must end in .md. +_MEMORY_FILENAME_RE = _re.compile(r"^[a-zA-Z0-9._-]+\.md$") + +# Match markdown link targets ending in .md (group 1 = target, with optional #frag or ?query stripped). +_MEMORY_LINK_RE = _re.compile(r"\[[^\]]*\]\(([^)\s]+?\.md)(?:[#?][^)]*)?\)") + + +def _parse_memory_frontmatter(text: str) -> tuple[dict, str]: + """Parse YAML frontmatter from a memory file. + + Accepts a leading ``---\\n...\\n---\\n`` block. On any parse error, returns + ``({}, text)`` and logs a warning. Only ``name``, ``description``, and ``type`` + keys are honored; other keys are ignored. + + Returns ``(frontmatter_dict, body_without_frontmatter)``. + """ + if not text.startswith("---"): + return {}, text + + # Find the closing --- on its own line after the opening one. + # Split off the first line (the opening ---). + lines = text.split("\n") + if not lines or lines[0].strip() != "---": + return {}, text + + closing_idx = None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + closing_idx = i + break + + if closing_idx is None: + # No closing fence — not a valid frontmatter block. + return {}, text + + fm_lines = lines[1:closing_idx] + body_lines = lines[closing_idx + 1 :] + body = "\n".join(body_lines) + # Strip a single leading newline so body doesn't start with extra blank line. + if body.startswith("\n"): + body = body[1:] + + fm: dict = {} + try: + for raw_line in fm_lines: + # Allow blank lines and comments. + line = raw_line.rstrip() + if not line.strip() or line.lstrip().startswith("#"): + continue + if ":" not in line: + # Malformed entry — skip it but don't fail the whole block. + continue + key, _, value = line.partition(":") + key = key.strip() + value = value.strip() + # Strip surrounding quotes if any. + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + value = value[1:-1] + if key in ("name", "description", "type"): + fm[key] = value + except Exception as e: # pragma: no cover - defensive + logger.warning("Failed to parse memory frontmatter: %s", e) + return {}, text + + return fm, body + + +def _filename_to_name(filename: str) -> str: + """Derive a fallback display name from a filename: strip .md, replace _ with space.""" + stem = filename[:-3] if filename.endswith(".md") else filename + return stem.replace("_", " ") + + +def _extract_index_link_targets(content: str) -> set[str]: + """Extract the basenames of all markdown links pointing to *.md files.""" + targets: set[str] = set() + for match in _MEMORY_LINK_RE.finditer(content): + raw = match.group(1) + # Strip fragment and query (defensive — regex already excludes them but be safe). + for sep in ("#", "?"): + if sep in raw: + raw = raw.split(sep, 1)[0] + if not raw: + continue + # Take the basename (handle relative-style paths like "./file.md" or "subdir/file.md"). + basename = raw.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] + if basename.endswith(".md"): + targets.add(basename) + return targets + + +def _read_child_meta( + file_path: Path, linked: bool, *, encoded_name: str +) -> Optional[MemoryFileMeta]: + """Build a MemoryFileMeta for a single child file. Returns None on unreadable file.""" + try: + stat = file_path.stat() + raw = file_path.read_text(encoding="utf-8", errors="replace") + except OSError as e: + logger.warning( + "Skipping unreadable memory file %s in %s: %s", file_path.name, encoded_name, e + ) + return None + + fm, body = _parse_memory_frontmatter(raw) + name = fm.get("name") or _filename_to_name(file_path.name) + description = fm.get("description") or "" + type_ = fm.get("type") or None + + return MemoryFileMeta( + filename=file_path.name, + name=name, + description=description, + type=type_, + word_count=len(body.split()), + size_bytes=stat.st_size, + modified=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), + linked_from_index=linked, + ) + + +def _build_files_list(memory_dir: Path, encoded_name: str, index_content: str) -> list: + """Enumerate all non-index *.md files in memory_dir and build their metadata.""" + files: list = [] + if not memory_dir.is_dir(): + return files + + link_targets = _extract_index_link_targets(index_content) if index_content else set() + + try: + entries = sorted(memory_dir.iterdir(), key=lambda p: p.name.lower()) + except OSError as e: + logger.warning("Failed to list memory directory for %s: %s", encoded_name, e) + return files + + for entry in entries: + # Skip non-files, MEMORY.md (the index), non-.md files. + if not entry.is_file(): + continue + if entry.name == "MEMORY.md": + continue + if not entry.name.endswith(".md"): + continue + + meta = _read_child_meta(entry, linked=entry.name in link_targets, encoded_name=encoded_name) + if meta is not None: + files.append(meta) + + return files + @router.get("/{encoded_name}/memory", response_model=ProjectMemoryResponse) @cacheable(max_age=30, stale_while_revalidate=60) async def get_project_memory(encoded_name: str, request: Request): """ - Get the MEMORY.md file for a project. + Get the project's memory directory contents. - Returns the markdown content of the project's memory file stored at - ~/.claude/projects/{encoded_name}/memory/MEMORY.md + Returns the MEMORY.md index plus metadata (no content) for every other ``*.md`` + file in ``~/.claude/projects/{encoded_name}/memory/``. """ encoded_name = resolve_project_identifier(encoded_name) from config import settings @@ -1387,8 +1542,24 @@ async def get_project_memory(encoded_name: str, request: Request): memory_dir = settings.projects_dir / encoded_name / "memory" memory_file = memory_dir / "MEMORY.md" - if not memory_file.exists(): - return ProjectMemoryResponse( + # Compute index entry. + if memory_file.exists(): + try: + stat = memory_file.stat() + index_content = memory_file.read_text(encoding="utf-8") + index_entry = MemoryIndexEntry( + content=index_content, + word_count=len(index_content.split()), + size_bytes=stat.st_size, + modified=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), + exists=True, + ) + except OSError as e: + logger.error("Error reading MEMORY.md for %s: %s", encoded_name, e) + raise HTTPException(status_code=500, detail="Failed to read memory file") from e + else: + index_content = "" + index_entry = MemoryIndexEntry( content="", word_count=0, size_bytes=0, @@ -1396,18 +1567,97 @@ async def get_project_memory(encoded_name: str, request: Request): exists=False, ) + files = _build_files_list(memory_dir, encoded_name, index_content) + + return ProjectMemoryResponse(index=index_entry, files=files) + + +@router.get( + "/{encoded_name}/memory/files/{filename}", + response_model=ProjectMemoryFileResponse, +) +@cacheable(max_age=30, stale_while_revalidate=60) +async def get_project_memory_file(encoded_name: str, filename: str, request: Request): + """ + Get the full content of a single child memory file. + + Path component ``files/`` disambiguates this from the index endpoint and + avoids collisions with other route segments. + """ + encoded_name = resolve_project_identifier(encoded_name) + from config import settings + + # ----- Path validation (security-critical) ----- + if not filename: + raise HTTPException(status_code=400, detail="Memory filename is required") + + # Reject path-traversal markers and separators outright before regex match. + if ( + "/" in filename + or "\\" in filename + or ".." in filename + or "\x00" in filename + or filename.startswith(".") + ): + raise HTTPException(status_code=400, detail="Invalid memory filename") + + if not filename.endswith(".md"): + raise HTTPException(status_code=400, detail="Memory filename must end in .md") + + if not _MEMORY_FILENAME_RE.match(filename): + raise HTTPException(status_code=400, detail="Invalid memory filename format") + + memory_dir = settings.projects_dir / encoded_name / "memory" + candidate = memory_dir / filename + + # Defense in depth: resolve and assert containment within memory_dir. try: - stat = memory_file.stat() - content = memory_file.read_text(encoding="utf-8") - word_count = len(content.split()) - - return ProjectMemoryResponse( - content=content, - word_count=word_count, - size_bytes=stat.st_size, - modified=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), - exists=True, + resolved_candidate = candidate.resolve() + resolved_memory_dir = memory_dir.resolve() + except OSError as e: + logger.error("Path resolution failed for %s/%s: %s", encoded_name, filename, e) + raise HTTPException(status_code=400, detail="Invalid memory file path") from e + + try: + is_inside = resolved_candidate.is_relative_to(resolved_memory_dir) + except AttributeError: # pragma: no cover - Python <3.9 compat + try: + resolved_candidate.relative_to(resolved_memory_dir) + is_inside = True + except ValueError: + is_inside = False + + if not is_inside: + logger.warning( + "Memory file path escape attempt: %s -> %s (memory_dir=%s)", + filename, + resolved_candidate, + resolved_memory_dir, ) + raise HTTPException(status_code=403, detail="Path escape detected") + + if not candidate.is_file(): + raise HTTPException(status_code=404, detail=f"Memory file not found: {filename}") + + try: + stat = candidate.stat() + raw = candidate.read_text(encoding="utf-8") except OSError as e: - logger.error(f"Error reading memory file for {encoded_name}: {e}") + logger.error("Failed to read memory file %s/%s: %s", encoded_name, filename, e) raise HTTPException(status_code=500, detail="Failed to read memory file") from e + + fm, body = _parse_memory_frontmatter(raw) + name = fm.get("name") or _filename_to_name(filename) + description = fm.get("description") or "" + type_ = fm.get("type") or None + + return ProjectMemoryFileResponse( + filename=filename, + name=name, + description=description, + type=type_, + content=body, + word_count=len(body.split()), + size_bytes=stat.st_size, + modified=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), + ) diff --git a/api/schemas.py b/api/schemas.py index e4917efd..d202f7c3 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -712,7 +712,10 @@ class SkillDetailResponse(BaseModel): is_plugin: bool = Field(False, description="True if this is a plugin skill") plugin: Optional[str] = Field(None, description="Plugin name if is_plugin") file_path: Optional[str] = Field(None, description="Path to the skill file") - category: Optional[str] = Field(None, description="Invocation category (builtin_command, bundled_skill, plugin_skill, user_skill, unknown)") + category: Optional[str] = Field( + None, + description="Invocation category (builtin_command, bundled_skill, plugin_skill, user_skill, unknown)", + ) calls: int = Field(0, description="Total invocations") main_calls: int = Field(0, description="Calls from main sessions") subagent_calls: int = Field(0, description="Calls from subagents") @@ -893,8 +896,8 @@ class PlanRelatedSession(BaseModel): # ============================================================================= -class ProjectMemoryResponse(BaseModel): - """Response for a project's MEMORY.md file.""" +class MemoryIndexEntry(BaseModel): + """The index (MEMORY.md) entry in a project's memory response.""" content: str = Field(..., description="Full markdown content of MEMORY.md") word_count: int = Field(0, description="Total word count") @@ -903,6 +906,58 @@ class ProjectMemoryResponse(BaseModel): exists: bool = Field(True, description="Whether the memory file exists") +class MemoryFileMeta(BaseModel): + """Metadata for a single child memory file (no content).""" + + filename: str = Field(..., description="Basename of the memory file (e.g. foo.md)") + name: str = Field( + ..., + description="Human-readable title from frontmatter, or filename-derived fallback", + ) + description: str = Field( + "", description="One-line description from frontmatter (empty string fallback)" + ) + type: Optional[str] = Field( + None, + description="Memory type from frontmatter: user | feedback | project | reference", + ) + word_count: int = Field(0, description="Word count of the body (frontmatter excluded)") + size_bytes: int = Field(0, description="File size in bytes") + modified: datetime = Field(..., description="Last modification time") + linked_from_index: bool = Field( + False, description="Whether MEMORY.md references this file via a markdown link" + ) + + +class ProjectMemoryResponse(BaseModel): + """Response for a project's memory directory. + + Contains the MEMORY.md index and metadata for all other *.md child files. + """ + + index: MemoryIndexEntry = Field(..., description="The MEMORY.md index entry") + files: List[MemoryFileMeta] = Field( + default_factory=list, + description="Metadata for each non-index *.md child file in memory/", + ) + + +class ProjectMemoryFileResponse(BaseModel): + """Full content of one child memory file.""" + + filename: str = Field(..., description="Basename of the memory file") + name: str = Field( + ..., + description="Human-readable title from frontmatter, or filename-derived fallback", + ) + description: str = Field("", description="One-line description from frontmatter") + type: Optional[str] = Field(None, description="Memory type from frontmatter") + content: str = Field(..., description="Body content with YAML frontmatter stripped") + word_count: int = Field(0, description="Word count of the stripped body") + size_bytes: int = Field(0, description="File size in bytes (raw, includes frontmatter)") + modified: datetime = Field(..., description="Last modification time") + + # ============================================================================= # Live Session Schemas # ============================================================================= diff --git a/api/services/conversation_endpoints.py b/api/services/conversation_endpoints.py index f926e9c0..11681ba9 100644 --- a/api/services/conversation_endpoints.py +++ b/api/services/conversation_endpoints.py @@ -9,6 +9,7 @@ the protocol in models/conversation.py. """ +import re from collections import Counter from typing import Optional @@ -64,6 +65,26 @@ def build_conversation_timeline( # Pass 1: Collect all tool results for later merging tool_results = collect_tool_results(conversation, extract_spawned_agent=True, parse_xml=True) + # Pass 1b: Collect taskId → subject from TaskCreate calls so TaskUpdate + # events can display the task description even though updates only send taskId + status. + # + # TaskCreate input has 'subject' but NO 'taskId' — the ID is assigned by the + # task runtime and returned in the result as "Task #N created successfully: ...". + # We parse it from the result content and map it to the input subject. + task_subjects: dict[str, str] = {} + for msg in conversation.iter_messages(): + if isinstance(msg, AssistantMessage): + for block in msg.content_blocks: + if isinstance(block, ToolUseBlock) and block.name == "TaskCreate": + subject = str(block.input.get("subject", "")) + if not subject: + continue + result = tool_results.get(block.id) + if result: + m = re.search(r"Task #(\d+)", result.content) + if m: + task_subjects[m.group(1)] = subject + # Pass 2: Build events with merged results events: list[TimelineEvent] = [] event_counter = 0 @@ -150,7 +171,7 @@ def build_conversation_timeline( # Build complete metadata with merged result metadata = _build_tool_call_metadata( - block, base_metadata, result_data, subagent_info + block, base_metadata, result_data, subagent_info, task_subjects ) # Add agent context for subagent messages @@ -308,6 +329,7 @@ def _build_tool_call_metadata( base_metadata: dict, result_data: Optional[ToolResultData], subagent_info: dict[str, Optional[str]], + task_subjects: Optional[dict[str, str]] = None, ) -> dict: """Build complete metadata for a tool call, merging in result if available.""" metadata = {"tool_name": block.name, "tool_id": block.id, **base_metadata} @@ -316,6 +338,12 @@ def _build_tool_call_metadata( if block.name in ("Task", "Agent"): metadata["is_spawn_task"] = True + # Annotate TaskUpdate with the subject from the matching TaskCreate + if block.name == "TaskUpdate" and task_subjects: + task_id = str(block.input.get("taskId", "")) + if task_id and task_id in task_subjects: + metadata["task_subject"] = task_subjects[task_id] + if result_data is None: return metadata diff --git a/api/tests/api/test_memory.py b/api/tests/api/test_memory.py new file mode 100644 index 00000000..2c4db574 --- /dev/null +++ b/api/tests/api/test_memory.py @@ -0,0 +1,448 @@ +""" +API tests for the project memory endpoints. + +Covers: +- GET /projects/{encoded_name}/memory (index + file list) +- GET /projects/{encoded_name}/memory/files/{filename} (single file) + +Fixtures create temp ~/.claude/projects/{encoded_name}/memory/ directories +with various shapes (no dir, only index, index+children, orphan children, +malformed YAML frontmatter, non-.md siblings). Security tests assert that +path traversal attempts return 400/403. +""" + +import sys +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +api_path = Path(__file__).parent.parent.parent +sys.path.insert(0, str(api_path)) + +from main import app + + +@pytest.fixture +def client(): + """Create a test client for the FastAPI app.""" + return TestClient(app) + + +ENCODED_NAME = "-Users-test-memproj" + + +@pytest.fixture +def memory_dir(tmp_path, monkeypatch): + """ + Create a temp ~/.claude/projects/{encoded_name}/memory/ directory and + redirect settings.claude_base to the tmp root. + + Returns the Path to the memory directory (not yet populated). + """ + claude_dir = tmp_path / ".claude" + projects_dir = claude_dir / "projects" + project_dir = projects_dir / ENCODED_NAME + mem_dir = project_dir / "memory" + mem_dir.mkdir(parents=True) + + from config import settings + + monkeypatch.setattr(settings, "claude_base", claude_dir) + + return mem_dir + + +@pytest.fixture +def no_memory_dir(tmp_path, monkeypatch): + """Create a project dir with NO memory subdirectory.""" + claude_dir = tmp_path / ".claude" + projects_dir = claude_dir / "projects" + project_dir = projects_dir / ENCODED_NAME + project_dir.mkdir(parents=True) + + from config import settings + + monkeypatch.setattr(settings, "claude_base", claude_dir) + + return project_dir + + +# ============================================================================= +# GET /memory — shape-variation tests +# ============================================================================= + + +class TestMemoryEndpointShape: + def test_no_memory_dir_returns_empty_index_and_files(self, client, no_memory_dir): + """When memory/ does not exist, index.exists=False and files=[].""" + response = client.get(f"/projects/{ENCODED_NAME}/memory") + assert response.status_code == 200 + data = response.json() + assert "index" in data + assert "files" in data + assert data["index"]["exists"] is False + assert data["index"]["content"] == "" + assert data["index"]["word_count"] == 0 + assert data["files"] == [] + + def test_only_index_no_children(self, client, memory_dir): + """MEMORY.md alone, no children → files=[].""" + (memory_dir / "MEMORY.md").write_text( + "# My memory\n\nSome content here with seven words total.\n", + encoding="utf-8", + ) + + response = client.get(f"/projects/{ENCODED_NAME}/memory") + assert response.status_code == 200 + data = response.json() + assert data["index"]["exists"] is True + assert "My memory" in data["index"]["content"] + assert data["index"]["word_count"] > 0 + assert data["files"] == [] + + def test_index_with_children(self, client, memory_dir): + """Index plus two children, both have valid frontmatter.""" + (memory_dir / "MEMORY.md").write_text( + "# Index\n\nSee [Arch](arch.md) and [Radio](project_git_radio.md).\n", + encoding="utf-8", + ) + (memory_dir / "arch.md").write_text( + "---\n" + "name: Architecture notes\n" + "description: Folder IDs and reconciliation\n" + "type: project\n" + "---\n\n" + "# Arch body\n\nBody content here.\n", + encoding="utf-8", + ) + (memory_dir / "project_git_radio.md").write_text( + "---\n" + "name: Git radio\n" + "description: submodule setup\n" + "type: reference\n" + "---\n\n" + "Details about git radio.\n", + encoding="utf-8", + ) + + response = client.get(f"/projects/{ENCODED_NAME}/memory") + assert response.status_code == 200 + data = response.json() + assert data["index"]["exists"] is True + assert len(data["files"]) == 2 + by_name = {f["filename"]: f for f in data["files"]} + assert "arch.md" in by_name + assert "project_git_radio.md" in by_name + assert by_name["arch.md"]["name"] == "Architecture notes" + assert by_name["arch.md"]["type"] == "project" + assert by_name["arch.md"]["linked_from_index"] is True + assert by_name["project_git_radio.md"]["type"] == "reference" + assert by_name["project_git_radio.md"]["linked_from_index"] is True + + def test_children_only_no_index(self, client, memory_dir): + """Children present but no MEMORY.md — index.exists=False but files populated.""" + (memory_dir / "orphan1.md").write_text( + "---\nname: Orphan one\ntype: user\n---\n\nBody.\n", encoding="utf-8" + ) + (memory_dir / "orphan2.md").write_text("Plain body, no frontmatter.\n", encoding="utf-8") + + response = client.get(f"/projects/{ENCODED_NAME}/memory") + assert response.status_code == 200 + data = response.json() + assert data["index"]["exists"] is False + assert len(data["files"]) == 2 + filenames = {f["filename"] for f in data["files"]} + assert filenames == {"orphan1.md", "orphan2.md"} + for f in data["files"]: + assert f["linked_from_index"] is False + + def test_malformed_yaml_frontmatter_does_not_fail_response(self, client, memory_dir): + """A broken frontmatter block falls back; response still succeeds.""" + (memory_dir / "MEMORY.md").write_text("# Index\n", encoding="utf-8") + (memory_dir / "good.md").write_text( + "---\nname: Good file\ntype: project\n---\n\nbody\n", encoding="utf-8" + ) + # Missing closing fence — our parser treats this as "no frontmatter", + # so the whole file body (including the opening ---) becomes content. + (memory_dir / "broken_no_close.md").write_text( + "---\nname: never closed\ntype: project\n\nbody without closing fence\n", + encoding="utf-8", + ) + # Opening fence, totally garbage line inside, closing fence — individual + # bad lines must be skipped, not crash the parser. + (memory_dir / "garbage.md").write_text( + "---\nname: partial\nnot_a_key_value_pair_line\n---\n\nbody\n", + encoding="utf-8", + ) + + response = client.get(f"/projects/{ENCODED_NAME}/memory") + assert response.status_code == 200 + data = response.json() + assert len(data["files"]) == 3 + by_name = {f["filename"]: f for f in data["files"]} + + # Good file: valid frontmatter honored. + assert by_name["good.md"]["name"] == "Good file" + assert by_name["good.md"]["type"] == "project" + + # Broken (no close): should fall back to filename-derived name. + assert by_name["broken_no_close.md"]["name"] == "broken no close" + assert by_name["broken_no_close.md"]["type"] is None + assert by_name["broken_no_close.md"]["description"] == "" + + # Garbage with partial valid lines: still succeeds, name extracted. + assert by_name["garbage.md"]["name"] == "partial" + + def test_non_md_files_are_ignored(self, client, memory_dir): + """Only *.md files should appear in files[].""" + (memory_dir / "MEMORY.md").write_text("# Index\n", encoding="utf-8") + (memory_dir / "a.md").write_text("body", encoding="utf-8") + (memory_dir / "b.txt").write_text("ignored", encoding="utf-8") + (memory_dir / "c.json").write_text("{}", encoding="utf-8") + (memory_dir / "sub").mkdir() + (memory_dir / "sub" / "nested.md").write_text("ignored", encoding="utf-8") + + response = client.get(f"/projects/{ENCODED_NAME}/memory") + assert response.status_code == 200 + data = response.json() + assert len(data["files"]) == 1 + assert data["files"][0]["filename"] == "a.md" + + def test_filename_fallback_for_missing_frontmatter(self, client, memory_dir): + """File with no frontmatter: name=underscore-to-space, description='', type=None.""" + (memory_dir / "MEMORY.md").write_text("# Index\n", encoding="utf-8") + (memory_dir / "project_git_radio.md").write_text("Just body text.\n", encoding="utf-8") + + response = client.get(f"/projects/{ENCODED_NAME}/memory") + assert response.status_code == 200 + data = response.json() + assert len(data["files"]) == 1 + meta = data["files"][0] + assert meta["name"] == "project git radio" + assert meta["description"] == "" + assert meta["type"] is None + + +# ============================================================================= +# linked_from_index computation tests +# ============================================================================= + + +class TestLinkedFromIndex: + def test_exact_match_links(self, client, memory_dir): + (memory_dir / "MEMORY.md").write_text( + "- [Foo](foo.md)\n- [Bar](bar.md)\n", encoding="utf-8" + ) + (memory_dir / "foo.md").write_text("body", encoding="utf-8") + (memory_dir / "bar.md").write_text("body", encoding="utf-8") + (memory_dir / "baz.md").write_text("body", encoding="utf-8") + + response = client.get(f"/projects/{ENCODED_NAME}/memory") + data = response.json() + by_name = {f["filename"]: f for f in data["files"]} + assert by_name["foo.md"]["linked_from_index"] is True + assert by_name["bar.md"]["linked_from_index"] is True + assert by_name["baz.md"]["linked_from_index"] is False + + def test_links_with_fragments_and_queries(self, client, memory_dir): + """Link targets with #fragment or ?query must still match base filename.""" + (memory_dir / "MEMORY.md").write_text( + "See [A](arch.md#section1) and [B](other.md?x=1).\n", encoding="utf-8" + ) + (memory_dir / "arch.md").write_text("body", encoding="utf-8") + (memory_dir / "other.md").write_text("body", encoding="utf-8") + + response = client.get(f"/projects/{ENCODED_NAME}/memory") + data = response.json() + by_name = {f["filename"]: f for f in data["files"]} + assert by_name["arch.md"]["linked_from_index"] is True + assert by_name["other.md"]["linked_from_index"] is True + + def test_relative_path_style_links(self, client, memory_dir): + """Links with ./ prefix or subdir/ prefix use basename for match.""" + (memory_dir / "MEMORY.md").write_text( + "[A](./arch.md) and [B](subdir/nested.md)\n", encoding="utf-8" + ) + (memory_dir / "arch.md").write_text("body", encoding="utf-8") + (memory_dir / "nested.md").write_text("body", encoding="utf-8") + + response = client.get(f"/projects/{ENCODED_NAME}/memory") + data = response.json() + by_name = {f["filename"]: f for f in data["files"]} + assert by_name["arch.md"]["linked_from_index"] is True + assert by_name["nested.md"]["linked_from_index"] is True + + def test_no_false_positive_on_substring(self, client, memory_dir): + """`foo.md` in index should not match a file named `extrafoo.md`.""" + (memory_dir / "MEMORY.md").write_text("[Foo](foo.md)\n", encoding="utf-8") + (memory_dir / "foo.md").write_text("body", encoding="utf-8") + (memory_dir / "extrafoo.md").write_text("body", encoding="utf-8") + + response = client.get(f"/projects/{ENCODED_NAME}/memory") + data = response.json() + by_name = {f["filename"]: f for f in data["files"]} + assert by_name["foo.md"]["linked_from_index"] is True + assert by_name["extrafoo.md"]["linked_from_index"] is False + + def test_non_link_md_mentions_do_not_match(self, client, memory_dir): + """A plain mention of foo.md in text (not a markdown link) must not count.""" + (memory_dir / "MEMORY.md").write_text( + "Plain text mentioning foo.md but no link syntax.\n", encoding="utf-8" + ) + (memory_dir / "foo.md").write_text("body", encoding="utf-8") + + response = client.get(f"/projects/{ENCODED_NAME}/memory") + data = response.json() + by_name = {f["filename"]: f for f in data["files"]} + assert by_name["foo.md"]["linked_from_index"] is False + + +# ============================================================================= +# GET /memory/files/{filename} — happy path +# ============================================================================= + + +class TestMemoryFileEndpoint: + def test_fetch_file_with_frontmatter(self, client, memory_dir): + (memory_dir / "MEMORY.md").write_text("# Index\n", encoding="utf-8") + (memory_dir / "arch.md").write_text( + "---\n" + "name: Architecture\n" + "description: Folder IDs\n" + "type: project\n" + "---\n\n" + "# Body\n\nThis body has exactly seven words here.\n", + encoding="utf-8", + ) + + response = client.get(f"/projects/{ENCODED_NAME}/memory/files/arch.md") + assert response.status_code == 200 + data = response.json() + assert data["filename"] == "arch.md" + assert data["name"] == "Architecture" + assert data["description"] == "Folder IDs" + assert data["type"] == "project" + # Frontmatter should be stripped from the returned content. + assert "---" not in data["content"].split("\n", 1)[0] + assert "Body" in data["content"] + # word_count computed on stripped body only. + assert data["word_count"] > 0 + assert data["word_count"] < 20 # sanity — body is short + + def test_fetch_file_without_frontmatter(self, client, memory_dir): + (memory_dir / "plain.md").write_text("Hello world\n", encoding="utf-8") + + response = client.get(f"/projects/{ENCODED_NAME}/memory/files/plain.md") + assert response.status_code == 200 + data = response.json() + assert data["filename"] == "plain.md" + assert data["name"] == "plain" + assert data["type"] is None + assert data["content"] == "Hello world\n" + + def test_fetch_not_found_returns_404(self, client, memory_dir): + (memory_dir / "MEMORY.md").write_text("# Index\n", encoding="utf-8") + + response = client.get(f"/projects/{ENCODED_NAME}/memory/files/missing.md") + assert response.status_code == 404 + + +# ============================================================================= +# GET /memory/files/{filename} — path traversal and input validation +# ============================================================================= + + +class TestMemoryFilePathValidation: + @pytest.mark.parametrize( + "filename", + [ + "..%2Fetc%2Fpasswd", # URL-encoded path traversal + "%2E%2E%2Fetc%2Fpasswd", # URL-encoded dots + "foo%2Fbar.md", # slash inside filename + ".hidden.md", # leading dot + "..md", # dot-dot + "file.txt", # wrong extension + "file", # no extension + "file.MD.bak", # .md not at end + "file.md.txt", # .md not at end + "file with space.md", # space not allowed by regex + "file$.md", # $ not allowed by regex + ], + ) + def test_invalid_filename_returns_400(self, client, memory_dir, filename): + response = client.get(f"/projects/{ENCODED_NAME}/memory/files/{filename}") + # 400 for format violations; 404 for routing mismatch on extreme cases is + # also acceptable since FastAPI may not match the route at all. + assert response.status_code in (400, 404), f"{filename!r} returned {response.status_code}" + + def test_empty_filename_returns_404_or_400(self, client, memory_dir): + """Empty filename doesn't match the route at all → 404, or 400.""" + response = client.get(f"/projects/{ENCODED_NAME}/memory/files/") + assert response.status_code in (400, 404) + + def test_null_byte_filename_rejected(self, client, memory_dir): + """Null byte in filename must be rejected.""" + # Starlette/httpx may reject the URL before it reaches the handler; + # accept any 4xx response as a valid rejection. + try: + response = client.get(f"/projects/{ENCODED_NAME}/memory/files/file\x00.md") + assert 400 <= response.status_code < 500 + except Exception: + # Client-side rejection is also a valid defense. + pass + + def test_dot_dot_in_filename_via_direct_handler_rejected(self, client, memory_dir, tmp_path): + """ + `../etc/passwd` via URL would hit a different route; validate the + handler directly to confirm its path validation. + """ + from fastapi import HTTPException + + from routers.projects import get_project_memory_file + + # Create a fake secret outside memory_dir that traversal would access. + (tmp_path / ".claude" / "secret.txt").write_text("sekrit", encoding="utf-8") + + class _DummyReq: + pass + + import asyncio + + for bad in ("..etc.passwd", "foo..bar.md", "._hidden.md"): + # Some of these should fail the leading-dot or .. or regex checks. + with pytest.raises(HTTPException) as exc_info: + asyncio.get_event_loop().run_until_complete( + get_project_memory_file( + encoded_name=ENCODED_NAME, + filename=bad, + request=_DummyReq(), + ) + ) + assert exc_info.value.status_code in (400, 403) + + +# ============================================================================= +# Non-existent project behavior +# ============================================================================= + + +class TestMissingProject: + def test_missing_project_memory_returns_empty(self, client, tmp_path, monkeypatch): + """Project dir that doesn't exist on disk: returns 404 from resolver, + or empty-exists=False response. Either is acceptable — we just assert + the service does not crash.""" + claude_dir = tmp_path / ".claude" + (claude_dir / "projects").mkdir(parents=True) + + from config import settings + + monkeypatch.setattr(settings, "claude_base", claude_dir) + + response = client.get(f"/projects/{ENCODED_NAME}/memory") + # Either 404 (resolver rejects unknown slug) OR 200 with empty shape. + assert response.status_code in (200, 404) + if response.status_code == 200: + data = response.json() + assert data["index"]["exists"] is False + assert data["files"] == [] diff --git a/api/tests/test_agent.py b/api/tests/test_agent.py index 121c5d52..c2fa8e88 100644 --- a/api/tests/test_agent.py +++ b/api/tests/test_agent.py @@ -360,16 +360,22 @@ def test_message_count_skips_empty_lines(self, temp_project_dir: Path) -> None: agent_path = temp_project_dir / "agent-with-empty.jsonl" with open(agent_path, "w") as f: - msg = { + msg1 = { "type": "user", "message": {"role": "user", "content": "test"}, "uuid": "uuid-1", "timestamp": "2026-01-08T13:00:00.000Z", } - f.write(json.dumps(msg) + "\n") + msg2 = { + "type": "user", + "message": {"role": "user", "content": "test2"}, + "uuid": "uuid-2", + "timestamp": "2026-01-08T13:01:00.000Z", + } + f.write(json.dumps(msg1) + "\n") f.write("\n") # Empty line f.write(" \n") # Whitespace only line - f.write(json.dumps(msg) + "\n") + f.write(json.dumps(msg2) + "\n") agent = Agent.from_path(agent_path) diff --git a/api/tests/test_conversation_endpoints.py b/api/tests/test_conversation_endpoints.py new file mode 100644 index 00000000..8b5a08a1 --- /dev/null +++ b/api/tests/test_conversation_endpoints.py @@ -0,0 +1,176 @@ +""" +Unit tests for build_conversation_timeline task-subject mapping logic. + +Covers the Pass 1b logic that: +1. Walks AssistantMessages for TaskCreate tool-use blocks. +2. Parses the matching tool result to extract the runtime-assigned task ID + using the regex ``Task #(\\d+)``. +3. Builds a taskId -> subject map used to annotate TaskUpdate events. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from datetime import datetime, timezone +from typing import Iterator, List + +from models.content import ToolUseBlock +from models.message import AssistantMessage, UserMessage +from services.conversation_endpoints import build_conversation_timeline + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_TS = datetime(2026, 1, 8, 12, 0, 0, tzinfo=timezone.utc) +_TOOL_RESULT_TS = datetime(2026, 1, 8, 12, 0, 5, tzinfo=timezone.utc) + + +def _user_msg( + uuid: str, content: str, *, is_tool_result: bool = False, tool_result_id: str | None = None +) -> UserMessage: + """Build a UserMessage directly without going through JSONL parsing.""" + return UserMessage( + uuid=uuid, + timestamp=_TS, + type="user", + content=content, + is_tool_result=is_tool_result, + tool_result_id=tool_result_id, + ) + + +def _assistant_msg_with_blocks(uuid: str, blocks: list) -> AssistantMessage: + """Build an AssistantMessage with pre-parsed content blocks.""" + return AssistantMessage( + uuid=uuid, + timestamp=_TS, + type="assistant", + content_blocks=blocks, + ) + + +def _task_create_block(block_id: str, subject: str) -> ToolUseBlock: + return ToolUseBlock( + type="tool_use", + id=block_id, + name="TaskCreate", + input={"subject": subject, "description": "desc"}, + ) + + +def _task_update_block(block_id: str, task_id: str, status: str = "in_progress") -> ToolUseBlock: + return ToolUseBlock( + type="tool_use", id=block_id, name="TaskUpdate", input={"taskId": task_id, "status": status} + ) + + +class FakeConversation: + """Minimal ConversationEntity for testing — satisfies MessageSource protocol.""" + + def __init__(self, messages: List): + self._messages = messages + # Attributes expected by build_conversation_timeline callers + self.cwd = "/fake/project" + + def iter_messages(self) -> Iterator: + return iter(self._messages) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestTaskUpdateInheritsSubject: + """TaskUpdate events should carry task_subject from matching TaskCreate.""" + + def test_task_update_inherits_subject_from_task_create(self): + """TaskCreate result supplies task ID; TaskUpdate metadata gets task_subject.""" + create_block_id = "toolu_create_001" + update_block_id = "toolu_update_001" + task_id = "1" + subject = "Fix login bug" + + # Tool result UserMessage: content is the raw extracted string (already + # handled by model validator when is_tool_result=True). + result_content = f"Task #{task_id} created successfully: {subject}" + + messages = [ + _assistant_msg_with_blocks( + "asst-001", + [_task_create_block(create_block_id, subject)], + ), + _user_msg( + "user-result-001", + result_content, + is_tool_result=True, + tool_result_id=create_block_id, + ), + _assistant_msg_with_blocks( + "asst-002", + [_task_update_block(update_block_id, task_id)], + ), + ] + + conversation = FakeConversation(messages) + events = build_conversation_timeline(conversation, working_dirs=["/fake/project"]) + + # Find the TaskUpdate event + update_events = [e for e in events if e.metadata.get("tool_name") == "TaskUpdate"] + assert len(update_events) == 1, "Expected exactly one TaskUpdate event" + assert update_events[0].metadata.get("task_subject") == subject + + def test_task_update_without_matching_task_create_has_no_subject(self): + """TaskUpdate referencing an unknown taskId gets no task_subject in metadata.""" + update_block_id = "toolu_update_orphan" + + messages = [ + # No TaskCreate at all — just a TaskUpdate for task #99 + _assistant_msg_with_blocks( + "asst-001", + [_task_update_block(update_block_id, "99")], + ), + ] + + conversation = FakeConversation(messages) + events = build_conversation_timeline(conversation, working_dirs=["/fake/project"]) + + update_events = [e for e in events if e.metadata.get("tool_name") == "TaskUpdate"] + assert len(update_events) == 1 + assert "task_subject" not in update_events[0].metadata + + def test_task_create_result_without_id_is_ignored(self): + """TaskCreate result that doesn't match 'Task #' leaves map empty.""" + create_block_id = "toolu_create_bad" + update_block_id = "toolu_update_bad" + subject = "Should not appear" + + # Result content lacks "#N" pattern — regex won't match + result_content = "Task created successfully (no ID in message)" + + messages = [ + _assistant_msg_with_blocks( + "asst-001", + [_task_create_block(create_block_id, subject)], + ), + _user_msg( + "user-result-bad", + result_content, + is_tool_result=True, + tool_result_id=create_block_id, + ), + _assistant_msg_with_blocks( + "asst-002", + [_task_update_block(update_block_id, "1")], + ), + ] + + conversation = FakeConversation(messages) + events = build_conversation_timeline(conversation, working_dirs=["/fake/project"]) + + update_events = [e for e in events if e.metadata.get("tool_name") == "TaskUpdate"] + assert len(update_events) == 1 + assert "task_subject" not in update_events[0].metadata diff --git a/api/tests/test_jsonl_utils.py b/api/tests/test_jsonl_utils.py index d1d0f5a7..535f6b0b 100644 --- a/api/tests/test_jsonl_utils.py +++ b/api/tests/test_jsonl_utils.py @@ -10,6 +10,7 @@ from typing import Any, Dict from models import AssistantMessage, UserMessage, iter_messages_from_jsonl +from models.jsonl_utils import _merge_user_message_dicts class TestIterMessagesFromJsonl: @@ -108,7 +109,8 @@ def test_preserves_message_order( for i in range(5): msg = sample_user_message_data.copy() msg["uuid"] = f"uuid-{i}" - msg["message"]["content"] = f"message {i}" + msg["timestamp"] = f"2026-01-08T13:0{i}:00.000Z" + msg["message"] = {"role": "user", "content": f"message {i}"} f.write(json.dumps(msg) + "\n") messages = list(iter_messages_from_jsonl(jsonl_path)) @@ -140,3 +142,309 @@ def test_is_a_generator( import types assert isinstance(result, types.GeneratorType) + + +class TestMessageMerging: + """Tests for _merge_user_message_dicts and the merge logic in iter_messages_from_jsonl.""" + + # ------------------------------------------------------------------------- + # Direct unit tests of _merge_user_message_dicts + # ------------------------------------------------------------------------- + + def test_merge_drops_image_source_text_part(self) -> None: + """Merged result contains the image block and real text, but NOT [Image: source:...] text.""" + base = { + "type": "user", + "uuid": "u1", + "timestamp": "2026-01-08T13:00:00.000Z", + "message": { + "role": "user", + "content": [ + {"type": "text", "text": "look at this"}, + { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": "abc"}, + }, + ], + }, + } + extra = { + "type": "user", + "uuid": "u2", + "timestamp": "2026-01-08T13:00:00.000Z", + "message": { + "role": "user", + "content": [ + {"type": "text", "text": "[Image: source: /var/folders/abc.png]"}, + ], + }, + } + + result = _merge_user_message_dicts(base, extra) + content = result["message"]["content"] + + types_in_result = [p.get("type") for p in content] + texts_in_result = [p.get("text", "") for p in content if p.get("type") == "text"] + + assert "image" in types_in_result + assert "look at this" in texts_in_result + # The [Image: source:...] fallback text must be absent + assert not any("[Image: source:" in t for t in texts_in_result) + + def test_merge_drops_image_hash_number_marker(self) -> None: + """ + Merged result drops the v2.1.83+ ``[Image #N]`` marker (including the + v2.1.85+ trailing-space variant). Regression guard for the format + change Claude Code introduced after our initial merge logic shipped. + """ + base = { + "type": "user", + "uuid": "u1", + "timestamp": "2026-01-08T13:00:00.000Z", + "message": { + "role": "user", + "content": [ + {"type": "text", "text": "explain this screenshot"}, + { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": "abc"}, + }, + { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": "def"}, + }, + ], + }, + } + extra = { + "type": "user", + "uuid": "u2", + "timestamp": "2026-01-08T13:00:00.000Z", + "message": { + "role": "user", + "content": [ + {"type": "text", "text": "[Image #1]"}, + {"type": "text", "text": "[Image #2] "}, # v2.1.85+ trailing-space variant + ], + }, + } + + result = _merge_user_message_dicts(base, extra) + content = result["message"]["content"] + + types_in_result = [p.get("type") for p in content] + texts_in_result = [p.get("text", "") for p in content if p.get("type") == "text"] + + # Both image blocks preserved from base + assert types_in_result.count("image") == 2 + # Real text from base preserved + assert "explain this screenshot" in texts_in_result + # Both [Image #N] markers dropped (with and without trailing space) + assert not any(t.startswith("[Image #") for t in texts_in_result) + + def test_merge_preserves_real_extra_text(self) -> None: + """Real text in the extra message is preserved alongside base content.""" + base = { + "type": "user", + "uuid": "u1", + "timestamp": "2026-01-08T13:00:00.000Z", + "message": {"role": "user", "content": [{"type": "text", "text": "first"}]}, + } + extra = { + "type": "user", + "uuid": "u2", + "timestamp": "2026-01-08T13:00:00.000Z", + "message": {"role": "user", "content": [{"type": "text", "text": "second"}]}, + } + + result = _merge_user_message_dicts(base, extra) + content = result["message"]["content"] + texts = [p["text"] for p in content if p.get("type") == "text"] + + assert "first" in texts + assert "second" in texts + + def test_merge_with_empty_extra_returns_base_content(self) -> None: + """When extra has no content, merged result preserves base content unchanged.""" + base = { + "type": "user", + "uuid": "u1", + "timestamp": "2026-01-08T13:00:00.000Z", + "message": {"role": "user", "content": [{"type": "text", "text": "only this"}]}, + } + extra = { + "type": "user", + "uuid": "u2", + "timestamp": "2026-01-08T13:00:00.000Z", + "message": {"role": "user", "content": []}, + } + + result = _merge_user_message_dicts(base, extra) + content = result["message"]["content"] + + assert len(content) == 1 + assert content[0]["text"] == "only this" + + def test_merge_handles_legacy_content_key(self) -> None: + """Merge works for legacy dicts that use top-level 'content' instead of 'message.content'.""" + base = { + "type": "user", + "uuid": "u1", + "timestamp": "2026-01-08T13:00:00.000Z", + "content": [{"type": "text", "text": "base text"}], + } + extra = { + "type": "user", + "uuid": "u2", + "timestamp": "2026-01-08T13:00:00.000Z", + "content": [{"type": "text", "text": "extra text"}], + } + + result = _merge_user_message_dicts(base, extra) + # No nested 'message' key — should fall back to top-level 'content' + content = result.get("content", []) + + texts = [p["text"] for p in content if p.get("type") == "text"] + assert "base text" in texts + assert "extra text" in texts + + # ------------------------------------------------------------------------- + # Integration tests via iter_messages_from_jsonl + # ------------------------------------------------------------------------- + + def _make_user_msg(self, uuid: str, timestamp: str, content_blocks: list) -> Dict[str, Any]: + return { + "type": "user", + "uuid": uuid, + "timestamp": timestamp, + "sessionId": "test-session", + "isSidechain": False, + "userType": "external", + "cwd": "/tmp/test", + "version": "2.1.1", + "message": {"role": "user", "content": content_blocks}, + } + + def _make_assistant_msg(self, uuid: str, timestamp: str) -> Dict[str, Any]: + return { + "type": "assistant", + "uuid": uuid, + "timestamp": timestamp, + "sessionId": "test-session", + "isSidechain": False, + "cwd": "/tmp/test", + "version": "2.1.1", + "message": { + "role": "assistant", + "model": "claude-opus-4-5-20251101", + "id": "msg_test", + "type": "message", + "content": [{"type": "text", "text": "reply"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 10, "output_tokens": 5}, + }, + } + + def test_iter_merges_same_timestamp_user_messages(self, temp_project_dir: Path) -> None: + """Two user messages at the same timestamp are merged into one UserMessage.""" + ts = "2026-01-08T13:00:00.000Z" + msg1 = self._make_user_msg( + "u1", + ts, + [ + {"type": "text", "text": "look at this"}, + { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": "abc"}, + }, + ], + ) + msg2 = self._make_user_msg( + "u2", + ts, + [ + {"type": "text", "text": "[Image: source: /var/folders/abc.png]"}, + ], + ) + + jsonl_path = temp_project_dir / "merge_test.jsonl" + with open(jsonl_path, "w") as f: + f.write(json.dumps(msg1) + "\n") + f.write(json.dumps(msg2) + "\n") + + messages = list(iter_messages_from_jsonl(jsonl_path)) + + assert len(messages) == 1 + assert isinstance(messages[0], UserMessage) + # Image data should be captured as attachment, and text should not include the [Image: source:] fallback + assert "[Image: source:" not in messages[0].content + + def test_iter_does_not_merge_different_timestamps(self, temp_project_dir: Path) -> None: + """User messages with different timestamps yield two separate messages.""" + msg1 = self._make_user_msg( + "u1", + "2026-01-08T13:00:00.000Z", + [ + {"type": "text", "text": "first message"}, + ], + ) + msg2 = self._make_user_msg( + "u2", + "2026-01-08T13:00:01.000Z", + [ + {"type": "text", "text": "second message"}, + ], + ) + + jsonl_path = temp_project_dir / "no_merge_test.jsonl" + with open(jsonl_path, "w") as f: + f.write(json.dumps(msg1) + "\n") + f.write(json.dumps(msg2) + "\n") + + messages = list(iter_messages_from_jsonl(jsonl_path)) + + assert len(messages) == 2 + assert all(isinstance(m, UserMessage) for m in messages) + + def test_iter_does_not_merge_user_and_assistant_with_same_timestamp( + self, temp_project_dir: Path + ) -> None: + """A user and an assistant message sharing the same timestamp are NOT merged.""" + ts = "2026-01-08T13:00:00.000Z" + user_msg = self._make_user_msg("u1", ts, [{"type": "text", "text": "user text"}]) + asst_msg = self._make_assistant_msg("a1", ts) + + jsonl_path = temp_project_dir / "cross_type_test.jsonl" + with open(jsonl_path, "w") as f: + f.write(json.dumps(user_msg) + "\n") + f.write(json.dumps(asst_msg) + "\n") + + messages = list(iter_messages_from_jsonl(jsonl_path)) + + assert len(messages) == 2 + assert isinstance(messages[0], UserMessage) + assert isinstance(messages[1], AssistantMessage) + + def test_iter_handles_three_consecutive_same_timestamp_user_messages( + self, temp_project_dir: Path + ) -> None: + """Three user messages at the same timestamp are all merged into one.""" + ts = "2026-01-08T13:00:00.000Z" + msg1 = self._make_user_msg("u1", ts, [{"type": "text", "text": "part one"}]) + msg2 = self._make_user_msg("u2", ts, [{"type": "text", "text": "part two"}]) + msg3 = self._make_user_msg("u3", ts, [{"type": "text", "text": "part three"}]) + + jsonl_path = temp_project_dir / "triple_merge_test.jsonl" + with open(jsonl_path, "w") as f: + f.write(json.dumps(msg1) + "\n") + f.write(json.dumps(msg2) + "\n") + f.write(json.dumps(msg3) + "\n") + + messages = list(iter_messages_from_jsonl(jsonl_path)) + + assert len(messages) == 1 + assert isinstance(messages[0], UserMessage) + # All three text parts should be present in the merged content + assert "part one" in messages[0].content + assert "part two" in messages[0].content + assert "part three" in messages[0].content diff --git a/api/tests/test_session.py b/api/tests/test_session.py index 551a6c8a..60968ad6 100644 --- a/api/tests/test_session.py +++ b/api/tests/test_session.py @@ -777,10 +777,12 @@ def test_get_git_branches_multiple( msg2 = sample_user_message_data.copy() msg2["uuid"] = "user-msg-002" + msg2["timestamp"] = "2026-01-08T13:01:00.000Z" msg2["gitBranch"] = "feature/new-stuff" msg3 = sample_user_message_data.copy() msg3["uuid"] = "user-msg-003" + msg3["timestamp"] = "2026-01-08T13:02:00.000Z" msg3["gitBranch"] = "main" # Duplicate with open(jsonl_path, "w") as f: @@ -827,6 +829,7 @@ def test_get_working_directories_multiple( msg2 = sample_user_message_data.copy() msg2["uuid"] = "user-msg-002" + msg2["timestamp"] = "2026-01-08T13:01:00.000Z" msg2["cwd"] = "/Users/test/project2" with open(jsonl_path, "w") as f: diff --git a/api/utils.py b/api/utils.py index 19b35af4..239616d2 100644 --- a/api/utils.py +++ b/api/utils.py @@ -776,7 +776,8 @@ def to_relative(path: str) -> str: return "Read file", to_relative(path), {"path": path} elif tool_name == "Write": path = tool_input.get("path") or tool_input.get("file_path", "") - return "Write file", to_relative(path), {"path": path} + content = tool_input.get("content", "") + return "Write file", to_relative(path), {"path": path, "content": content} elif tool_name == "Edit" or tool_name == "StrReplace": path = tool_input.get("path") or tool_input.get("file_path", "") return "Edit file", to_relative(path), {"path": path} diff --git a/captain-hook/CLAUDE.md b/captain-hook/CLAUDE.md index 5430c497..789e43c5 100644 --- a/captain-hook/CLAUDE.md +++ b/captain-hook/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Captain Hook is a type-safe Pydantic model library for Claude Code hook events. It provides models for all 10 hook types that fire during Claude Code sessions, with forward compatibility via `extra="allow"` configuration. +Captain Hook is a type-safe Pydantic model library for Claude Code hook events. It provides models for all 24 hook types that fire during Claude Code sessions, with forward compatibility via `extra="allow"` configuration. ## Commands @@ -29,12 +29,16 @@ The codebase follows a modular package structure under `src/captain_hook/`: - **`base.py`** - Foundation: `BaseHook` class with 5 common fields (`session_id`, `transcript_path`, `cwd`, `permission_mode`, `hook_event_name`) and all Literal type definitions - **`__init__.py`** - Entry point: exports all models, contains `parse_hook_event()` dispatcher and `HOOK_TYPE_MAP` registry - **Hook modules** - Grouped by category: - - `tool_hooks.py` - PreToolUseHook, PostToolUseHook - - `user_hooks.py` - UserPromptSubmitHook, PermissionRequestHook, NotificationHook + - `tool_hooks.py` - PreToolUseHook, PostToolUseHook, PostToolUseFailureHook + - `user_hooks.py` - UserPromptSubmitHook, PermissionRequestHook, NotificationHook, PermissionDeniedHook, ElicitationHook, ElicitationResultHook - `session_hooks.py` - SessionStartHook, SessionEndHook - - `agent_hooks.py` - StopHook, SubagentStopHook - - `context_hooks.py` - PreCompactHook -- **`outputs.py`** - Response models for hooks that return JSON (PreToolUseOutput, StopOutput, PermissionRequestOutput) + - `agent_hooks.py` - StopHook, SubagentStartHook, SubagentStopHook + - `context_hooks.py` - PreCompactHook, InstructionsLoadedHook + - `setup_hooks.py` - SetupHook + - `fs_hooks.py` - CwdChangedHook, FileChangedHook + - `team_hooks.py` - TaskCreatedHook, TaskCompletedHook, TeammateIdleHook (experimental Agent Teams) + - `worktree_hooks.py` - WorktreeCreateHook, WorktreeRemoveHook +- **`outputs.py`** - Response models for hooks that return JSON (PreToolUseOutput, StopOutput, PermissionRequestOutput, PermissionDeniedOutput) The root `models.py` is a backward compatibility layer that re-exports from `src.captain_hook`. @@ -49,10 +53,14 @@ hook = parse_hook_event(json_data) # Returns typed hook based on hook_event_nam **All hooks inherit from BaseHook** and use `ConfigDict(extra="allow")` for forward compatibility. **Hook capabilities vary:** -- Can block (exit code 2): PreToolUse, UserPromptSubmit, PermissionRequest +- Can block (exit code 2): PreToolUse, UserPromptSubmit, PermissionRequest, Elicitation, ElicitationResult, TeammateIdle, WorktreeCreate +- Can block (`{"continue": false}`): TaskCreated, TaskCompleted - Can modify input: PreToolUse - Can modify environment: SessionStart +- Can override worktree path (HTTP hook): WorktreeCreate +- Can request retry: PermissionDenied - Can force continuation: Stop, SubagentStop +- New PreToolUse permission decisions: `allow`, `deny`, `ask`, `defer` ## Documentation diff --git a/captain-hook/README.md b/captain-hook/README.md index afeaa0f1..bb4448b9 100644 --- a/captain-hook/README.md +++ b/captain-hook/README.md @@ -1,15 +1,15 @@ # Captain Hook - Claude Code Hooks Library -Type-safe Pydantic models and complete documentation for all Claude Code hook types. +Type-safe Pydantic models and complete documentation for all 24 Claude Code hook types. ## Overview Claude Code hooks are shell commands or LLM prompts that execute at specific points during a session. All hooks receive JSON input via **stdin**. This library provides: -- **Type-safe Pydantic models** for all 10 hook types +- **Type-safe Pydantic models** for all 24 hook types - **Comprehensive documentation** for each hook - **Forward compatibility** via `extra="allow"` configuration -- **113 tests** ensuring reliability +- **238 tests** ensuring reliability ## Installation @@ -48,6 +48,17 @@ if isinstance(hook, PreToolUseHook): | [PreCompact](./docs/hooks/PreCompact-info-available.md) | Before context compaction | No | No | | [PermissionRequest](./docs/hooks/PermissionRequest-info-available.md) | Permission dialog shown | Yes | No | | [Notification](./docs/hooks/Notification-info-available.md) | System notification | No | No | +| [InstructionsLoaded](./docs/hooks/InstructionsLoaded-info-available.md) | CLAUDE.md / rules file loaded | No | No | +| [PermissionDenied](./docs/hooks/PermissionDenied-info-available.md) | Auto mode denied a tool call | No | Yes (retry) | +| [Elicitation](./docs/hooks/Elicitation-info-available.md) | MCP server requests structured input | Yes | No | +| [ElicitationResult](./docs/hooks/ElicitationResult-info-available.md) | User responds to elicitation | Yes | No | +| [CwdChanged](./docs/hooks/CwdChanged-info-available.md) | Working directory changed | No | No | +| [FileChanged](./docs/hooks/FileChanged-info-available.md) | External file change detected | No | No | +| [TaskCreated](./docs/hooks/TaskCreated-info-available.md) | Agent Teams task created (experimental) | Yes | No | +| [TaskCompleted](./docs/hooks/TaskCompleted-info-available.md) | Agent Teams task completed (experimental) | Yes | No | +| [TeammateIdle](./docs/hooks/TeammateIdle-info-available.md) | Teammate became idle (experimental) | Yes | No | +| [WorktreeCreate](./docs/hooks/WorktreeCreate-info-available.md) | Before git worktree creation | Yes | Yes (path via HTTP) | +| [WorktreeRemove](./docs/hooks/WorktreeRemove-info-available.md) | After git worktree removal | No | No | ## Common Fields (All Hooks) @@ -148,14 +159,19 @@ hooks: captain-hook/ ├── src/captain_hook/ # Main package (modular design) │ ├── base.py # BaseHook class & type definitions -│ ├── tool_hooks.py # PreToolUseHook, PostToolUseHook -│ ├── user_hooks.py # UserPromptSubmitHook, PermissionRequestHook, NotificationHook +│ ├── tool_hooks.py # PreToolUseHook, PostToolUseHook, PostToolUseFailureHook +│ ├── user_hooks.py # UserPromptSubmitHook, PermissionRequestHook, NotificationHook, +│ │ # PermissionDeniedHook, ElicitationHook, ElicitationResultHook │ ├── session_hooks.py # SessionStartHook, SessionEndHook -│ ├── agent_hooks.py # StopHook, SubagentStopHook -│ ├── context_hooks.py # PreCompactHook +│ ├── agent_hooks.py # StopHook, SubagentStartHook, SubagentStopHook +│ ├── context_hooks.py # PreCompactHook, InstructionsLoadedHook +│ ├── setup_hooks.py # SetupHook +│ ├── fs_hooks.py # CwdChangedHook, FileChangedHook +│ ├── team_hooks.py # TaskCreatedHook, TaskCompletedHook, TeammateIdleHook +│ ├── worktree_hooks.py # WorktreeCreateHook, WorktreeRemoveHook │ └── outputs.py # Response models ├── docs/hooks/ # Hook documentation -├── tests/ # Test suite (113 tests) +├── tests/ # Test suite (238 tests) ├── models.py # Backward compatibility layer └── file_index.md # Complete file reference ``` @@ -194,14 +210,28 @@ from models import parse_hook_event, PreToolUseHook | `BaseHook` | All | `session_id`, `transcript_path`, `cwd`, `permission_mode` | | `PreToolUseHook` | PreToolUse | `tool_name`, `tool_use_id`, `tool_input` | | `PostToolUseHook` | PostToolUse | `tool_name`, `tool_use_id`, `tool_input`, `tool_response` | +| `PostToolUseFailureHook` | PostToolUseFailure | `tool_name`, `tool_use_id`, `tool_input`, `error` | | `UserPromptSubmitHook` | UserPromptSubmit | `prompt` | -| `SessionStartHook` | SessionStart | `source` | +| `SessionStartHook` | SessionStart | `source`, `model`, `agent_type` | | `SessionEndHook` | SessionEnd | `reason` | | `StopHook` | Stop | `stop_hook_active` | -| `SubagentStopHook` | SubagentStop | `stop_hook_active` | +| `SubagentStartHook` | SubagentStart | `agent_id`, `agent_type` | +| `SubagentStopHook` | SubagentStop | `stop_hook_active`, `agent_id`, `agent_transcript_path` | | `PreCompactHook` | PreCompact | `trigger`, `custom_instructions` | | `PermissionRequestHook` | PermissionRequest | `notification_type`, `message` | -| `NotificationHook` | Notification | `notification_type` | +| `NotificationHook` | Notification | `notification_type`, `message` | +| `SetupHook` | Setup | `trigger` | +| `InstructionsLoadedHook` | InstructionsLoaded | `file_path`, `memory_type`, `load_reason`, `globs` | +| `PermissionDeniedHook` | PermissionDenied | `tool_name`, `tool_use_id`, `reason`, `tool_input` | +| `ElicitationHook` | Elicitation | `mcp_server`, `tool_name`, `request` | +| `ElicitationResultHook` | ElicitationResult | `mcp_server`, `user_response` | +| `CwdChangedHook` | CwdChanged | `old_cwd`, `new_cwd` | +| `FileChangedHook` | FileChanged | `file_path`, `file_name` | +| `TaskCreatedHook` | TaskCreated | `task_id`, `task_subject`, `team_name`, `teammate_name` | +| `TaskCompletedHook` | TaskCompleted | `task_id`, `task_subject`, `team_name`, `teammate_name` | +| `TeammateIdleHook` | TeammateIdle | `agent_id`, `agent_type`, `team_name` | +| `WorktreeCreateHook` | WorktreeCreate | `worktree_name`, `base_ref` | +| `WorktreeRemoveHook` | WorktreeRemove | `worktree_path`, `worktree_name` | ### Output Models @@ -237,12 +267,12 @@ assert hook.new_field_2026 == "some_value" pytest tests/test_models.py -v ``` -113 tests covering: +238 tests covering: - Base hook validation -- All 10 hook types (parametrized) +- All 24 hook types (parametrized) - Parser dispatch function - Forward compatibility -- Output models +- Output models (including the new `defer` permission decision and `PermissionDeniedOutput`) - JSON serialization round-trips --- diff --git a/captain-hook/docs/hooks/CwdChanged-info-available.md b/captain-hook/docs/hooks/CwdChanged-info-available.md new file mode 100644 index 00000000..d86fae4a --- /dev/null +++ b/captain-hook/docs/hooks/CwdChanged-info-available.md @@ -0,0 +1,71 @@ +# CwdChanged Hook + +Fires when Claude Code's working directory changes mid-session. Cannot block execution. + +## When It Fires + +- When the user `cd`s to a different directory +- When a tool changes the current working directory +- After git worktree switches that update the cwd + +## Input JSON (via stdin) + +```json +{ + "session_id": "abc123-def456", + "transcript_path": "/Users/name/.claude/projects/hash/sessions/session-id.jsonl", + "cwd": "/Users/me/new_project", + "permission_mode": "default", + "hook_event_name": "CwdChanged", + + "old_cwd": "/Users/me/old_project", + "new_cwd": "/Users/me/new_project" +} +``` + +## Field Reference + +### Common Fields + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | string | Unique session identifier | +| `transcript_path` | string | Path to full conversation JSONL | +| `cwd` | string | Current working directory (matches `new_cwd` after the change) | +| `permission_mode` | enum | Current permission mode | +| `hook_event_name` | string | Always `"CwdChanged"` | + +### CwdChanged-Specific Fields + +| Field | Type | Description | +|-------|------|-------------| +| `old_cwd` | string | Previous working directory before the change | +| `new_cwd` | string | New working directory after the change | + +## Output Options + +CwdChanged **cannot block** — it is purely observational. + +## Configuration Example + +```yaml +hooks: + CwdChanged: + - command: | + INPUT=$(cat) + OLD=$(echo "$INPUT" | jq -r '.old_cwd') + NEW=$(echo "$INPUT" | jq -r '.new_cwd') + echo "$(date): $OLD -> $NEW" >> /tmp/cwd_history.log + timeout: 2000 +``` + +## Use Cases + +1. **Navigation tracking** — record movement across projects +2. **Per-directory environment switching** — react to project context changes +3. **Audit logs** — capture working directory history for compliance + +## Notes + +- Cannot block execution (exit code is ignored) +- The base hook `cwd` field reflects the **new** directory after the change diff --git a/captain-hook/docs/hooks/Elicitation-info-available.md b/captain-hook/docs/hooks/Elicitation-info-available.md new file mode 100644 index 00000000..789e183d --- /dev/null +++ b/captain-hook/docs/hooks/Elicitation-info-available.md @@ -0,0 +1,81 @@ +# Elicitation Hook + +Fires when an MCP server requests structured input from the user. **CAN block** via exit code 2. + +## When It Fires + +- When an MCP tool invokes the `elicitation/create` MCP method to ask the user for structured input +- Before the elicitation dialog is shown to the user + +## Input JSON (via stdin) + +```json +{ + "session_id": "abc123-def456", + "transcript_path": "/Users/name/.claude/projects/hash/sessions/session-id.jsonl", + "cwd": "/path/to/current/directory", + "permission_mode": "default", + "hook_event_name": "Elicitation", + + "mcp_server": "github", + "tool_name": "create_issue", + "request": { + "title": "string", + "body": "string" + } +} +``` + +## Field Reference + +### Common Fields + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | string | Unique session identifier | +| `transcript_path` | string | Path to full conversation JSONL | +| `cwd` | string | Current working directory | +| `permission_mode` | enum | Current permission mode | +| `hook_event_name` | string | Always `"Elicitation"` | + +### Elicitation-Specific Fields + +| Field | Type | Description | +|-------|------|-------------| +| `mcp_server` | string | Name of the MCP server making the elicitation request | +| `tool_name` | string | Tool that triggered the elicitation request | +| `request` | object | The form/schema that the MCP server requested input for | + +## Output Options + +### Allow (default) +Exit code 0, no output. The elicitation dialog is shown to the user. + +### Block Elicitation +Exit code 2 with stderr to prevent the dialog (useful for policy enforcement). + +## Configuration Example + +```yaml +hooks: + Elicitation: + - command: | + INPUT=$(cat) + SERVER=$(echo "$INPUT" | jq -r '.mcp_server') + if [[ "$SERVER" == "untrusted-mcp" ]]; then + echo "Blocked elicitation from $SERVER" >&2 + exit 2 + fi + timeout: 2000 +``` + +## Use Cases + +1. **Policy enforcement** — block elicitations from untrusted MCP servers +2. **Audit logging** — record every elicitation request +3. **Auto-fill** — capture the request schema for future automation + +## Notes + +- Blocks via exit code 2 +- Fires before `ElicitationResult` (which fires after the user responds) diff --git a/captain-hook/docs/hooks/ElicitationResult-info-available.md b/captain-hook/docs/hooks/ElicitationResult-info-available.md new file mode 100644 index 00000000..b1f07541 --- /dev/null +++ b/captain-hook/docs/hooks/ElicitationResult-info-available.md @@ -0,0 +1,76 @@ +# ElicitationResult Hook + +Fires when the user responds to an MCP elicitation request. **CAN block** via exit code 2. + +## When It Fires + +- After the user submits a response to an `elicitation/create` MCP request +- Before the response is returned to the MCP server + +## Input JSON (via stdin) + +```json +{ + "session_id": "abc123-def456", + "transcript_path": "/Users/name/.claude/projects/hash/sessions/session-id.jsonl", + "cwd": "/path/to/current/directory", + "permission_mode": "default", + "hook_event_name": "ElicitationResult", + + "mcp_server": "github", + "user_response": { + "title": "Bug report", + "body": "Steps to reproduce..." + } +} +``` + +## Field Reference + +### Common Fields + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | string | Unique session identifier | +| `transcript_path` | string | Path to full conversation JSONL | +| `cwd` | string | Current working directory | +| `permission_mode` | enum | Current permission mode | +| `hook_event_name` | string | Always `"ElicitationResult"` | + +### ElicitationResult-Specific Fields + +| Field | Type | Description | +|-------|------|-------------| +| `mcp_server` | string | Name of the MCP server that originally requested the elicitation | +| `user_response` | object | The structured response provided by the user | + +## Output Options + +### Allow (default) +Exit code 0, no output. The response is delivered to the MCP server. + +### Block Delivery +Exit code 2 with stderr to prevent the response from reaching the MCP server. + +## Configuration Example + +```yaml +hooks: + ElicitationResult: + - command: | + INPUT=$(cat) + SERVER=$(echo "$INPUT" | jq -r '.mcp_server') + echo "User responded to $SERVER" >> /tmp/elicitations.log + timeout: 2000 +``` + +## Use Cases + +1. **PII filtering** — block responses that contain sensitive data +2. **Audit logging** — capture user responses for review +3. **Validation** — reject malformed responses before they reach the MCP server + +## Notes + +- Blocks via exit code 2 +- Fires after `Elicitation` (which fires before the dialog is shown) diff --git a/captain-hook/docs/hooks/FileChanged-info-available.md b/captain-hook/docs/hooks/FileChanged-info-available.md new file mode 100644 index 00000000..b54885a3 --- /dev/null +++ b/captain-hook/docs/hooks/FileChanged-info-available.md @@ -0,0 +1,70 @@ +# FileChanged Hook + +Fires when an external file change is detected by Claude Code's filesystem watcher. Cannot block execution. + +## When It Fires + +- When a file in the project tree is modified outside Claude Code +- When a watched file is created, edited, or deleted +- Used to keep Claude's view of the workspace in sync + +## Input JSON (via stdin) + +```json +{ + "session_id": "abc123-def456", + "transcript_path": "/Users/name/.claude/projects/hash/sessions/session-id.jsonl", + "cwd": "/Users/me/project", + "permission_mode": "default", + "hook_event_name": "FileChanged", + + "file_path": "/Users/me/project/src/main.py", + "file_name": "main.py" +} +``` + +## Field Reference + +### Common Fields + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | string | Unique session identifier | +| `transcript_path` | string | Path to full conversation JSONL | +| `cwd` | string | Current working directory | +| `permission_mode` | enum | Current permission mode | +| `hook_event_name` | string | Always `"FileChanged"` | + +### FileChanged-Specific Fields + +| Field | Type | Description | +|-------|------|-------------| +| `file_path` | string | Absolute path to the file that changed | +| `file_name` | string | Basename of the file that changed | + +## Output Options + +FileChanged **cannot block** — it is purely observational. + +## Configuration Example + +```yaml +hooks: + FileChanged: + - command: | + INPUT=$(cat) + FILE=$(echo "$INPUT" | jq -r '.file_path') + echo "External change: $FILE" >> /tmp/file_changes.log + timeout: 2000 +``` + +## Use Cases + +1. **Cache invalidation** — refresh derived state when source files change +2. **Live-reload integrations** — coordinate with build watchers +3. **Conflict detection** — warn when external edits collide with Claude's pending changes + +## Notes + +- Cannot block execution (exit code is ignored) +- Fires per file, not per batch — multiple events may arrive in quick succession diff --git a/captain-hook/docs/hooks/InstructionsLoaded-info-available.md b/captain-hook/docs/hooks/InstructionsLoaded-info-available.md new file mode 100644 index 00000000..83a4d8b5 --- /dev/null +++ b/captain-hook/docs/hooks/InstructionsLoaded-info-available.md @@ -0,0 +1,80 @@ +# InstructionsLoaded Hook + +Fires when Claude Code loads a CLAUDE.md or rules file into context. Cannot block execution. + +## When It Fires + +- On session startup as project/user/managed CLAUDE.md files are loaded +- When a CLAUDE.md uses `@import` to pull in another file +- When glob-matched rules files are activated for a path + +## Input JSON (via stdin) + +```json +{ + "session_id": "abc123-def456", + "transcript_path": "/Users/name/.claude/projects/hash/sessions/session-id.jsonl", + "cwd": "/path/to/current/directory", + "permission_mode": "default", + "hook_event_name": "InstructionsLoaded", + + "file_path": "/Users/me/repo/CLAUDE.md", + "memory_type": "project", + "load_reason": "startup", + "globs": [], + "trigger_file_path": null, + "parent_file_path": null +} +``` + +## Field Reference + +### Common Fields + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | string | Unique session identifier | +| `transcript_path` | string | Path to full conversation JSONL | +| `cwd` | string | Current working directory | +| `permission_mode` | enum | Current permission mode | +| `hook_event_name` | string | Always `"InstructionsLoaded"` | + +### InstructionsLoaded-Specific Fields + +| Field | Type | Description | +|-------|------|-------------| +| `file_path` | string | Absolute path to the CLAUDE.md / rules file that was loaded | +| `memory_type` | string | Category: `project`, `user`, `plugin`, `managed` | +| `load_reason` | string | Why loaded: `startup`, `import`, `glob_match`, etc. | +| `globs` | string[] | Glob patterns associated with the load (when triggered by globs) | +| `trigger_file_path` | string \| null | File that triggered the load (for imports/glob matches) | +| `parent_file_path` | string \| null | Parent CLAUDE.md that imported this one | + +## Output Options + +InstructionsLoaded **cannot block** — it is purely observational. Stdout/stderr are logged but ignored. + +## Configuration Example + +```yaml +hooks: + InstructionsLoaded: + - command: | + INPUT=$(cat) + FILE=$(echo "$INPUT" | jq -r '.file_path') + echo "Loaded instructions: $FILE" >> /tmp/instructions.log + timeout: 2000 +``` + +## Use Cases + +1. **Audit logging** — track which rule files are active in each session +2. **Index building** — build a searchable map of project instructions +3. **Validation** — verify expected CLAUDE.md files were loaded +4. **Telemetry** — measure rule file usage across projects + +## Notes + +- Cannot block execution (exit code is ignored) +- Fires once per loaded file (multiple times per session) +- For nested imports, both `trigger_file_path` and `parent_file_path` are populated diff --git a/captain-hook/docs/hooks/PermissionDenied-info-available.md b/captain-hook/docs/hooks/PermissionDenied-info-available.md new file mode 100644 index 00000000..df318d5b --- /dev/null +++ b/captain-hook/docs/hooks/PermissionDenied-info-available.md @@ -0,0 +1,89 @@ +# PermissionDenied Hook + +Fires when **auto mode** denies a tool call. Cannot block execution, but **can request a retry** via JSON output. + +## When It Fires + +- After Claude Code's auto-mode permission policy rejects a pending tool call +- Distinct from `PermissionRequest` (which fires before showing a dialog) and `PreToolUse` (which fires before any tool runs) + +## Input JSON (via stdin) + +```json +{ + "session_id": "abc123-def456", + "transcript_path": "/Users/name/.claude/projects/hash/sessions/session-id.jsonl", + "cwd": "/path/to/current/directory", + "permission_mode": "default", + "hook_event_name": "PermissionDenied", + + "tool_name": "Bash", + "tool_use_id": "toolu_01ABC123", + "reason": "Auto mode policy: dangerous command", + "tool_input": { + "command": "rm -rf /" + } +} +``` + +## Field Reference + +### Common Fields + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | string | Unique session identifier | +| `transcript_path` | string | Path to full conversation JSONL | +| `cwd` | string | Current working directory | +| `permission_mode` | enum | Current permission mode | +| `hook_event_name` | string | Always `"PermissionDenied"` | + +### PermissionDenied-Specific Fields + +| Field | Type | Description | +|-------|------|-------------| +| `tool_name` | string | Name of the tool whose call was denied | +| `tool_use_id` | string | Unique identifier for the denied tool invocation | +| `reason` | string | Denial reason string explaining why the tool call was rejected | +| `tool_input` | object | Original input parameters of the denied tool call | + +## Output Options + +### Default (No Retry) +Exit code 0, no output. The denial stands and Claude is informed. + +### Request Retry +```json +{ + "hookSpecificOutput": { + "retry": true + } +} +``` + +When `retry` is `true`, Claude Code will re-attempt the tool call (useful after a hook has fixed external state, e.g., re-authenticated, opened a port, etc.). + +## Configuration Example + +```yaml +hooks: + PermissionDenied: + - command: | + INPUT=$(cat) + REASON=$(echo "$INPUT" | jq -r '.reason') + TOOL=$(echo "$INPUT" | jq -r '.tool_name') + echo "Denied $TOOL: $REASON" >> /tmp/denied.log + timeout: 2000 +``` + +## Use Cases + +1. **Audit denials** — log every blocked tool call for review +2. **Auto-recovery** — fix the underlying issue and request a retry +3. **Alerting** — notify operators when unsafe operations are attempted + +## Notes + +- Cannot block execution (the denial is already in effect) +- The `retry: true` output asks Claude to attempt the same call again +- Fires once per denied tool call diff --git a/captain-hook/docs/hooks/TaskCompleted-info-available.md b/captain-hook/docs/hooks/TaskCompleted-info-available.md new file mode 100644 index 00000000..8359e542 --- /dev/null +++ b/captain-hook/docs/hooks/TaskCompleted-info-available.md @@ -0,0 +1,83 @@ +# TaskCompleted Hook + +Fires when a task is completed in an Agent Team. **CAN block** via `{"continue": false}`. + +> **Experimental:** This hook is gated on `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. + +## When It Fires + +- After a teammate marks a task as complete +- Before downstream consumers (other tasks, reviewers) are notified + +## Input JSON (via stdin) + +```json +{ + "session_id": "abc123-def456", + "transcript_path": "/Users/name/.claude/projects/hash/sessions/session-id.jsonl", + "cwd": "/path/to/current/directory", + "permission_mode": "default", + "hook_event_name": "TaskCompleted", + + "task_id": "task_002", + "task_subject": "Add tests", + "task_description": "Cover edge cases", + "teammate_name": "Bob", + "team_name": "core-team" +} +``` + +## Field Reference + +### Common Fields + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | string | Unique session identifier | +| `transcript_path` | string | Path to full conversation JSONL | +| `cwd` | string | Current working directory | +| `permission_mode` | enum | Current permission mode | +| `hook_event_name` | string | Always `"TaskCompleted"` | + +### TaskCompleted-Specific Fields + +| Field | Type | Description | +|-------|------|-------------| +| `task_id` | string | Unique identifier for the completed task | +| `task_subject` | string | Short subject/title of the task | +| `task_description` | string \| null | Longer task description (if provided) | +| `teammate_name` | string \| null | Name of the teammate that completed the task | +| `team_name` | string | Name of the team where the task was completed | + +## Output Options + +### Allow (default) +Exit code 0, no output. + +### Block Completion +```json +{ + "continue": false, + "stopReason": "Task completion blocked: missing required tests" +} +``` + +## Configuration Example + +```yaml +hooks: + TaskCompleted: + - command: ./scripts/verify-task-done.sh + timeout: 5000 +``` + +## Use Cases + +1. **Quality gates** — verify tests/lint pass before allowing completion +2. **External sync** — close issues in an external tracker +3. **Metrics** — record task throughput per teammate + +## Notes + +- Experimental — requires `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` +- Blocks via `{"continue": false}` in the JSON output diff --git a/captain-hook/docs/hooks/TaskCreated-info-available.md b/captain-hook/docs/hooks/TaskCreated-info-available.md new file mode 100644 index 00000000..ca04053e --- /dev/null +++ b/captain-hook/docs/hooks/TaskCreated-info-available.md @@ -0,0 +1,83 @@ +# TaskCreated Hook + +Fires when a task is created in an Agent Team. **CAN block** via `{"continue": false}`. + +> **Experimental:** This hook is gated on `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. + +## When It Fires + +- After a teammate creates a new task in the team's task list +- Before the task is dispatched to its assigned teammate + +## Input JSON (via stdin) + +```json +{ + "session_id": "abc123-def456", + "transcript_path": "/Users/name/.claude/projects/hash/sessions/session-id.jsonl", + "cwd": "/path/to/current/directory", + "permission_mode": "default", + "hook_event_name": "TaskCreated", + + "task_id": "task_001", + "task_subject": "Refactor auth module", + "task_description": "Split auth.py into smaller files", + "teammate_name": "Alice", + "team_name": "core-team" +} +``` + +## Field Reference + +### Common Fields + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | string | Unique session identifier | +| `transcript_path` | string | Path to full conversation JSONL | +| `cwd` | string | Current working directory | +| `permission_mode` | enum | Current permission mode | +| `hook_event_name` | string | Always `"TaskCreated"` | + +### TaskCreated-Specific Fields + +| Field | Type | Description | +|-------|------|-------------| +| `task_id` | string | Unique identifier for the created task | +| `task_subject` | string | Short subject/title of the task | +| `task_description` | string \| null | Longer task description (if provided) | +| `teammate_name` | string \| null | Name of the assignee teammate (if any) | +| `team_name` | string | Name of the team where the task was created | + +## Output Options + +### Allow (default) +Exit code 0, no output. + +### Block Task Creation +```json +{ + "continue": false, + "stopReason": "Task creation rejected by policy" +} +``` + +## Configuration Example + +```yaml +hooks: + TaskCreated: + - command: ./scripts/audit-task-created.sh + timeout: 3000 +``` + +## Use Cases + +1. **Task auditing** — log every created task for the team +2. **Policy enforcement** — block tasks that violate rules (e.g., assigning to a paused teammate) +3. **External tracking** — sync new tasks to an issue tracker + +## Notes + +- Experimental — requires `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` +- Blocks via `{"continue": false}` in the JSON output diff --git a/captain-hook/docs/hooks/TeammateIdle-info-available.md b/captain-hook/docs/hooks/TeammateIdle-info-available.md new file mode 100644 index 00000000..13075e6b --- /dev/null +++ b/captain-hook/docs/hooks/TeammateIdle-info-available.md @@ -0,0 +1,74 @@ +# TeammateIdle Hook + +Fires when a teammate becomes idle (awaiting a task). **CAN block** via exit code 2. + +> **Experimental:** This hook is gated on `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. + +## When It Fires + +- When an Agent Teams teammate finishes its current task and has no queued work +- Useful for opportunistic task assignment + +## Input JSON (via stdin) + +```json +{ + "session_id": "abc123-def456", + "transcript_path": "/Users/name/.claude/projects/hash/sessions/session-id.jsonl", + "cwd": "/path/to/current/directory", + "permission_mode": "default", + "hook_event_name": "TeammateIdle", + + "agent_id": "agent_idle_001", + "agent_type": "Explore", + "team_name": "core-team" +} +``` + +## Field Reference + +### Common Fields + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | string | Unique session identifier | +| `transcript_path` | string | Path to full conversation JSONL | +| `cwd` | string | Current working directory | +| `permission_mode` | enum | Current permission mode | +| `hook_event_name` | string | Always `"TeammateIdle"` | + +### TeammateIdle-Specific Fields + +| Field | Type | Description | +|-------|------|-------------| +| `agent_id` | string | Unique identifier for the idle agent | +| `agent_type` | string | Type of the idle agent (e.g., `Explore`, `Plan`) | +| `team_name` | string \| null | Name of the team the agent belongs to (if any) | + +## Output Options + +### Allow Idle (default) +Exit code 0, no output. + +### Block Idleness +Exit code 2 with stderr to keep the agent active (e.g., to dispatch new work). + +## Configuration Example + +```yaml +hooks: + TeammateIdle: + - command: ./scripts/maybe-dispatch-task.sh + timeout: 5000 +``` + +## Use Cases + +1. **Opportunistic dispatch** — push queued work to idle teammates +2. **Telemetry** — track idle time per teammate +3. **Auto-shutdown** — block idleness only if there is no more work + +## Notes + +- Experimental — requires `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` +- Blocks via exit code 2 diff --git a/captain-hook/docs/hooks/WorktreeCreate-info-available.md b/captain-hook/docs/hooks/WorktreeCreate-info-available.md new file mode 100644 index 00000000..0e7eeb61 --- /dev/null +++ b/captain-hook/docs/hooks/WorktreeCreate-info-available.md @@ -0,0 +1,79 @@ +# WorktreeCreate Hook + +Fires when a git worktree is about to be created. **CAN override** the worktree path via an HTTP hook response. + +## When It Fires + +- Before Claude Code creates a new git worktree (e.g., from Claude Desktop session creation, `/worktree` slash command, or tool invocation) + +## Input JSON (via stdin) + +```json +{ + "session_id": "abc123-def456", + "transcript_path": "/Users/name/.claude/projects/hash/sessions/session-id.jsonl", + "cwd": "/path/to/current/directory", + "permission_mode": "default", + "hook_event_name": "WorktreeCreate", + + "worktree_name": "feat-auth", + "base_ref": "main" +} +``` + +## Field Reference + +### Common Fields + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | string | Unique session identifier | +| `transcript_path` | string | Path to full conversation JSONL | +| `cwd` | string | Current working directory | +| `permission_mode` | enum | Current permission mode | +| `hook_event_name` | string | Always `"WorktreeCreate"` | + +### WorktreeCreate-Specific Fields + +| Field | Type | Description | +|-------|------|-------------| +| `worktree_name` | string | The chosen or generated name for the new worktree | +| `base_ref` | string | Git ref the worktree branches from (e.g., `main`, `origin/develop`) | + +## Output Options + +### Allow Default Path +Exit code 0, no output. + +### Override Worktree Path (HTTP hook only) +An HTTP hook can return: +```json +{ + "worktreePath": "/custom/location/worktrees/feat-auth" +} +``` +to redirect where the worktree is created. + +### Block Creation +Exit code 2 with stderr. + +## Configuration Example + +```yaml +hooks: + WorktreeCreate: + - type: "http" + url: "https://hooks.example.com/worktree" + timeout: 5000 +``` + +## Use Cases + +1. **Custom storage** — place worktrees on a faster disk or shared mount +2. **Naming conventions** — enforce a project-specific worktree layout +3. **Audit logging** — record every worktree creation + +## Notes + +- The `worktreePath` override is only honored from HTTP hook responses +- Command hooks can still observe and block via exit code 2 diff --git a/captain-hook/docs/hooks/WorktreeRemove-info-available.md b/captain-hook/docs/hooks/WorktreeRemove-info-available.md new file mode 100644 index 00000000..a66622c3 --- /dev/null +++ b/captain-hook/docs/hooks/WorktreeRemove-info-available.md @@ -0,0 +1,68 @@ +# WorktreeRemove Hook + +Fires when a git worktree is removed. Cannot block execution. + +## When It Fires + +- After Claude Code removes a git worktree (e.g., session cleanup, `/worktree remove` slash command) + +## Input JSON (via stdin) + +```json +{ + "session_id": "abc123-def456", + "transcript_path": "/Users/name/.claude/projects/hash/sessions/session-id.jsonl", + "cwd": "/path/to/current/directory", + "permission_mode": "default", + "hook_event_name": "WorktreeRemove", + + "worktree_path": "/Users/me/.claude-worktrees/repo/feat-auth", + "worktree_name": "feat-auth" +} +``` + +## Field Reference + +### Common Fields + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | string | Unique session identifier | +| `transcript_path` | string | Path to full conversation JSONL | +| `cwd` | string | Current working directory | +| `permission_mode` | enum | Current permission mode | +| `hook_event_name` | string | Always `"WorktreeRemove"` | + +### WorktreeRemove-Specific Fields + +| Field | Type | Description | +|-------|------|-------------| +| `worktree_path` | string | Absolute path to the worktree being removed | +| `worktree_name` | string | Name of the worktree being removed | + +## Output Options + +WorktreeRemove **cannot block** — it is purely observational. + +## Configuration Example + +```yaml +hooks: + WorktreeRemove: + - command: | + INPUT=$(cat) + PATH_=$(echo "$INPUT" | jq -r '.worktree_path') + echo "$(date): Removed $PATH_" >> /tmp/worktree_removals.log + timeout: 2000 +``` + +## Use Cases + +1. **Cleanup verification** — log every removed worktree +2. **External cleanup** — drop derived caches, indexes, or DB rows tied to the worktree +3. **Telemetry** — measure worktree lifetime + +## Notes + +- Cannot block execution (exit code is ignored) +- Fires after the worktree's git metadata is removed diff --git a/captain-hook/models.py b/captain-hook/models.py index 1ffac876..fd22ce01 100644 --- a/captain-hook/models.py +++ b/captain-hook/models.py @@ -30,11 +30,24 @@ PermissionRequestHook, NotificationHook, SetupHook, + # New hook types (v2.1.83 - v2.1.92) + InstructionsLoadedHook, + PermissionDeniedHook, + ElicitationHook, + ElicitationResultHook, + CwdChangedHook, + FileChangedHook, + TaskCreatedHook, + TaskCompletedHook, + TeammateIdleHook, + WorktreeCreateHook, + WorktreeRemoveHook, # Output Types HookOutput, PreToolUseOutput, StopOutput, PermissionRequestOutput, + PermissionDeniedOutput, # Enums PermissionMode, HookEventName, @@ -65,11 +78,24 @@ "PermissionRequestHook", "NotificationHook", "SetupHook", + # New hook types (v2.1.83 - v2.1.92) + "InstructionsLoadedHook", + "PermissionDeniedHook", + "ElicitationHook", + "ElicitationResultHook", + "CwdChangedHook", + "FileChangedHook", + "TaskCreatedHook", + "TaskCompletedHook", + "TeammateIdleHook", + "WorktreeCreateHook", + "WorktreeRemoveHook", # Output Types "HookOutput", "PreToolUseOutput", "StopOutput", "PermissionRequestOutput", + "PermissionDeniedOutput", # Enums "PermissionMode", "HookEventName", diff --git a/captain-hook/src/captain_hook/__init__.py b/captain-hook/src/captain_hook/__init__.py index 1a3aa92d..83811ce0 100644 --- a/captain-hook/src/captain_hook/__init__.py +++ b/captain-hook/src/captain_hook/__init__.py @@ -41,11 +41,21 @@ # ============================================================================= from .tool_hooks import PreToolUseHook, PostToolUseHook, PostToolUseFailureHook -from .user_hooks import UserPromptSubmitHook, PermissionRequestHook, NotificationHook +from .user_hooks import ( + UserPromptSubmitHook, + PermissionRequestHook, + NotificationHook, + PermissionDeniedHook, + ElicitationHook, + ElicitationResultHook, +) from .session_hooks import SessionStartHook, SessionEndHook from .agent_hooks import StopHook, SubagentStopHook, SubagentStartHook -from .context_hooks import PreCompactHook +from .context_hooks import PreCompactHook, InstructionsLoadedHook from .setup_hooks import SetupHook +from .fs_hooks import CwdChangedHook, FileChangedHook +from .team_hooks import TaskCreatedHook, TaskCompletedHook, TeammateIdleHook +from .worktree_hooks import WorktreeCreateHook, WorktreeRemoveHook # ============================================================================= # Output Models @@ -56,6 +66,7 @@ PreToolUseOutput, StopOutput, PermissionRequestOutput, + PermissionDeniedOutput, ) # ============================================================================= @@ -76,6 +87,17 @@ PermissionRequestHook, NotificationHook, SetupHook, + InstructionsLoadedHook, + PermissionDeniedHook, + ElicitationHook, + ElicitationResultHook, + CwdChangedHook, + FileChangedHook, + TaskCreatedHook, + TaskCompletedHook, + TeammateIdleHook, + WorktreeCreateHook, + WorktreeRemoveHook, ] # Mapping from hook_event_name to class for dynamic parsing @@ -93,6 +115,18 @@ "PermissionRequest": PermissionRequestHook, "Notification": NotificationHook, "Setup": SetupHook, + # New in v2.1.83-v2.1.92 + "InstructionsLoaded": InstructionsLoadedHook, + "PermissionDenied": PermissionDeniedHook, + "Elicitation": ElicitationHook, + "ElicitationResult": ElicitationResultHook, + "CwdChanged": CwdChangedHook, + "FileChanged": FileChangedHook, + "TaskCreated": TaskCreatedHook, + "TaskCompleted": TaskCompletedHook, + "TeammateIdle": TeammateIdleHook, + "WorktreeCreate": WorktreeCreateHook, + "WorktreeRemove": WorktreeRemoveHook, } @@ -154,11 +188,24 @@ def parse_hook_event(data: Dict[str, Any]) -> HookEvent: "PermissionRequestHook", "NotificationHook", "SetupHook", + # New hook types (v2.1.83 - v2.1.92) + "InstructionsLoadedHook", + "PermissionDeniedHook", + "ElicitationHook", + "ElicitationResultHook", + "CwdChangedHook", + "FileChangedHook", + "TaskCreatedHook", + "TaskCompletedHook", + "TeammateIdleHook", + "WorktreeCreateHook", + "WorktreeRemoveHook", # Output Types "HookOutput", "PreToolUseOutput", "StopOutput", "PermissionRequestOutput", + "PermissionDeniedOutput", # Enums "PermissionMode", "HookEventName", diff --git a/captain-hook/src/captain_hook/base.py b/captain-hook/src/captain_hook/base.py index c413fc51..eed89c8a 100644 --- a/captain-hook/src/captain_hook/base.py +++ b/captain-hook/src/captain_hook/base.py @@ -29,19 +29,39 @@ class MyCustomHook(BaseHook): PermissionMode = Literal["default", "plan", "acceptEdits", "dontAsk", "bypassPermissions"] HookEventName = Literal[ + # Tool lifecycle "PreToolUse", "PostToolUse", "PostToolUseFailure", + # User interaction "UserPromptSubmit", + "PermissionRequest", + "PermissionDenied", + "Notification", + "Elicitation", + "ElicitationResult", + # Session lifecycle "SessionStart", "SessionEnd", + # Agent control "Stop", "SubagentStart", "SubagentStop", + # Context "PreCompact", - "PermissionRequest", - "Notification", + "InstructionsLoaded", + # Setup "Setup", + # Filesystem + "CwdChanged", + "FileChanged", + # Agent teams (experimental) + "TaskCreated", + "TaskCompleted", + "TeammateIdle", + # Worktree lifecycle + "WorktreeCreate", + "WorktreeRemove", ] SetupTrigger = Literal["init", "maintenance"] diff --git a/captain-hook/src/captain_hook/context_hooks.py b/captain-hook/src/captain_hook/context_hooks.py index d5f052b5..00bdd7b3 100644 --- a/captain-hook/src/captain_hook/context_hooks.py +++ b/captain-hook/src/captain_hook/context_hooks.py @@ -1,12 +1,12 @@ """ Context Management Hooks -Hook models for context-related events like compaction. +Hook models for context-related events like compaction and instruction loading. """ from __future__ import annotations -from typing import Literal +from typing import List, Literal, Optional from pydantic import Field from .base import BaseHook, PreCompactTrigger @@ -40,6 +40,46 @@ class PreCompactHook(BaseHook): ) +class InstructionsLoadedHook(BaseHook): + """CLAUDE.md or rules file loaded. Cannot block execution.""" + + hook_event_name: Literal["InstructionsLoaded"] = Field( + default="InstructionsLoaded", + description="Always 'InstructionsLoaded' for this hook type" + ) + + file_path: str = Field( + ..., + description="Path to the CLAUDE.md / rules file that was loaded" + ) + + memory_type: str = Field( + ..., + description="Category of memory loaded (e.g., 'project', 'user', 'plugin', 'managed')" + ) + + load_reason: str = Field( + ..., + description="Why the file was loaded (e.g., 'startup', 'import', 'glob_match')" + ) + + globs: List[str] = Field( + default_factory=list, + description="Glob patterns associated with the load (when triggered by globs)" + ) + + trigger_file_path: Optional[str] = Field( + default=None, + description="File that triggered the load (for imports/glob matches)" + ) + + parent_file_path: Optional[str] = Field( + default=None, + description="Parent CLAUDE.md that imported this one (for nested imports)" + ) + + __all__ = [ "PreCompactHook", + "InstructionsLoadedHook", ] diff --git a/captain-hook/src/captain_hook/fs_hooks.py b/captain-hook/src/captain_hook/fs_hooks.py new file mode 100644 index 00000000..e33101e7 --- /dev/null +++ b/captain-hook/src/captain_hook/fs_hooks.py @@ -0,0 +1,68 @@ +""" +Filesystem lifecycle hooks - CwdChanged, FileChanged. +""" + +from __future__ import annotations + +from typing import Literal +from pydantic import Field + +from .base import BaseHook + + +# ============================================================================= +# Filesystem Lifecycle Hooks +# ============================================================================= + +class CwdChangedHook(BaseHook): + """ + Fires when the working directory changes during a session. + + Cannot block - purely observational. + Use cases: tracking project navigation, environment switching, audit logs. + """ + + hook_event_name: Literal["CwdChanged"] = Field( + default="CwdChanged", + description="Always 'CwdChanged' for this hook type" + ) + + old_cwd: str = Field( + ..., + description="Previous working directory before the change" + ) + + new_cwd: str = Field( + ..., + description="New working directory after the change" + ) + + +class FileChangedHook(BaseHook): + """ + Fires when an external file change is detected. + + Cannot block - purely observational. + Use cases: cache invalidation, refresh prompts, change tracking. + """ + + hook_event_name: Literal["FileChanged"] = Field( + default="FileChanged", + description="Always 'FileChanged' for this hook type" + ) + + file_path: str = Field( + ..., + description="Absolute path to the file that changed" + ) + + file_name: str = Field( + ..., + description="Basename of the file that changed" + ) + + +__all__ = [ + "CwdChangedHook", + "FileChangedHook", +] diff --git a/captain-hook/src/captain_hook/outputs.py b/captain-hook/src/captain_hook/outputs.py index 2ec97613..2515c226 100644 --- a/captain-hook/src/captain_hook/outputs.py +++ b/captain-hook/src/captain_hook/outputs.py @@ -20,10 +20,14 @@ class PreToolUseOutput(HookOutput): class HookSpecificOutput(BaseModel): model_config = ConfigDict(extra="allow") - permission_decision: Optional[Literal["allow", "deny"]] = Field( + permission_decision: Optional[Literal["allow", "deny", "ask", "defer"]] = Field( default=None, alias="permissionDecision", - description="Auto-approve or deny the tool execution" + description=( + "Auto-approve, deny, ask the user, or defer the tool execution. " + "'defer' is used by the headless `-p --resume` pause/resume flow " + "to postpone a decision until the session is resumed." + ) ) permission_decision_reason: Optional[str] = Field( default=None, @@ -89,3 +93,24 @@ class HookSpecificOutput(BaseModel): default=None, alias="hookSpecificOutput" ) + + +class PermissionDeniedOutput(HookOutput): + """Output schema for PermissionDenied hooks. + + A PermissionDenied hook can request that Claude retry the tool call + (e.g., after fixing some external state) by returning ``{"retry": true}``. + """ + + class HookSpecificOutput(BaseModel): + model_config = ConfigDict(extra="allow") + + retry: bool = Field( + default=False, + description="If True, request that Claude retry the previously denied tool call" + ) + + hook_specific_output: Optional[HookSpecificOutput] = Field( + default=None, + alias="hookSpecificOutput" + ) diff --git a/captain-hook/src/captain_hook/team_hooks.py b/captain-hook/src/captain_hook/team_hooks.py new file mode 100644 index 00000000..9cfd9887 --- /dev/null +++ b/captain-hook/src/captain_hook/team_hooks.py @@ -0,0 +1,111 @@ +"""Agent Teams lifecycle hooks (experimental - gated on CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1).""" + +from __future__ import annotations + +from typing import Literal, Optional +from pydantic import Field + +from .base import BaseHook + + +# ============================================================================= +# Agent Teams Lifecycle Hooks +# ============================================================================= + +class TaskCreatedHook(BaseHook): + """Task created in an agent team. CAN block via {"continue": false}.""" + + hook_event_name: Literal["TaskCreated"] = Field( + default="TaskCreated", + description="Always 'TaskCreated' for this hook type" + ) + + task_id: str = Field( + ..., + description="Unique identifier for the created task" + ) + + task_subject: str = Field( + ..., + description="Short subject/title of the task" + ) + + task_description: Optional[str] = Field( + default=None, + description="Longer task description (if provided)" + ) + + teammate_name: Optional[str] = Field( + default=None, + description="Name of the teammate the task is assigned to (if any)" + ) + + team_name: str = Field( + ..., + description="Name of the team where the task was created" + ) + + +class TaskCompletedHook(BaseHook): + """Task completed in an agent team. CAN block via {"continue": false}.""" + + hook_event_name: Literal["TaskCompleted"] = Field( + default="TaskCompleted", + description="Always 'TaskCompleted' for this hook type" + ) + + task_id: str = Field( + ..., + description="Unique identifier for the completed task" + ) + + task_subject: str = Field( + ..., + description="Short subject/title of the task" + ) + + task_description: Optional[str] = Field( + default=None, + description="Longer task description (if provided)" + ) + + teammate_name: Optional[str] = Field( + default=None, + description="Name of the teammate that completed the task (if any)" + ) + + team_name: str = Field( + ..., + description="Name of the team where the task was completed" + ) + + +class TeammateIdleHook(BaseHook): + """Teammate became idle (awaiting task). CAN block via exit 2.""" + + hook_event_name: Literal["TeammateIdle"] = Field( + default="TeammateIdle", + description="Always 'TeammateIdle' for this hook type" + ) + + agent_id: str = Field( + ..., + description="Unique identifier for the idle agent" + ) + + agent_type: str = Field( + ..., + description="Type of the idle agent" + ) + + team_name: Optional[str] = Field( + default=None, + description="Name of the team the agent belongs to (if any)" + ) + + +__all__ = [ + "TaskCreatedHook", + "TaskCompletedHook", + "TeammateIdleHook", +] diff --git a/captain-hook/src/captain_hook/user_hooks.py b/captain-hook/src/captain_hook/user_hooks.py index e1c7c178..94b771df 100644 --- a/captain-hook/src/captain_hook/user_hooks.py +++ b/captain-hook/src/captain_hook/user_hooks.py @@ -1,12 +1,13 @@ """ User Interaction Hooks -Hook models for user-facing events: prompt submission, permission requests, and notifications. +Hook models for user-facing events: prompt submission, permission requests, +notifications, denials, and MCP elicitations. """ from __future__ import annotations -from typing import Literal, Optional +from typing import Any, Dict, Literal, Optional from pydantic import Field from .base import BaseHook @@ -83,8 +84,83 @@ class NotificationHook(BaseHook): ) +class PermissionDeniedHook(BaseHook): + """Auto mode denied a tool call. Cannot block execution.""" + + hook_event_name: Literal["PermissionDenied"] = Field( + default="PermissionDenied", + description="Always 'PermissionDenied' for this hook type" + ) + + tool_name: str = Field( + ..., + description="Name of the tool whose call was denied" + ) + + tool_use_id: str = Field( + ..., + description="Unique identifier for the denied tool invocation" + ) + + reason: str = Field( + ..., + description="Denial reason string explaining why the tool call was rejected" + ) + + tool_input: Dict[str, Any] = Field( + default_factory=dict, + description="Original input parameters of the denied tool call" + ) + + +class ElicitationHook(BaseHook): + """MCP server requested structured input from user. CAN block via exit 2.""" + + hook_event_name: Literal["Elicitation"] = Field( + default="Elicitation", + description="Always 'Elicitation' for this hook type" + ) + + mcp_server: str = Field( + ..., + description="Name of the MCP server making the elicitation request" + ) + + tool_name: str = Field( + ..., + description="Tool that triggered the elicitation request" + ) + + request: Dict[str, Any] = Field( + default_factory=dict, + description="The form/schema that the MCP server requested input for" + ) + + +class ElicitationResultHook(BaseHook): + """User responded to an MCP elicitation request. CAN block via exit 2.""" + + hook_event_name: Literal["ElicitationResult"] = Field( + default="ElicitationResult", + description="Always 'ElicitationResult' for this hook type" + ) + + mcp_server: str = Field( + ..., + description="Name of the MCP server that originally requested the elicitation" + ) + + user_response: Dict[str, Any] = Field( + default_factory=dict, + description="The structured response provided by the user" + ) + + __all__ = [ "UserPromptSubmitHook", "PermissionRequestHook", "NotificationHook", + "PermissionDeniedHook", + "ElicitationHook", + "ElicitationResultHook", ] diff --git a/captain-hook/src/captain_hook/worktree_hooks.py b/captain-hook/src/captain_hook/worktree_hooks.py new file mode 100644 index 00000000..4e250e3a --- /dev/null +++ b/captain-hook/src/captain_hook/worktree_hooks.py @@ -0,0 +1,58 @@ +""" +Worktree lifecycle hooks - WorktreeCreate, WorktreeRemove. +""" + +from __future__ import annotations + +from typing import Literal +from pydantic import Field + +from .base import BaseHook + + +# ============================================================================= +# Worktree Lifecycle Hooks +# ============================================================================= + +class WorktreeCreateHook(BaseHook): + """Worktree is about to be created. CAN override worktreePath via HTTP hook response.""" + + hook_event_name: Literal["WorktreeCreate"] = Field( + default="WorktreeCreate", + description="Always 'WorktreeCreate' for this hook type" + ) + + worktree_name: str = Field( + ..., + description="The chosen or generated name for the new worktree" + ) + + base_ref: str = Field( + ..., + description="Git ref the worktree branches from (e.g., 'main', 'origin/develop')" + ) + + +class WorktreeRemoveHook(BaseHook): + """Worktree removed. Cannot block execution.""" + + hook_event_name: Literal["WorktreeRemove"] = Field( + default="WorktreeRemove", + description="Always 'WorktreeRemove' for this hook type" + ) + + worktree_path: str = Field( + ..., + description="Absolute path to the worktree being removed" + ) + + worktree_name: str = Field( + ..., + description="Name of the worktree being removed" + ) + + +__all__ = [ + "WorktreeCreateHook", + "WorktreeRemoveHook", +] diff --git a/captain-hook/tests/test_models.py b/captain-hook/tests/test_models.py index 13faaeb6..12abb43a 100644 --- a/captain-hook/tests/test_models.py +++ b/captain-hook/tests/test_models.py @@ -35,11 +35,24 @@ PermissionRequestHook, NotificationHook, SetupHook, + # New hook types (v2.1.83 - v2.1.92) + InstructionsLoadedHook, + PermissionDeniedHook, + ElicitationHook, + ElicitationResultHook, + CwdChangedHook, + FileChangedHook, + TaskCreatedHook, + TaskCompletedHook, + TeammateIdleHook, + WorktreeCreateHook, + WorktreeRemoveHook, # Output Types HookOutput, PreToolUseOutput, StopOutput, PermissionRequestOutput, + PermissionDeniedOutput, ) @@ -168,6 +181,139 @@ def notification_data(base_hook_data) -> Dict[str, Any]: } +@pytest.fixture +def instructions_loaded_data(base_hook_data) -> Dict[str, Any]: + """Valid InstructionsLoaded hook data.""" + return { + **base_hook_data, + "hook_event_name": "InstructionsLoaded", + "file_path": "/Users/me/repo/CLAUDE.md", + "memory_type": "project", + "load_reason": "startup", + "globs": [], + } + + +@pytest.fixture +def permission_denied_data(base_hook_data) -> Dict[str, Any]: + """Valid PermissionDenied hook data.""" + return { + **base_hook_data, + "hook_event_name": "PermissionDenied", + "tool_name": "Bash", + "tool_use_id": "tool_denied_001", + "reason": "Auto mode policy: dangerous command", + "tool_input": {"command": "rm -rf /"}, + } + + +@pytest.fixture +def elicitation_data(base_hook_data) -> Dict[str, Any]: + """Valid Elicitation hook data.""" + return { + **base_hook_data, + "hook_event_name": "Elicitation", + "mcp_server": "github", + "tool_name": "create_issue", + "request": {"title": "string", "body": "string"}, + } + + +@pytest.fixture +def elicitation_result_data(base_hook_data) -> Dict[str, Any]: + """Valid ElicitationResult hook data.""" + return { + **base_hook_data, + "hook_event_name": "ElicitationResult", + "mcp_server": "github", + "user_response": {"title": "Bug report", "body": "Steps to reproduce..."}, + } + + +@pytest.fixture +def cwd_changed_data(base_hook_data) -> Dict[str, Any]: + """Valid CwdChanged hook data.""" + return { + **base_hook_data, + "hook_event_name": "CwdChanged", + "old_cwd": "/Users/me/old_project", + "new_cwd": "/Users/me/new_project", + } + + +@pytest.fixture +def file_changed_data(base_hook_data) -> Dict[str, Any]: + """Valid FileChanged hook data.""" + return { + **base_hook_data, + "hook_event_name": "FileChanged", + "file_path": "/Users/me/project/src/main.py", + "file_name": "main.py", + } + + +@pytest.fixture +def task_created_data(base_hook_data) -> Dict[str, Any]: + """Valid TaskCreated hook data.""" + return { + **base_hook_data, + "hook_event_name": "TaskCreated", + "task_id": "task_001", + "task_subject": "Refactor auth module", + "task_description": "Split auth.py into smaller files", + "teammate_name": "Alice", + "team_name": "core-team", + } + + +@pytest.fixture +def task_completed_data(base_hook_data) -> Dict[str, Any]: + """Valid TaskCompleted hook data.""" + return { + **base_hook_data, + "hook_event_name": "TaskCompleted", + "task_id": "task_002", + "task_subject": "Add tests", + "task_description": "Cover edge cases", + "teammate_name": "Bob", + "team_name": "core-team", + } + + +@pytest.fixture +def teammate_idle_data(base_hook_data) -> Dict[str, Any]: + """Valid TeammateIdle hook data.""" + return { + **base_hook_data, + "hook_event_name": "TeammateIdle", + "agent_id": "agent_idle_001", + "agent_type": "Explore", + "team_name": "core-team", + } + + +@pytest.fixture +def worktree_create_data(base_hook_data) -> Dict[str, Any]: + """Valid WorktreeCreate hook data.""" + return { + **base_hook_data, + "hook_event_name": "WorktreeCreate", + "worktree_name": "feat-auth", + "base_ref": "main", + } + + +@pytest.fixture +def worktree_remove_data(base_hook_data) -> Dict[str, Any]: + """Valid WorktreeRemove hook data.""" + return { + **base_hook_data, + "hook_event_name": "WorktreeRemove", + "worktree_path": "/Users/me/.claude-worktrees/repo/feat-auth", + "worktree_name": "feat-auth", + } + + # ============================================================================= # Test Data Registry (for parametrized tests) # ============================================================================= @@ -238,6 +384,69 @@ def notification_data(base_hook_data) -> Dict[str, Any]: "hook_event_name": "Setup", "trigger": "init", }, + # New in v2.1.83 - v2.1.92 + "InstructionsLoaded": { + "hook_event_name": "InstructionsLoaded", + "file_path": "/Users/me/repo/CLAUDE.md", + "memory_type": "project", + "load_reason": "startup", + "globs": [], + }, + "PermissionDenied": { + "hook_event_name": "PermissionDenied", + "tool_name": "Write", + "tool_use_id": "tool_denied_002", + "reason": "Auto mode policy: protected path", + "tool_input": {"file_path": "/etc/passwd"}, + }, + "Elicitation": { + "hook_event_name": "Elicitation", + "mcp_server": "linear", + "tool_name": "create_issue", + "request": {"title": "string"}, + }, + "ElicitationResult": { + "hook_event_name": "ElicitationResult", + "mcp_server": "linear", + "user_response": {"title": "Bug"}, + }, + "CwdChanged": { + "hook_event_name": "CwdChanged", + "old_cwd": "/Users/me/old", + "new_cwd": "/Users/me/new", + }, + "FileChanged": { + "hook_event_name": "FileChanged", + "file_path": "/Users/me/project/file.py", + "file_name": "file.py", + }, + "TaskCreated": { + "hook_event_name": "TaskCreated", + "task_id": "task_test_001", + "task_subject": "Add login flow", + "team_name": "frontend-team", + }, + "TaskCompleted": { + "hook_event_name": "TaskCompleted", + "task_id": "task_test_002", + "task_subject": "Add login flow", + "team_name": "frontend-team", + }, + "TeammateIdle": { + "hook_event_name": "TeammateIdle", + "agent_id": "agent_test_idle", + "agent_type": "Explore", + }, + "WorktreeCreate": { + "hook_event_name": "WorktreeCreate", + "worktree_name": "feat-x", + "base_ref": "main", + }, + "WorktreeRemove": { + "hook_event_name": "WorktreeRemove", + "worktree_path": "/Users/me/.claude-worktrees/repo/feat-x", + "worktree_name": "feat-x", + }, } @@ -455,6 +664,286 @@ def test_notification_types(self, notification_data, notification_type): assert hook.notification_type == notification_type +# ============================================================================= +# 3b. New Hook Type Tests (v2.1.83 - v2.1.92) +# ============================================================================= + +class TestInstructionsLoadedHook: + """Tests specific to InstructionsLoaded hook.""" + + def test_basic_instantiation(self, instructions_loaded_data): + """All required fields produce a valid hook.""" + hook = InstructionsLoadedHook.model_validate(instructions_loaded_data) + assert hook.file_path == "/Users/me/repo/CLAUDE.md" + assert hook.memory_type == "project" + assert hook.load_reason == "startup" + assert hook.globs == [] + assert hook.trigger_file_path is None + assert hook.parent_file_path is None + + def test_round_trip_via_parser(self, instructions_loaded_data): + """Hook round-trips through parse_hook_event.""" + hook = parse_hook_event(instructions_loaded_data) + assert isinstance(hook, InstructionsLoadedHook) + assert hook.hook_event_name == "InstructionsLoaded" + + def test_extra_fields_preserved(self, instructions_loaded_data): + """Unknown fields are preserved (forward compat).""" + instructions_loaded_data["future_field"] = {"x": 1} + hook = InstructionsLoadedHook.model_validate(instructions_loaded_data) + assert hook.future_field == {"x": 1} + + def test_with_imports(self, instructions_loaded_data): + """Imported CLAUDE.md files include trigger/parent paths.""" + instructions_loaded_data["load_reason"] = "import" + instructions_loaded_data["trigger_file_path"] = "/Users/me/repo/CLAUDE.md" + instructions_loaded_data["parent_file_path"] = "/Users/me/repo/CLAUDE.md" + hook = InstructionsLoadedHook.model_validate(instructions_loaded_data) + assert hook.load_reason == "import" + assert hook.trigger_file_path == "/Users/me/repo/CLAUDE.md" + + +class TestPermissionDeniedHook: + """Tests specific to PermissionDenied hook.""" + + def test_basic_instantiation(self, permission_denied_data): + """All required fields produce a valid hook.""" + hook = PermissionDeniedHook.model_validate(permission_denied_data) + assert hook.tool_name == "Bash" + assert hook.tool_use_id == "tool_denied_001" + assert "dangerous" in hook.reason + assert hook.tool_input["command"] == "rm -rf /" + + def test_round_trip_via_parser(self, permission_denied_data): + """Hook round-trips through parse_hook_event.""" + hook = parse_hook_event(permission_denied_data) + assert isinstance(hook, PermissionDeniedHook) + assert hook.hook_event_name == "PermissionDenied" + + def test_extra_fields_preserved(self, permission_denied_data): + """Unknown fields are preserved (forward compat).""" + permission_denied_data["denial_id"] = "den_xyz" + hook = PermissionDeniedHook.model_validate(permission_denied_data) + assert hook.denial_id == "den_xyz" + + +class TestElicitationHook: + """Tests specific to Elicitation hook.""" + + def test_basic_instantiation(self, elicitation_data): + """All required fields produce a valid hook.""" + hook = ElicitationHook.model_validate(elicitation_data) + assert hook.mcp_server == "github" + assert hook.tool_name == "create_issue" + assert hook.request == {"title": "string", "body": "string"} + + def test_round_trip_via_parser(self, elicitation_data): + """Hook round-trips through parse_hook_event.""" + hook = parse_hook_event(elicitation_data) + assert isinstance(hook, ElicitationHook) + assert hook.hook_event_name == "Elicitation" + + def test_extra_fields_preserved(self, elicitation_data): + """Unknown fields are preserved (forward compat).""" + elicitation_data["request_id"] = "elicit_123" + hook = ElicitationHook.model_validate(elicitation_data) + assert hook.request_id == "elicit_123" + + +class TestElicitationResultHook: + """Tests specific to ElicitationResult hook.""" + + def test_basic_instantiation(self, elicitation_result_data): + """All required fields produce a valid hook.""" + hook = ElicitationResultHook.model_validate(elicitation_result_data) + assert hook.mcp_server == "github" + assert hook.user_response["title"] == "Bug report" + + def test_round_trip_via_parser(self, elicitation_result_data): + """Hook round-trips through parse_hook_event.""" + hook = parse_hook_event(elicitation_result_data) + assert isinstance(hook, ElicitationResultHook) + assert hook.hook_event_name == "ElicitationResult" + + def test_extra_fields_preserved(self, elicitation_result_data): + """Unknown fields are preserved (forward compat).""" + elicitation_result_data["request_id"] = "elicit_456" + hook = ElicitationResultHook.model_validate(elicitation_result_data) + assert hook.request_id == "elicit_456" + + +class TestCwdChangedHook: + """Tests specific to CwdChanged hook.""" + + def test_basic_instantiation(self, cwd_changed_data): + """All required fields produce a valid hook.""" + hook = CwdChangedHook.model_validate(cwd_changed_data) + assert hook.old_cwd == "/Users/me/old_project" + assert hook.new_cwd == "/Users/me/new_project" + + def test_round_trip_via_parser(self, cwd_changed_data): + """Hook round-trips through parse_hook_event.""" + hook = parse_hook_event(cwd_changed_data) + assert isinstance(hook, CwdChangedHook) + assert hook.hook_event_name == "CwdChanged" + + def test_extra_fields_preserved(self, cwd_changed_data): + """Unknown fields are preserved (forward compat).""" + cwd_changed_data["change_source"] = "cd_command" + hook = CwdChangedHook.model_validate(cwd_changed_data) + assert hook.change_source == "cd_command" + + +class TestFileChangedHook: + """Tests specific to FileChanged hook.""" + + def test_basic_instantiation(self, file_changed_data): + """All required fields produce a valid hook.""" + hook = FileChangedHook.model_validate(file_changed_data) + assert hook.file_path == "/Users/me/project/src/main.py" + assert hook.file_name == "main.py" + + def test_round_trip_via_parser(self, file_changed_data): + """Hook round-trips through parse_hook_event.""" + hook = parse_hook_event(file_changed_data) + assert isinstance(hook, FileChangedHook) + assert hook.hook_event_name == "FileChanged" + + def test_extra_fields_preserved(self, file_changed_data): + """Unknown fields are preserved (forward compat).""" + file_changed_data["change_type"] = "modified" + hook = FileChangedHook.model_validate(file_changed_data) + assert hook.change_type == "modified" + + +class TestTaskCreatedHook: + """Tests specific to TaskCreated hook.""" + + def test_basic_instantiation(self, task_created_data): + """All required fields produce a valid hook.""" + hook = TaskCreatedHook.model_validate(task_created_data) + assert hook.task_id == "task_001" + assert hook.task_subject == "Refactor auth module" + assert hook.team_name == "core-team" + assert hook.teammate_name == "Alice" + + def test_round_trip_via_parser(self, task_created_data): + """Hook round-trips through parse_hook_event.""" + hook = parse_hook_event(task_created_data) + assert isinstance(hook, TaskCreatedHook) + assert hook.hook_event_name == "TaskCreated" + + def test_extra_fields_preserved(self, task_created_data): + """Unknown fields are preserved (forward compat).""" + task_created_data["priority"] = "high" + hook = TaskCreatedHook.model_validate(task_created_data) + assert hook.priority == "high" + + def test_optional_fields_default_none(self, task_created_data): + """Optional fields default to None when omitted.""" + del task_created_data["task_description"] + del task_created_data["teammate_name"] + hook = TaskCreatedHook.model_validate(task_created_data) + assert hook.task_description is None + assert hook.teammate_name is None + + +class TestTaskCompletedHook: + """Tests specific to TaskCompleted hook.""" + + def test_basic_instantiation(self, task_completed_data): + """All required fields produce a valid hook.""" + hook = TaskCompletedHook.model_validate(task_completed_data) + assert hook.task_id == "task_002" + assert hook.task_subject == "Add tests" + assert hook.team_name == "core-team" + + def test_round_trip_via_parser(self, task_completed_data): + """Hook round-trips through parse_hook_event.""" + hook = parse_hook_event(task_completed_data) + assert isinstance(hook, TaskCompletedHook) + assert hook.hook_event_name == "TaskCompleted" + + def test_extra_fields_preserved(self, task_completed_data): + """Unknown fields are preserved (forward compat).""" + task_completed_data["completion_time_ms"] = 12345 + hook = TaskCompletedHook.model_validate(task_completed_data) + assert hook.completion_time_ms == 12345 + + +class TestTeammateIdleHook: + """Tests specific to TeammateIdle hook.""" + + def test_basic_instantiation(self, teammate_idle_data): + """All required fields produce a valid hook.""" + hook = TeammateIdleHook.model_validate(teammate_idle_data) + assert hook.agent_id == "agent_idle_001" + assert hook.agent_type == "Explore" + assert hook.team_name == "core-team" + + def test_round_trip_via_parser(self, teammate_idle_data): + """Hook round-trips through parse_hook_event.""" + hook = parse_hook_event(teammate_idle_data) + assert isinstance(hook, TeammateIdleHook) + assert hook.hook_event_name == "TeammateIdle" + + def test_extra_fields_preserved(self, teammate_idle_data): + """Unknown fields are preserved (forward compat).""" + teammate_idle_data["idle_seconds"] = 30 + hook = TeammateIdleHook.model_validate(teammate_idle_data) + assert hook.idle_seconds == 30 + + def test_optional_team_name(self, teammate_idle_data): + """team_name is optional.""" + del teammate_idle_data["team_name"] + hook = TeammateIdleHook.model_validate(teammate_idle_data) + assert hook.team_name is None + + +class TestWorktreeCreateHook: + """Tests specific to WorktreeCreate hook.""" + + def test_basic_instantiation(self, worktree_create_data): + """All required fields produce a valid hook.""" + hook = WorktreeCreateHook.model_validate(worktree_create_data) + assert hook.worktree_name == "feat-auth" + assert hook.base_ref == "main" + + def test_round_trip_via_parser(self, worktree_create_data): + """Hook round-trips through parse_hook_event.""" + hook = parse_hook_event(worktree_create_data) + assert isinstance(hook, WorktreeCreateHook) + assert hook.hook_event_name == "WorktreeCreate" + + def test_extra_fields_preserved(self, worktree_create_data): + """Unknown fields are preserved (forward compat).""" + worktree_create_data["requested_path"] = "/tmp/wt" + hook = WorktreeCreateHook.model_validate(worktree_create_data) + assert hook.requested_path == "/tmp/wt" + + +class TestWorktreeRemoveHook: + """Tests specific to WorktreeRemove hook.""" + + def test_basic_instantiation(self, worktree_remove_data): + """All required fields produce a valid hook.""" + hook = WorktreeRemoveHook.model_validate(worktree_remove_data) + assert hook.worktree_path == "/Users/me/.claude-worktrees/repo/feat-auth" + assert hook.worktree_name == "feat-auth" + + def test_round_trip_via_parser(self, worktree_remove_data): + """Hook round-trips through parse_hook_event.""" + hook = parse_hook_event(worktree_remove_data) + assert isinstance(hook, WorktreeRemoveHook) + assert hook.hook_event_name == "WorktreeRemove" + + def test_extra_fields_preserved(self, worktree_remove_data): + """Unknown fields are preserved (forward compat).""" + worktree_remove_data["removed_at"] = "2026-04-06T12:00:00Z" + hook = WorktreeRemoveHook.model_validate(worktree_remove_data) + assert hook.removed_at == "2026-04-06T12:00:00Z" + + # ============================================================================= # 4. Parser Function Tests # ============================================================================= @@ -618,6 +1107,51 @@ def test_output_extra_fields(self): }) assert output.hook_specific_output.future_field == "future_value" + def test_pre_tool_use_output_defer(self): + """PreToolUseOutput supports the new 'defer' decision (headless -p --resume).""" + output = PreToolUseOutput.model_validate({ + "hookSpecificOutput": { + "permissionDecision": "defer", + "permissionDecisionReason": "Awaiting headless resume", + } + }) + assert output.hook_specific_output.permission_decision == "defer" + + def test_pre_tool_use_output_ask(self): + """PreToolUseOutput supports the 'ask' decision.""" + output = PreToolUseOutput.model_validate({ + "hookSpecificOutput": { + "permissionDecision": "ask", + } + }) + assert output.hook_specific_output.permission_decision == "ask" + + def test_permission_denied_output_default(self): + """PermissionDeniedOutput defaults retry to False.""" + output = PermissionDeniedOutput.model_validate({ + "hookSpecificOutput": {} + }) + assert output.hook_specific_output.retry is False + + def test_permission_denied_output_retry(self): + """PermissionDeniedOutput can request retry.""" + output = PermissionDeniedOutput.model_validate({ + "hookSpecificOutput": { + "retry": True, + } + }) + assert output.hook_specific_output.retry is True + + def test_permission_denied_output_extra_fields(self): + """PermissionDeniedOutput accepts extra fields.""" + output = PermissionDeniedOutput.model_validate({ + "hookSpecificOutput": { + "retry": True, + "future_field": "value", + } + }) + assert output.hook_specific_output.future_field == "value" + # ============================================================================= # 7. HOOK_TYPE_MAP Registry Tests @@ -627,7 +1161,7 @@ class TestHookTypeMap: """Tests for the HOOK_TYPE_MAP registry.""" def test_all_hooks_registered(self): - """All 13 hook types are in the registry.""" + """All 24 hook types are in the registry.""" expected_hooks = { "PreToolUse", "PostToolUse", @@ -642,8 +1176,21 @@ def test_all_hooks_registered(self): "PermissionRequest", "Notification", "Setup", + # New in v2.1.83 - v2.1.92 + "InstructionsLoaded", + "PermissionDenied", + "Elicitation", + "ElicitationResult", + "CwdChanged", + "FileChanged", + "TaskCreated", + "TaskCompleted", + "TeammateIdle", + "WorktreeCreate", + "WorktreeRemove", } assert set(HOOK_TYPE_MAP.keys()) == expected_hooks + assert len(HOOK_TYPE_MAP) == 24 def test_registry_values_are_classes(self): """All registry values are BaseHook subclasses.""" diff --git a/docs/features/2026-04-06-claude-code-audit-action-items.md b/docs/features/2026-04-06-claude-code-audit-action-items.md new file mode 100644 index 00000000..61a97195 --- /dev/null +++ b/docs/features/2026-04-06-claude-code-audit-action-items.md @@ -0,0 +1,874 @@ +# Feature Definition: Claude Code Audit — Remediation Action Items (v2.1.81 → v2.1.92) + +**Status**: Planning +**Date**: 2026-04-06 +**Audit Window**: 2026-03-20 → 2026-04-06 +**Last Tracked Release**: v2.1.92 (2026-04-04) + +--- + +## Section 1: Scope & Remediation Goals + +### Purpose + +Claude Code shipped 9 releases between 2026-03-20 and 2026-04-06 (v2.1.81 through v2.1.92). The dashboard's main branch remained at the 2026-03-20 baseline, creating a 17-day drift. An audit via `claude-code-guide` identified 14 action items across storage discovery, hook expansion, tool recognition, settings parsing, and edge case handling. Zero JSONL schema breaking changes occurred, but multiple additive features and one regression in already-shipped code require remediation. This document tracks the sprint across three priority tiers. + +### Goals + +- Add 11 new captain-hook event types to the Pydantic models and hook parser +- Discover and display Agent Teams storage (`~/.claude/teams/`) and task context +- Detect and badge scripted `--bare` mode sessions to eliminate "empty session" false positives +- Extend tool recognition to PowerShell and Agent Teams tools (SendMessage, TaskCreate, etc.) +- Parse frontmatter additions: subagent `initialPrompt`, skill `shell: powershell`, skill `paths: [list]` +- Merge and display `~/.claude/managed-settings.d/` fragments with per-fragment source attribution +- Preserve and render MCP tool result size metadata (`_meta["anthropic/maxResultSizeChars"]`) +- Expand settings.json parser to handle 14 new fields introduced in the audit window +- Ensure session resumption handles tool_result blocks from the v2.1.85–v2.1.91 regression window +- Deliver complete test coverage for all new code paths with passing CI + +### Not In Scope + +- Rewriting any existing parser from scratch +- Backporting fixes to older Claude Code versions (< v2.1.81) +- Implementing the `if` field on hook matchers in captain-hook (belongs in settings parser, not hook models) +- Dashboard UI redesign or breaking UI changes +- Syncthing sync architecture overhaul (separate workstream) +- Audio recording, webcam, or camera tool support +- Streaming response ingestion or incremental JSONL appending + +--- + +## Section 2: Findings Summary + +| Tier | Count | Affected Subsystems | Representative Finding | +|------|-------|---------------------|--------------------------| +| **Tier 1 (Blockers)** | 4 | captain-hook, JSONL merge, Teams discovery, session detection | `[Image #N]` regression blocks timeline rendering; 11 new hook types unrecognized; Teams storage discovery absent | +| **Tier 2 (High Priority)** | 7 | Tool recognition, frontmatter parsing, settings expansion, MCP metadata | PowerShell tool unsupported; 14 new settings fields unparsed; managed-settings.d fragments not merged | +| **Tier 3 (Experimental/Polish)** | 3 | Extended thinking, hook settings, session resumption hardening | Thinking summary visibility requires settings flag; resumption edge cases across version boundary | + +--- + +## Section 3: Vocabulary + +| Term | Definition | NOT the Same As | +|------|-----------|-----------------| +| **Hook event** | A discrete action fired by Claude Code (SessionStart, PreToolUse, SessionEnd, etc.). Modeled in captain-hook. | **Hook matcher** — a conditional rule in settings.json that filters when hooks run. Different layer. | +| **`[Image #N]` marker** | Integer suffix on MessageAttachment for images, e.g., `[Image 1]`. Appears in merged messages. | **ImageAttachment** — the Pydantic model representing an image object with mime_type and data. | +| **Agent Team** | A multi-user collaboration workspace. Storage: `~/.claude/teams/{team-name}/config.json` and `~/.claude/tasks/{team-name}/`. | **Subagent** — a single-user task agent spawned within a session. Storage: `~/.claude/projects/.../subagents/`. | +| **Tool name** | Recognized tool from Bash, Read, Write, SearchCode (API layer). E.g., "PowerShell" or "TaskCreate". | **Plugin-namespaced tool** — @plugin/toolname notation for plugin tools. Different discovery and execution model. | +| **managed-settings.d** | Directory fragment pattern: `~/.claude/managed-settings.d/*.json`. Last-write-wins merge semantics. | **settings.json** — monolithic user settings at `~/.claude/settings.json`. No fragment support. | +| **Bare mode session** | Scripted invocation with `--bare` flag. No hook events, no skill invocations. Appears "empty" to dashboard. | **Minimal session** — a session with few events but normal initialization/termination. Has hook events. | + +--- + +## Section 4: Tier 1 — Blockers (Sprint 1) + +### 4.1 `[Image #N]` JSONL Merge Regression + +**Problem**: Commit `272f506` on branch `enhance/timeline-updates` fixed the `[Image #N]` marker regression where image references were incorrectly merged. This fix must be listed as shipped so readers understand the timeline. + +**Cite**: Claude Code v2.1.85–v2.1.86 regression window. Fix landed before audit. + +**Affected Files** + +| File | Change Type | Workstream | Notes | +|------|-------------|-----------|-------| +| `api/models/jsonl_utils.py` | Modify | Parser/Merge | Drop `[Image #N]` markers during JSONL merge (already fixed, commit 272f506) | +| `api/tests/test_jsonl_utils.py` | Modify | Test | Add test_merge_drops_image_hash_number_marker (already present) | + +**Steps** + +| # | Action | Verification | +|---|--------|--------------| +| 1 | Confirm commit 272f506 is on main | `git log --oneline \| grep "272f506"` should show commit | +| 2 | Verify test exists and passes | `pytest tests/test_jsonl_utils.py::TestMessageMerging -v` should PASS | +| 3 | Spot-check JSONL merge in sample | Read a merged message, confirm `[Image #N]` absent from final output | + +**Postconditions** + +- [ ] Commit 272f506 verified on branch +- [ ] All merge regression tests passing +- [ ] No `[Image #N]` artifacts in merged JSONL + +--- + +### 4.2 Captain-Hook Library Expansion + +**Problem**: Claude Code v2.1.82–v2.1.92 introduced 11 new hook event types. captain-hook models only 10 hook types (PreToolUse, PostToolUse, etc.). Parser fails silently or raises on unrecognized events. + +**Cite**: Claude Code v2.1.82 (InstructionsLoaded), v2.1.84 (CwdChanged, FileChanged), v2.1.86 (PermissionDenied, TaskCreated), v2.1.87 (TaskCompleted, TeammateIdle), v2.1.88 (WorktreeCreate, WorktreeRemove), v2.1.90 (Elicitation, ElicitationResult). + +**Affected Files** + +| File | Change Type | Workstream | Notes | +|------|-------------|-----------|-------| +| `captain-hook/models/hooks.py` | Modify | Expansion | Add 11 new Pydantic event models | +| `captain-hook/models/__init__.py` | Modify | Expansion | Export new event types | +| `captain-hook/parser.py` | Modify | Expansion | Update parse_hook_event() to dispatch to new types | +| `captain-hook/tests/test_models.py` | Modify | Test | Add round-trip tests for all 21 event types | + +**New Hook Event Types** (detailed specs {needs clarification — awaiting official hook schema docs}) + +| Hook Type | Fires | Input Schema | Output Schema | Can Block? | +|-----------|-------|-------------|---------------|-----------| +| InstructionsLoaded | System instructions loaded into context | `{ "instructions": [...] }` | N/A | No | +| CwdChanged | Working directory changed | `{ "old_cwd": str, "new_cwd": str }` | N/A | No | +| FileChanged | File created/modified outside session | `{ "path": str, "action": "create"\|"modify" }` | N/A | No | +| PermissionDenied | Tool execution blocked by policy | `{ "tool_name": str, "reason": str }` | N/A | No | +| TaskCreated | New task spawned in Agent Teams | `{ "task_id": str, "title": str }` | N/A | No | +| TaskCompleted | Task marked complete | `{ "task_id": str, "status": str }` | N/A | No | +| TeammateIdle | Teammate inactive for threshold | `{ "member_id": str, "duration_seconds": int }` | N/A | No | +| WorktreeCreate | Git worktree created for session | `{ "worktree_path": str, "branch": str }` | N/A | No | +| WorktreeRemove | Git worktree cleaned up | `{ "worktree_path": str }` | N/A | No | +| Elicitation | LLM requesting user info | `{ "prompt": str, "field_name": str }` | `{ "response": str }` | Yes | +| ElicitationResult | User provided elicitation response | `{ "field_name": str, "response": str }` | N/A | No | + +**Additional Changes** + +- Modify `PreToolUseOutput.permissionDecision` Literal to include `"defer"` value +- Add `PermissionDeniedOutput` class (output schema for PermissionDenied hook when it can block) + +**Steps** + +| # | Action | Verification | +|---|--------|--------------| +| 1 | Create new Pydantic models for each hook | Each model has BaseModel + ConfigDict(frozen=True) | +| 2 | Update parser dispatch logic | parse_hook_event() routes each event_type string to correct class | +| 3 | Add round-trip tests | For each new hook: parse JSON → model → json, compare | +| 4 | Test existing hooks still parse | Regression: all 10 original hook types work unchanged | +| 5 | Run full test suite | `pytest captain-hook/tests/ -v` all green | + +**Postconditions** + +- [ ] All 21 hook types (10 original + 11 new) parse correctly +- [ ] Round-trip JSON serialization works for all types +- [ ] Zero regressions on existing 10 hook types +- [ ] Full test coverage for new types + +--- + +### 4.3 Agent Teams Storage Discovery + +**Problem**: Claude Code v2.1.85+ stores multi-user team context in `~/.claude/teams/`. Dashboard has zero awareness of teams. Sessions spawned within teams show no team affiliation or task context in UI. + +**Cite**: Claude Code v2.1.85 (Agent Teams feature launch). + +**Affected Files** + +| File | Change Type | Workstream | Notes | +|------|-------------|-----------|-------| +| `api/models/team.py` | Create | Models | Team, Task, TeamMember Pydantic models | +| `api/routers/teams.py` | Create | Routers | Endpoints for team discovery and context | +| `api/utils.py` | Modify | Utils | Add team discovery and validation logic | +| `frontend/src/routes/teams/+page.svelte` | Create | Frontend | Team listing and browser view | +| `frontend/src/routes/teams/[team_name]/+page.svelte` | Create | Frontend | Team detail with tasks | +| `frontend/src/lib/components/TeamCard.svelte` | Create | Components | Reusable team summary card | + +**Storage Locations to Discover** + +``` +~/.claude/teams/{team-name}/ + config.json # Team metadata, members, permissions + settings.json # Team-specific settings (optional) + +~/.claude/tasks/{team-name}/ + {task-id}.json # Individual task definition + status +``` + +**Steps** + +| # | Action | Verification | +|---|--------|--------------| +| 1 | Scan `~/.claude/teams/` for team directories | If absent, teams UI hidden; if present, list teams | +| 2 | Parse team config.json and task definitions | Load into Pydantic models | +| 3 | Link sessions to teams via metadata correlation | Check JSONL session metadata for team_id field | +| 4 | Create API endpoints for team listing and detail | /teams, /teams/{name}, /teams/{name}/tasks | +| 5 | Build frontend team browser | Replicate project/session browser UX for teams | +| 6 | Add team context to session card (optional) | Show team affiliation badge if applicable | + +**Postconditions** + +- [ ] Teams directory discovery works when present, degrades gracefully when absent +- [ ] All team metadata parsed into models without errors +- [ ] API endpoints return 200 for valid teams, 404 for missing teams +- [ ] Frontend team browser renders correctly with team cards +- [ ] Sessions show team affiliation when applicable + +--- + +### 4.4 `--bare` Mode Session Detection + +**Problem**: Claude Code supports `--bare` flag for scripted sessions (no GUI, no interactive hooks). These sessions produce zero hook events and zero skill invocations. Dashboard classifies them as "empty" or "corrupt" instead of "bare mode". Users see warnings/errors instead of understanding the mode. + +**Cite**: Claude Code v2.1.80+ (bare mode flag support, no specific hook added for it). + +**Affected Files** + +| File | Change Type | Workstream | Notes | +|------|-------------|-----------|-------| +| `api/models/session.py` | Modify | Models | Add `is_bare_mode: bool` property | +| `api/utils.py` | Modify | Utils | Detect bare mode by absence of hook events + skill invocations | +| `frontend/src/lib/components/SessionCard.svelte` | Modify | Components | Render "Bare mode" badge instead of "Empty" warning | +| `frontend/src/lib/utils/session.ts` | Modify | Utils | Add is_bare_mode() helper for frontend logic | + +**Detection Logic** + +A session is bare mode if ALL of: +- Session has > 0 messages (not completely empty) +- Zero PostToolUse hook events (no tool invocations tracked) +- Zero skill invocation references in JSONL +- Session has normal start/end markers (not corrupted) + +**Steps** + +| # | Action | Verification | +|---|--------|--------------| +| 1 | Implement is_bare_mode() detection in Session model | Read first 10 messages, check for hook absence | +| 2 | Add is_bare_mode property to session metadata | Expose in API responses | +| 3 | Update session card to check is_bare_mode | If true, show "Bare mode" badge; if false, proceed normally | +| 4 | Test with sample bare-mode JSONL file | Verify detection triggers correctly | +| 5 | Run regression on normal sessions | Ensure no false positives | + +**Postconditions** + +- [ ] Bare mode sessions detected and badged correctly +- [ ] No false positives on normal sessions with few events +- [ ] Session card displays "Bare mode" instead of warnings +- [ ] API includes is_bare_mode in session detail response + +--- + +## Section 5: Tier 2 — High Priority (Sprint 2) + +### 5.1 PowerShell Tool Support + +**Problem**: Claude Code v2.1.88+ supports PowerShell as a first-class tool (alongside Bash). Dashboard's `get_tool_summary()` in `api/utils.py` only recognizes Bash, classifying PowerShell invocations as unknown. + +**Cite**: Claude Code v2.1.88 (PowerShell tool launch). + +**Affected Files** + +| File | Change Type | Workstream | Notes | +|------|-------------|-----------|-------| +| `api/utils.py` | Modify | Utils | Add PowerShell to recognized tools in get_tool_summary() | +| `api/models/tool_result.py` | Modify | Models | Ensure tool_result.tool_name handles "PowerShell" string | +| `frontend/src/lib/components/timeline/TimelineEventCard.svelte` | Modify | Components | Add PowerShell icon/badge distinct from Bash | +| `frontend/src/routes/tools/+page.svelte` | Modify | Routes | Split tool stats: Bash vs PowerShell columns | +| `api/tests/test_utils.py` | Modify | Test | Add test_powershell_tool_recognized | + +**Steps** + +| # | Action | Verification | +|---|--------|--------------| +| 1 | Update tool_name recognition to accept "PowerShell" | get_tool_summary() includes PowerShell in known_tools | +| 2 | Add PowerShell icon to timeline (lucide-svelte: Terminal, Shield, or custom) | Visual distinction from Bash icon | +| 3 | Update /tools route analytics to split Bash and PowerShell | Two separate rows in tool breakdown table | +| 4 | Test with sample session containing PowerShell invocations | Verify correct categorization | +| 5 | Check backward compat (sessions with only Bash) | No regressions | + +**Postconditions** + +- [ ] PowerShell invocations recognized and categorized +- [ ] Timeline shows PowerShell tool calls with correct icon +- [ ] /tools route splits Bash and PowerShell statistics +- [ ] No regressions on existing Bash handling + +--- + +### 5.2 Agent Teams Tools Recognition + +**Problem**: Claude Code v2.1.86+ treats Agent Teams operations (SendMessage, TeamCreate, TaskCreate, TaskUpdate, etc.) as first-class "tools" in the timeline. Dashboard's tool parser sees them as unknown or ignores them. + +**Cite**: Claude Code v2.1.86 (TaskCreate mapping via PR #55). v2.1.87+ (SendMessage, TeamCreate, TaskDelete, TaskUpdate, TaskRead). + +**Affected Files** + +| File | Change Type | Workstream | Notes | +|------|-------------|-----------|-------| +| `api/utils.py` | Modify | Utils | Extend get_tool_summary() to recognize Teams tools | +| `api/models/tool_result.py` | Modify | Models | Add tool_result variants for Teams operations | +| `frontend/src/lib/components/timeline/TimelineEventCard.svelte` | Modify | Components | Render Teams tool cards (SendMessage shows message preview, etc.) | + +**Recognized Teams Tools** + +| Tool | Semantic | Appears As | Input Schema | Notes | +|------|----------|-----------|--------------|-------| +| SendMessage | Async communication | "→ {recipient}" | `{ "member_id": str, "message": str }` | Teammate messaging | +| TeamCreate | Collaboration setup | "👥 Create team" | `{ "team_name": str, "members": [...] }` | New team formation | +| TeamDelete | Team removal | "👥 Delete team" | `{ "team_id": str }` | Decommission team | +| TaskCreate | Work unit creation | "✓ Create task" | `{ "title": str, "assignee": str, "description": str }` | New task (already mapped in PR #55) | +| TaskUpdate | Task mutation | "✓ Update task" | `{ "task_id": str, "status": str }` | Status/assignment changes | +| TaskRead | Task query | "✓ Read task" | `{ "task_id": str }` | Metadata fetch | + +**Steps** + +| # | Action | Verification | +|---|--------|--------------| +| 1 | Update tool_name recognition to include all Teams tools | get_tool_summary() covers all 6 tools | +| 2 | Extend timeline event rendering to handle Teams operations | Different card template per Teams tool | +| 3 | Add Teams tool icons (bits-ui icons or custom SVG) | Visual distinction for each operation | +| 4 | Test with sample session containing Teams tool calls | Verify correct parsing and rendering | +| 5 | Verify TaskCreate mapping from PR #55 still works | No regression on already-shipped feature | + +**Postconditions** + +- [ ] All 6 Teams tools recognized in timeline +- [ ] Timeline cards render Teams operations with semantic clarity +- [ ] No regressions on TaskCreate mapping from PR #55 +- [ ] /tools route includes Teams operations in tool breakdown + +--- + +### 5.3 Subagent `initialPrompt` Frontmatter Field + +**Problem**: Claude Code v2.1.87+ includes `initialPrompt` in subagent frontmatter (the first user message to the agent). Dashboard's subagent parser does not extract or display this field. + +**Cite**: Claude Code v2.1.87 (subagent frontmatter expansion). + +**Affected Files** + +| File | Change Type | Workstream | Notes | +|------|-------------|-----------|-------| +| `api/models/agent.py` | Modify | Models | Add initial_prompt property | +| `api/parsers/subagent_frontmatter.py` | Modify or Create | Parser | Extract initialPrompt from frontmatter YAML | +| `frontend/src/lib/components/subagents/SubagentCard.svelte` | Modify | Components | Display initial_prompt in tooltip or expanded view | +| `api/tests/test_agent.py` | Modify | Test | Test frontmatter parsing includes initialPrompt | + +**Steps** + +| # | Action | Verification | +|---|--------|--------------| +| 1 | Parse initialPrompt from subagent JSONL frontmatter | Frontmatter parser yields initial_prompt field | +| 2 | Add initial_prompt property to Agent model | Cached or computed from JSONL header | +| 3 | Display in SubagentCard (tooltip on hover, or detail panel) | User can see what task was given to agent | +| 4 | Handle missing initialPrompt gracefully (older agents) | No errors if field absent | +| 5 | Test with sample subagent JSONL | Verify parsing works | + +**Postconditions** + +- [ ] initial_prompt extracted and available in API +- [ ] SubagentCard displays initial_prompt in UI +- [ ] Graceful handling of missing field (no errors) +- [ ] Zero regressions on existing subagent display + +--- + +### 5.4 Skill Frontmatter Additions + +**Problem**: Claude Code v2.1.87+ extends skill frontmatter with new fields: `shell: powershell`, `paths: [list]`, and `${CLAUDE_SKILL_DIR}` variable support. Dashboard's skill parser is outdated. + +**Cite**: Claude Code v2.1.87 (skill frontmatter expansion) and v2.1.88 (PowerShell support). + +**Affected Files** + +| File | Change Type | Workstream | Notes | +|------|-------------|-----------|-------| +| `api/models/skill.py` | Modify | Models | Add shell, paths, and expand_variables support | +| `api/parsers/skill_frontmatter.py` | Modify or Create | Parser | Extract shell, parse paths (list or string), handle ${CLAUDE_SKILL_DIR} | +| `frontend/src/lib/components/skills/SkillCard.svelte` | Modify | Components | Display shell type (bash/powershell) and path info | +| `api/tests/test_skill.py` | Modify | Test | Test parsing of new frontmatter fields | + +**New Fields** + +| Field | Type | Examples | Notes | +|-------|------|----------|-------| +| `shell` | string | "bash", "powershell" | Default "bash" if omitted | +| `paths` | list OR string | `["path/a", "path/b"]` or `"path/a, path/b"` | Both YAML list and comma-separated string supported | +| `${CLAUDE_SKILL_DIR}` | variable | Used in paths: `"${CLAUDE_SKILL_DIR}/lib"` | Expands to skill directory at runtime | + +**Steps** + +| # | Action | Verification | +|---|--------|--------------| +| 1 | Update skill frontmatter parser to extract shell, paths | Parse both list and string formats for paths | +| 2 | Implement variable expansion for ${CLAUDE_SKILL_DIR} | Replace token with actual skill directory path | +| 3 | Add shell and paths properties to Skill model | Expose in API responses | +| 4 | Update SkillCard to display shell type and paths | Show visual indicators (shell icon, path list) | +| 5 | Test with sample skills featuring new fields | Verify parsing and rendering work | +| 6 | Backward compat: skills without new fields | No errors, graceful defaults | + +**Postconditions** + +- [ ] Skill shell type (bash/powershell) extracted and displayed +- [ ] Paths field parsed (both list and string formats) +- [ ] ${CLAUDE_SKILL_DIR} variable expanded correctly +- [ ] SkillCard displays new info in UI +- [ ] Backward compatible with older skills + +--- + +### 5.5 Managed-Settings Fragments Merging + +**Problem**: Claude Code v2.1.89+ supports modular settings via `~/.claude/managed-settings.d/*.json` (e.g., `override.json`, `team-defaults.json`). These fragments use last-write-wins merging. Dashboard only reads monolithic `settings.json` and ignores fragments. + +**Cite**: Claude Code v2.1.89 (managed-settings.d directory support). + +**Affected Files** + +| File | Change Type | Workstream | Notes | +|------|-------------|-----------|-------| +| `api/models/settings.py` | Modify | Models | Add merged_settings field tracking per-fragment source | +| `api/services/settings_loader.py` | Modify or Create | Services | Implement fragment discovery, merging (last-write-wins), and source attribution | +| `frontend/src/lib/components/settings/SettingsPanel.svelte` | Modify | Components | Show merged settings with per-value source attribution | +| `api/tests/test_settings.py` | Modify | Test | Test fragment merging with multiple fragments | + +**Merging Rules** + +1. Read `~/.claude/settings.json` (base) +2. Scan `~/.claude/managed-settings.d/` for all `*.json` files +3. Sort fragments by modification time (last-write-wins) +4. Merge each fragment into base using deep-merge (nested objects combined, arrays replaced) +5. Track source file for each value + +**Steps** + +| # | Action | Verification | +|---|--------|--------------| +| 1 | Implement fragment discovery in settings loader | Scan managed-settings.d, find all *.json | +| 2 | Implement merge logic (last-write-wins for each key) | Deep merge with array replacement strategy | +| 3 | Track source attribution for each merged value | Store {key: {value, source_file}} | +| 4 | Update SettingsPanel to display source annotations | e.g., "defaultShell: bash (from team-defaults.json)" | +| 5 | Test with 3+ fragment files and conflicts | Verify correct merge order and attribution | + +**Postconditions** + +- [ ] All managed-settings.d fragments discovered and loaded +- [ ] Merging follows last-write-wins semantics +- [ ] Source file tracked for each setting value +- [ ] SettingsPanel displays source attribution +- [ ] Graceful handling if managed-settings.d absent + +--- + +### 5.6 MCP Tool Result Size Annotation Preservation + +**Problem**: Claude Code v2.1.90+ annotates large tool results with `_meta["anthropic/maxResultSizeChars"]` metadata. Dashboard's ToolResult model discards this metadata, losing visibility into truncation. + +**Cite**: Claude Code v2.1.90 (MCP metadata standardization). + +**Affected Files** + +| File | Change Type | Workstream | Notes | +|------|-------------|-----------|-------| +| `api/models/tool_result.py` | Modify | Models | Add meta field with Optional[Dict] for metadata | +| `api/routers/sessions.py` | Modify | Routers | Include meta in tool_result response | +| `frontend/src/lib/components/timeline/TimelineToolCall.svelte` | Modify | Components | Render "(large output)" badge if maxResultSizeChars present | +| `api/tests/test_tool_result.py` | Modify | Test | Test parsing and preserving metadata | + +**Steps** + +| # | Action | Verification | +|---|--------|--------------| +| 1 | Add Optional[Dict[str, Any]] meta field to ToolResult model | Pydantic field with default None | +| 2 | Update JSONL parser to preserve _meta block | Extract from tool_result structure | +| 3 | Expose meta in API responses | Include in /sessions/{uuid}/tools endpoint | +| 4 | Render "(large output)" badge in timeline | Check if meta["anthropic/maxResultSizeChars"] present | +| 5 | Test with sample tool_result containing metadata | Verify preservation and rendering | + +**Postconditions** + +- [ ] MCP metadata preserved in ToolResult model +- [ ] Metadata exposed in API responses +- [ ] Timeline shows "(large output)" annotation for truncated results +- [ ] Zero regressions on tool results without metadata + +--- + +### 5.7 Fourteen New Settings Fields + +**Problem**: Claude Code v2.1.81–v2.1.92 introduced 14 new settings.json fields. Dashboard's settings parser model omits them, causing validation errors or silent omissions. + +**Cite**: Multiple versions across the audit window. + +**Affected Files** + +| File | Change Type | Workstream | Notes | +|------|-------------|-----------|-------| +| `api/models/settings.py` | Modify | Models | Add 14 new fields to Settings Pydantic model | +| `frontend/src/lib/components/settings/SettingsPanel.svelte` | Modify | Components | Render new fields with appropriate UI controls | +| `api/tests/test_settings.py` | Modify | Test | Test each new field parsing and display | + +**New Settings Fields** (specifications {needs clarification — awaiting official settings schema docs}) + +| Field | Type | Purpose | Default | Notes | +|-------|------|---------|---------|-------| +| `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` | bool | Enable Agent Teams feature | false | Feature flag for Teams | +| `forceRemoteSettingsRefresh` | bool | Refresh settings from server on startup | false | Multi-device sync aid | +| `disableSkillShellExecution` | bool | Prevent skill shell commands | false | Security policy | +| `sandbox.failIfUnavailable` | bool | Error if sandbox unavailable | false | Sandbox policy | +| `env.CLAUDE_CODE_NO_FLICKER` | bool | Disable UI flicker during updates | false | Performance/aesthetics | +| `env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` | bool | Strip environment vars in subprocesses | false | Security hardening | +| `env.CLAUDE_CODE_USE_POWERSHELL_TOOL` | bool | Prefer PowerShell over Bash | false | Shell preference | +| `env.CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE` | bool | Keep marketplace UI on plugin load fail | false | UX continuity | +| `env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING` | bool | Disable extended thinking in Claude Code | false | Performance tuning | +| `showThinkingSummaries` | bool | Display extended thinking summaries in UI | false | UX feature | +| `defaultShell` | string | Default shell: "bash", "zsh", "powershell" | "bash" | Shell choice | +| `agent` (plugin-specific) | object | Plugin-defined agent settings | {} | Per-plugin config | +| `allowedChannelPlugins` | array[string] | Whitelist of allowed channel plugins | [] | Plugin security | +| `includeGitInstructions` | bool | Prepend git best practices to prompts | false | Git guidance | + +**Steps** + +| # | Action | Verification | +|---|--------|--------------| +| 1 | Add all 14 fields to Settings Pydantic model | Each field with correct type and Optional handling | +| 2 | Set sensible defaults for each field | Match Claude Code defaults or safe fallbacks | +| 3 | Update SettingsPanel to render new fields | Appropriate controls (toggle, text input, dropdown) per type | +| 4 | Test each field parsing from settings.json | pytest validates all 14 fields | +| 5 | Backward compat: settings without new fields | No errors if fields omitted | + +**Postconditions** + +- [ ] All 14 new fields recognized and parsed +- [ ] SettingsPanel renders all fields with appropriate UI +- [ ] Backward compatible (no errors for old settings.json) +- [ ] API includes all fields in /settings endpoint + +--- + +## Section 6: Tier 3 — Experimental & Polish (Sprint 3) + +### 6.1 Extended Thinking Visibility + +**Problem**: Claude Code v2.1.90+ can display extended thinking summaries when `showThinkingSummaries: true` in settings. Dashboard has zero awareness of thinking blocks and does not render them. + +**Cite**: Claude Code v2.1.90 (extended thinking UI support). + +**Affected Files** + +| File | Change Type | Workstream | Notes | +|------|-------------|-----------|-------| +| `api/models/message.py` | Modify | Models | Ensure thinking block content preserved in AssistantMessage | +| `api/routers/sessions.py` | Modify | Routers | Include thinking content in timeline responses when showThinkingSummaries=true | +| `frontend/src/lib/components/timeline/TimelineEventCard.svelte` | Modify | Components | Render thinking summary (collapsed by default, expandable) | +| `frontend/src/lib/utils/settings.ts` | Modify or Create | Utils | Read showThinkingSummaries from settings, pass to components | +| `api/tests/test_message.py` | Modify | Test | Test thinking block preservation in AssistantMessage | + +**Steps** + +| # | Action | Verification | +|---|--------|--------------| +| 1 | Verify AssistantMessage preserves thinking block content | Check JSONL parsing includes thinking in content array | +| 2 | Check settings loader reads showThinkingSummaries | Frontend utils can access flag | +| 3 | Update timeline to conditionally render thinking | If showThinkingSummaries=true, render in collapsed detail | +| 4 | Style thinking summary (monospace, dimmed, expandable) | Match Claude Code's display aesthetic | +| 5 | Test with sample session containing thinking blocks | Verify rendering works when flag enabled | + +**Postconditions** + +- [ ] Thinking blocks preserved in models and API +- [ ] Timeline respects showThinkingSummaries setting +- [ ] Thinking summary renders with expand/collapse +- [ ] No regression when flag disabled + +--- + +### 6.2 Hook Conditional `if` Field Support + +**Problem**: Claude Code v2.1.91+ supports conditional `if` field on hook matchers in settings.json. These are NOT hook event types (captain-hook concern) but rather matcher conditions (settings parser concern). Dashboard must display the `if` condition for each hook matcher. + +**Cite**: Claude Code v2.1.91 (hook matcher conditionals). + +**Affected Files** + +| File | Change Type | Workstream | Notes | +|------|-------------|-----------|-------| +| `api/models/settings.py` | Modify | Models | Add conditional_if field to HookMatcher model | +| `api/parsers/hook_matcher.py` | Modify or Create | Parser | Extract and parse if field from settings hook array | +| `frontend/src/lib/components/settings/HookMatcherRow.svelte` | Modify or Create | Components | Display if condition in settings hook table | +| `api/tests/test_settings.py` | Modify | Test | Test parsing hook matcher conditionals | + +**Hook Matcher Conditional Example** + +```json +{ + "hook": "PreToolUse", + "if": "tool_name == 'Read' && context.file_size > 1000000", + "command": "/path/to/script.py" +} +``` + +**Steps** + +| # | Action | Verification | +|---|--------|--------------| +| 1 | Add conditional_if: Optional[str] to HookMatcher model | Optional field, default None | +| 2 | Parse if field from hook matcher in settings | Extract condition string as-is | +| 3 | Display if condition in settings UI (HookMatcherRow) | Show condition in table, truncate if long | +| 4 | Test with sample hook matchers with if conditions | Verify parsing and display | + +**Postconditions** + +- [ ] Hook matcher if conditions parsed +- [ ] Settings UI displays hook conditions +- [ ] Graceful handling if if field absent +- [ ] No regressions on existing hook matchers + +--- + +### 6.3 Session Resumption Hardening + +**Problem**: Claude Code v2.1.85–v2.1.91 experienced a regression where session resumption across versions could produce tool_result blocks with mismatched IDs. Dashboard must validate tool_use ID ↔ tool_result ID pairing during resumption parsing. + +**Cite**: Claude Code v2.1.85–v2.1.91 regression window (fixed in v2.1.92). + +**Affected Files** + +| File | Change Type | Workstream | Notes | +|------|-------------|-----------|-------| +| `api/models/message.py` | Modify | Models | Add validation for tool_use/tool_result ID pairing | +| `api/parsers/jsonl_parser.py` | Modify | Parser | Add resumption-edge-case detection and warnings | +| `api/routers/sessions.py` | Modify | Routers | Include resumption warnings in session metadata if detected | +| `api/tests/test_session_resumption.py` | Modify or Create | Test | Test resumption across regression boundary versions | + +**Validation Logic** + +When parsing a session that shows resumption (detected by session_uuid changing mid-JSONL or by explicit resume marker): + +1. Collect all tool_use IDs from AssistantMessage content blocks +2. Collect all tool_result IDs from UserMessage content blocks +3. For each tool_result, verify corresponding tool_use exists by ID +4. Flag any orphaned tool_results (no matching tool_use) +5. Log warnings but do not fail parse + +**Steps** + +| # | Action | Verification | +|---|--------|--------------| +| 1 | Implement tool_use/result ID pairing validation | Check all IDs match during resumption | +| 2 | Detect sessions with resumption (markers or UUID change) | Identify resumption boundary | +| 3 | Add warnings to session metadata if ID mismatches found | Report as "resumption_warnings" array | +| 4 | Test with sample session from regression window | Verify validation catches ID mismatches | +| 5 | Test with normal sessions (no resumption) | Zero false positives | + +**Postconditions** + +- [ ] Tool_use/result ID pairing validated +- [ ] Resumption edge cases detected and warned +- [ ] Session parse does not fail on ID mismatches +- [ ] Zero regressions on normal sessions + +--- + +## Section 7: File Change Index + +| File | Change Type | Sprint | Workstream | Notes | +|------|-------------|--------|-----------|-------| +| `api/models/agent.py` | Modify | S1 | Expansion | Add initial_prompt property | +| `api/models/hooks.py` (captain-hook) | Modify | S1 | Expansion | Add 11 new hook event types | +| `api/models/message.py` | Modify | S3 | Validation | Add tool_use/result ID validation | +| `api/models/settings.py` | Modify | S2 | Expansion | Add 14 new settings fields + conditional_if for hooks | +| `api/models/session.py` | Modify | S1 | Detection | Add is_bare_mode property | +| `api/models/skill.py` | Modify | S2 | Expansion | Add shell, paths, variable expansion | +| `api/models/team.py` | Create | S1 | Discovery | Team, Task, TeamMember Pydantic models | +| `api/models/tool_result.py` | Modify | S2 | Preservation | Add meta field for MCP metadata | +| `api/parsers/hook_matcher.py` | Create or Modify | S3 | Settings | Parse hook conditional if field | +| `api/parsers/jsonl_parser.py` | Modify | S3 | Validation | Add resumption validation | +| `api/parsers/skill_frontmatter.py` | Create or Modify | S2 | Expansion | Parse shell, paths, variables | +| `api/parsers/subagent_frontmatter.py` | Create or Modify | S2 | Expansion | Extract initialPrompt | +| `api/routers/sessions.py` | Modify | S2, S3 | Routers | Include tool meta, resumption warnings, thinking content | +| `api/routers/teams.py` | Create | S1 | Discovery | Team listing and detail endpoints | +| `api/services/settings_loader.py` | Create or Modify | S2 | Merging | Implement fragment discovery and merging | +| `api/tests/test_agent.py` | Modify | S2 | Test | Test initialPrompt extraction | +| `api/tests/test_hook_matcher.py` | Modify | S3 | Test | Test conditional if parsing | +| `api/tests/test_jsonl_utils.py` | Modify | S1 | Test | Confirm merge regression test (already shipped) | +| `api/tests/test_message.py` | Modify | S3 | Test | Test tool_use/result ID validation | +| `api/tests/test_session_resumption.py` | Create or Modify | S3 | Test | Test resumption edge cases | +| `api/tests/test_settings.py` | Modify | S2, S3 | Test | Test new fields and fragment merging | +| `api/tests/test_skill.py` | Modify | S2 | Test | Test shell, paths parsing | +| `api/tests/test_tool_result.py` | Modify | S2 | Test | Test metadata preservation | +| `api/tests/test_utils.py` | Modify | S2 | Test | Test PowerShell and Teams tool recognition | +| `api/utils.py` | Modify | S1, S2 | Utils | Bare mode detection, Teams tool discovery, PowerShell support | +| `captain-hook/models/__init__.py` | Modify | S1 | Expansion | Export new hook event types | +| `captain-hook/parser.py` | Modify | S1 | Expansion | Update parse_hook_event dispatch | +| `captain-hook/tests/test_models.py` | Modify | S1 | Test | Add tests for 11 new hook types | +| `frontend/src/lib/api-types.ts` | Modify | S1, S2 | Types | Add Team types, extend tool and settings types | +| `frontend/src/lib/components/settings/HookMatcherRow.svelte` | Create or Modify | S3 | Components | Display if conditions | +| `frontend/src/lib/components/settings/SettingsPanel.svelte` | Modify | S2, S3 | Components | Render new settings, fragment sources, thinking | +| `frontend/src/lib/components/skills/SkillCard.svelte` | Modify | S2 | Components | Display shell, paths info | +| `frontend/src/lib/components/subagents/SubagentCard.svelte` | Modify | S2 | Components | Display initialPrompt | +| `frontend/src/lib/components/TeamCard.svelte` | Create | S1 | Components | Team summary card | +| `frontend/src/lib/components/timeline/TimelineEventCard.svelte` | Modify | S2, S3 | Components | Add PowerShell icon, Teams tool cards, thinking render | +| `frontend/src/lib/components/timeline/TimelineToolCall.svelte` | Modify | S2 | Components | Render "(large output)" badge | +| `frontend/src/lib/utils/session.ts` | Modify | S1 | Utils | Add is_bare_mode() helper | +| `frontend/src/lib/utils/settings.ts` | Modify or Create | S3 | Utils | Read showThinkingSummaries setting | +| `frontend/src/routes/settings/+page.svelte` | Modify | S2, S3 | Routes | Display new settings, conditionals | +| `frontend/src/routes/teams/+page.svelte` | Create | S1 | Routes | Team listing view | +| `frontend/src/routes/teams/[team_name]/+page.svelte` | Create | S1 | Routes | Team detail view | +| `frontend/src/routes/tools/+page.svelte` | Modify | S2 | Routes | Split Bash/PowerShell stats, include Teams tools | + +--- + +## Section 8: Cross-Cutting Concerns + +### 8.1 Sequencing & Dependencies + +**Sequential (must complete in order)** + +1. **S1 captain-hook expansion** — must complete before any S2 code references new hook types +2. **S1 bare mode detection** — quick, unblocks session card updates +3. **S1 Teams discovery** — foundational, other code may reference teams +4. **S2 tool recognition expansion** — depends on S1 captain-hook; can run in parallel with settings/metadata work + +**Parallel (can run together)** + +- S2 settings parsing (14 fields + fragments) — independent +- S2 skill/subagent frontmatter parsing — independent +- S2 MCP metadata preservation — independent + +**Late-stage (S3, after core work stable)** + +- S3 thinking visibility — depends on S2 settings being solid +- S3 hook conditional if — depends on S2 settings parser refactored +- S3 resumption hardening — can start in parallel but test last + +### 8.2 Test Strategy + +**Test Scope per Sprint** + +| Sprint | Test Execution | Coverage Target | +|--------|---|---| +| S1 | `pytest api/tests/ captain-hook/tests/ -v` | ~85% (captain-hook 100%, models/utils 70%+) | +| S2 | `pytest api/tests/test_settings.py api/tests/test_utils.py api/tests/test_*.py -v` | ~80% (new parsers, model serialization) | +| S3 | `pytest api/tests/test_session_resumption.py api/tests/test_message.py -v` | ~75% (edge cases, validation) | + +**Integration Tests** + +- End-to-end timeline rendering with all new tools (PowerShell, Teams, thinking) +- Settings merge with 3+ fragments + source attribution +- Bare mode detection on sample JSONL (zero hook events) +- Session resumption across version boundary with ID mismatch detection + +**Test File Locations** + +``` +api/tests/ + test_utils.py # Tool recognition, bare mode + test_settings.py # Settings parsing, fragment merging + test_agent.py # Subagent initialPrompt + test_message.py # Thinking blocks, tool_use/result validation + test_session_resumption.py # Edge cases, ID validation + +captain-hook/tests/ + test_models.py # All 21 hook types round-trip +``` + +### 8.3 Rollout + +**Backward Compatibility** + +- No breaking JSONL schema changes (all new features are additive) +- Settings with missing new fields: use defaults, no errors +- Sessions without new metadata: degrade gracefully (no badges, no warnings) +- Fragments missing: use base settings.json only (no errors) + +**Zero-Downtime Deploy** + +1. Deploy backend (api) with new models and endpoints +2. Deploy frontend (SvelteKit) with new components +3. No data migration needed (all new fields optional) + +**Visibility Flags** + +- Teams tab: hidden if `~/.claude/teams/` absent +- Thinking summary: controlled by showThinkingSummaries setting +- Bare mode badge: always visible (detection automatic) +- PowerShell stats: visible if any PowerShell invocations found + +--- + +## Section 9: Verification Matrix + +### 9.1 Sprint 1 Verification + +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 1.1 | `[Image #N]` marker dropped during merge | `pytest tests/test_jsonl_utils.py::TestMessageMerging::test_merge_drops_image_hash_number_marker -v` | [ ] | +| 1.2 | All 21 captain-hook types parse without error | `pytest captain-hook/tests/test_models.py::TestParseHookEvent -v` | [ ] | +| 1.3 | New 11 hook types round-trip JSON serialization | `pytest captain-hook/tests/test_models.py::TestRoundTrip -v` | [ ] | +| 1.4 | Bare mode detection triggers on zero-hook JSONL | Sample: read bare-mode-sample.jsonl, confirm is_bare_mode=true | [ ] | +| 1.5 | Normal sessions do NOT trigger bare mode | Sample: read normal-session.jsonl, confirm is_bare_mode=false | [ ] | +| 1.6 | Teams discovery returns empty when ~./claude/teams/ absent | Manual: rm -rf ~/.claude/teams; curl http://localhost:8000/teams; expect 200 with empty list | [ ] | +| 1.7 | Teams API returns team metadata when directory present | Manual: create test team dir; curl /teams/{name}; expect team object | [ ] | +| 1.8 | Frontend teams route renders correctly | Manual: navigate to /teams in browser; expect team cards | [ ] | + +### 9.2 Sprint 2 Verification + +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 2.1 | PowerShell tool recognized in get_tool_summary | `pytest tests/test_utils.py::TestToolSummary::test_powershell_recognized -v` | [ ] | +| 2.2 | PowerShell timeline card renders with correct icon | Manual: view session with PowerShell invocation; icon visible | [ ] | +| 2.3 | /tools route splits Bash and PowerShell stats | Manual: navigate to /tools; expect two rows (Bash, PowerShell) | [ ] | +| 2.4 | Teams tools (SendMessage, TaskCreate, etc.) recognized | `pytest tests/test_utils.py::TestTeamsTools -v` | [ ] | +| 2.5 | Teams tool timeline cards render semantically | Manual: view session with Teams operations; cards show operation type | [ ] | +| 2.6 | Subagent initialPrompt extracted and stored | `pytest tests/test_agent.py::TestSubagentFrontmatter::test_initial_prompt -v` | [ ] | +| 2.7 | Subagent initialPrompt displayed in card tooltip | Manual: hover subagent card; tooltip shows initial prompt | [ ] | +| 2.8 | Skill shell field parsed (bash/powershell) | `pytest tests/test_skill.py::TestFrontmatter::test_shell_field -v` | [ ] | +| 2.9 | Skill paths field parsed (list and string format) | `pytest tests/test_skill.py::TestFrontmatter::test_paths_list` and `test_paths_string -v` | [ ] | +| 2.10 | ${CLAUDE_SKILL_DIR} variable expanded in paths | `pytest tests/test_skill.py::TestFrontmatter::test_variable_expansion -v` | [ ] | +| 2.11 | Managed-settings.d fragments discovered | Manual: create 2+ fragments in managed-settings.d; curl /settings; expect merged result | [ ] | +| 2.12 | Fragment merging follows last-write-wins | Manual: modify fragment timestamps; verify merge order | [ ] | +| 2.13 | Settings UI shows fragment source attribution | Manual: navigate to settings; hover/expand field; see "(from team-defaults.json)" | [ ] | +| 2.14 | MCP metadata preserved in ToolResult model | `pytest tests/test_tool_result.py::TestMeta -v` | [ ] | +| 2.15 | "(large output)" badge renders when meta present | Manual: view tool result with maxResultSizeChars; badge visible | [ ] | +| 2.16 | All 14 new settings fields parse without error | `pytest tests/test_settings.py::TestNewFields -v` | [ ] | +| 2.17 | New settings fields render in SettingsPanel | Manual: navigate to settings; verify all 14 fields visible | [ ] | + +### 9.3 Sprint 3 Verification + +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 3.1 | Thinking blocks preserved in AssistantMessage | `pytest tests/test_message.py::TestThinkingBlocks -v` | [ ] | +| 3.2 | Thinking block rendered in timeline when showThinkingSummaries=true | Manual: set showThinkingSummaries=true; view session; thinking visible | [ ] | +| 3.3 | Thinking block hidden when showThinkingSummaries=false | Manual: set showThinkingSummaries=false; view session; no thinking | [ ] | +| 3.4 | Hook conditional if field parsed from settings | `pytest tests/test_settings.py::TestHookConditional -v` | [ ] | +| 3.5 | Hook conditional if displayed in settings UI | Manual: navigate to settings hooks; see if conditions in hook rows | [ ] | +| 3.6 | Tool_use/result ID pairing validated on parse | `pytest tests/test_message.py::TestIdValidation -v` | [ ] | +| 3.7 | Resumption edge case detected (ID mismatch) | `pytest tests/test_session_resumption.py::TestIdMismatch -v` | [ ] | +| 3.8 | Orphaned tool_result warnings logged (not fatal) | Manual: parse sample with ID mismatch; check session metadata warnings | [ ] | +| 3.9 | Normal sessions with valid IDs show zero warnings | `pytest tests/test_session_resumption.py::TestNormalSessions -v` | [ ] | + +--- + +## Section 10: Release Reference + +| CC Version | Date | Headline Change | Impact | In Audit Window? | +|-----------|------|-----------------|--------|---| +| v2.1.81 | 2026-03-20 | Baseline | Audit window start | No (baseline) | +| v2.1.82 | 2026-03-21 | InstructionsLoaded hook | captain-hook expansion | Yes | +| v2.1.83 | 2026-03-23 | Bug fixes | Minor | No | +| v2.1.84 | 2026-03-24 | CwdChanged, FileChanged hooks | captain-hook expansion | Yes | +| v2.1.85 | 2026-03-25 | Agent Teams launch, session resumption regression | Teams discovery, resumption hardening | Yes | +| v2.1.86 | 2026-03-26 | PermissionDenied, TaskCreated hooks; TaskCreate tool | captain-hook expansion, tool recognition | Yes | +| v2.1.87 | 2026-03-27 | TaskCompleted, TeammateIdle hooks; subagent initialPrompt; skill shell field | captain-hook expansion, frontmatter parsing | Yes | +| v2.1.88 | 2026-03-28 | PowerShell tool; WorktreeCreate, WorktreeRemove hooks; skill paths field | Tool recognition, captain-hook expansion, frontmatter | Yes | +| v2.1.89 | 2026-03-30 | managed-settings.d fragment support | Settings merging | Yes | +| v2.1.90 | 2026-04-01 | Extended thinking, Elicitation hooks; MCP metadata; showThinkingSummaries setting; 14 new settings fields | Thinking visibility, hook expansion, metadata preservation, settings expansion | Yes | +| v2.1.91 | 2026-04-03 | Hook conditional if field; ElicitationResult hook | Hook settings, captain-hook expansion | Yes | +| v2.1.92 | 2026-04-04 | Resumption regression fix | Closes v2.1.85 regression | Yes | + +--- + +## Appendix: Glossary of Claude Code Concepts + +**Agent Teams**: Multi-user collaboration feature (v2.1.85+) for coordinated work on projects. Not the same as subagents (single-user task agents). + +**Bare Mode**: Scripted session invocation with `--bare` flag. No GUI, no hooks, no interactive features. Appears empty to dashboard without proper detection. + +**Extended Thinking**: Claude's reasoning process (formerly "thinking"). Displayed in UI when `showThinkingSummaries: true`. + +**Hook Events**: Discrete actions fired by Claude Code (SessionStart, PreToolUse, etc.). Defined in captain-hook. 21 types total (10 original + 11 new). + +**Hook Matchers**: Conditional rules in settings.json that filter when hooks run (e.g., `if: tool_name == 'Read'`). Different from hook events. + +**Managed Settings**: Fragment-based settings system (`~/.claude/managed-settings.d/`) for modular config. Merges with base settings.json via last-write-wins. + +**MCP Tools**: Tools from Model Context Protocol (Search, Read, Write, custom tools). Metadata (`_meta["anthropic/maxResultSizeChars"]`) annotates truncation. + +**Session Resumption**: Continuing a session across version boundaries or after interruption. Regression v2.1.85–v2.1.91 caused tool_use/result ID mismatches; fixed in v2.1.92. + +**Skill**: Reusable command prompt with frontmatter metadata (shell, paths, description). Invocable from prompt context. + +--- + +**Document Status**: Ready for planning interview and Sprint 1 execution. diff --git a/docs/features/2026-04-07-multi-file-memory-ui.md b/docs/features/2026-04-07-multi-file-memory-ui.md new file mode 100644 index 00000000..bb48be23 --- /dev/null +++ b/docs/features/2026-04-07-multi-file-memory-ui.md @@ -0,0 +1,343 @@ +# Feature Definition: Multi-File Project Memory UI + +**Status:** Design approved, pending implementation plan +**Owner:** Jayant +**Related:** `frontend/src/lib/components/memory/MemoryViewer.svelte`, `api/routers/projects.py`, `api/schemas.py` + +## Section 1: Context & Motivation + +Claude Code's auto-memory system has evolved from a single `MEMORY.md` file into a directory of related markdown files: one index (`MEMORY.md`) plus many topical children (e.g. `syncthing-sync-architecture.md`, `project_git_radio.md`). Each child has YAML frontmatter describing `name`, `description`, and `type` (one of `user | feedback | project | reference`). `MEMORY.md` is itself a narrative index that references children via standard markdown links: `- [Title](file.md) — one-line hook`. + +The current Claude Karma UI was built for the single-file era. It reads only `memory/MEMORY.md` and renders it as a single markdown blob in `MemoryViewer.svelte`. The consequence: + +- All of the user's topical memory files (4+ in the working example) are invisible in the dashboard. +- Index links render as broken `file.md` URLs the browser cannot resolve. +- Users have no way to inspect individual memories, see their types, or browse what Claude has remembered. + +This feature reworks the memory view to embrace the new multi-file model while preserving the **reader-first** experience: the index narrative remains the centerpiece, and children are accessed contextually rather than as a separate navigation surface. + +### User-facing goal + +A user lands on a project's memory tab and sees the `MEMORY.md` index rendered cleanly. Links within the index now behave as first-class in-app references: hovering any link reveals a small popover with the linked file's type, description, word count, and last-modified time; clicking opens the full file in a side panel without leaving the index. + +## Section 2: Scope & Sub-Features + +### Sub-Features + +1. **Backend enumeration** — Parse all `*.md` files in `~/.claude/projects/{encoded_name}/memory/`, extract YAML frontmatter, compute which files are referenced from the index. +2. **Backend per-file fetch** — Serve individual child file contents on demand with basename-only path validation. +3. **Index rewriting** — Post-process the rendered MEMORY.md DOM to convert `[text](file.md)` anchors into in-app interactive elements. +4. **Hover previews** — Show a Wikipedia-style popover with file metadata when the user hovers a rewritten link. +5. **Side panel reading** — Slide-in right drawer displaying the full content of a clicked memory file. +6. **Orphan file display** — A collapsible "Other memory files" section listing files in the directory that `MEMORY.md` does not reference, with the same hover/click behavior. +7. **Backwards compatibility** — Projects with only a single `MEMORY.md` (no children) render identically to today. + +### Not In Scope (v1) + +- Editing, creating, or deleting memory files from the UI. +- Full-text search across memory files. +- Type-based filtering tabs (all / user / feedback / project / reference). +- "Open in editor" or "copy file path" affordances inside the panel. +- Stale-memory detection or health warnings. +- Syncing memory state across devices (handled by the sync-v4 pipeline at the filesystem level). + +## Section 3: Actors & Roles + +| Actor | Capabilities | Restrictions | +|-------|--------------|--------------| +| Dashboard viewer | Read project memory index, hover links for previews, open any child file in a side panel, browse orphan files. | Read-only. No mutation, no search, no cross-project navigation from this view. | + +## Section 4: Data Model + +### On-disk layout (unchanged, consumed as-is) + +``` +~/.claude/projects/{encoded_name}/memory/ +├── MEMORY.md # Index; plain markdown, no frontmatter +├── syncthing-sync-architecture.md # Child; frontmatter + body +├── project_git_radio.md +└── ... +``` + +### Child file frontmatter (authored by Claude per the auto-memory spec) + +```markdown +--- +name: Syncthing v2 architecture +description: Folder IDs, member_tag format, reconciliation pipeline +type: project +--- + +# Body content here... +``` + +Frontmatter fields: +- `name` — human-readable title. Fallback: filename without extension, underscores replaced with spaces. +- `description` — one-line hook. Fallback: empty string. +- `type` — one of `user | feedback | project | reference`. Fallback: `null`. + +The YAML block is optional. Legacy files without frontmatter must still render. + +## Section 5: API Changes + +### Endpoint 1 (upgraded): `GET /projects/{encoded_name}/memory` + +Returns the index file plus metadata (no content) for every other `*.md` file in `memory/`. + +**Response schema (`ProjectMemoryResponse` — breaking change):** + +```jsonc +{ + "index": { + "content": "string", // full MEMORY.md body + "word_count": 0, + "size_bytes": 0, + "modified": "2026-04-07T00:00:00Z", + "exists": true + }, + "files": [ // [] when no children exist + { + "filename": "syncthing-sync-architecture.md", + "name": "Syncthing v2 architecture", + "description": "Folder IDs, member_tag format, reconciliation pipeline", + "type": "project", // string | null + "word_count": 4812, + "size_bytes": 28630, + "modified": "2026-03-13T22:34:00Z", + "linked_from_index": true + } + ] +} +``` + +Backend logic: +1. List `memory/*.md`. +2. If `MEMORY.md` is absent → return `{ index: { exists: false, ... }, files: [] }`. +3. For each non-index file: read → parse frontmatter → stat → compute `word_count`. +4. Parse `MEMORY.md` for markdown link targets matching `*.md`. For each child file, set `linked_from_index = filename in index_link_targets`. +5. Frontmatter parser: accept `---\n...yaml...\n---` prefix; on any parse error, treat the file as having no frontmatter (do not fail the whole response). +6. Response is cached via the existing `@cacheable(max_age=30, stale_while_revalidate=60)` decorator. + +### Endpoint 2 (new): `GET /projects/{encoded_name}/memory/files/{filename}` + +Returns one child file's full content. Path component `files/` in the URL disambiguates from the index route and avoids a trailing-segment collision. + +**Path validation (security-critical):** +- `filename` must match `^[a-zA-Z0-9._-]+\.md$` (basename only, `.md` required). +- Reject anything containing `/`, `\`, `..`, leading `.`, or null bytes. +- After validation, resolve to `memory_dir / filename` and assert the resolved absolute path is still inside `memory_dir` (defense in depth against symlink escapes). +- 404 on not-found, 400 on invalid filename, 403 on path escape. + +**Response schema (`ProjectMemoryFileResponse`):** + +```jsonc +{ + "filename": "syncthing-sync-architecture.md", + "name": "Syncthing v2 architecture", + "description": "Folder IDs, member_tag format...", + "type": "project", + "content": "string", // body only; frontmatter stripped + "word_count": 4812, + "size_bytes": 28630, + "modified": "2026-03-13T22:34:00Z" +} +``` + +### New Pydantic schemas (`api/schemas.py`) + +- `MemoryFileMeta` — one entry in the `files[]` array of the index response. +- `MemoryIndexEntry` — the `index` object on the list response (same fields as the legacy `ProjectMemoryResponse`). +- `ProjectMemoryResponse` (replaced) — `{ index: MemoryIndexEntry, files: list[MemoryFileMeta] }`. +- `ProjectMemoryFileResponse` — per-file detail response. + +## Section 6: Frontend Changes + +### Component tree (new files) + +``` +frontend/src/lib/components/memory/ +├── MemoryViewer.svelte # shell, refactored — orchestrates everything below +├── MemoryIndex.svelte # renders MEMORY.md with rewritten links (NEW) +├── MemoryHoverCard.svelte # bits-ui Popover wrapper (NEW) +├── MemoryFilePanel.svelte # bits-ui Dialog (sheet variant) for reading (NEW) +└── MemoryOrphanList.svelte # collapsible "Other memory files" section (NEW) +``` + +Plus one Svelte action: + +``` +frontend/src/lib/actions/rewriteMemoryLinks.ts # DOM post-processor +``` + +### Component responsibilities + +**`MemoryViewer.svelte` (shell — existing file, rewritten)** +- Fetches `/projects/{encoded_name}/memory` on mount via `$effect`. +- State: `loading`, `error`, `indexPayload`, `files`, `selectedFilename`, `hoveredFilename`, `hoverAnchorRect`. +- Renders empty state (no `MEMORY.md` and no children), error state, or the layout. +- Layout: `MemoryIndex` with header card (reused from current UI), then `MemoryOrphanList` below, then `MemoryFilePanel` as an overlay. + +**`MemoryIndex.svelte`** +- Props: `content: string`, `files: MemoryFileMeta[]`, `onLinkHover(filename, rect)`, `onLinkLeave()`, `onLinkSelect(filename)`. +- Renders markdown via the existing `marked` + `DOMPurify` pipeline. +- Applies the `rewriteMemoryLinks` action to the container div. +- The action walks all `` elements, checks if `href` ends in `.md`, and if the basename matches a known filename in `files`, marks the anchor with `data-memory-file="..."` and attaches hover/leave/click listeners that call the prop callbacks. +- Unknown `.md` links (pointing to files not in `files`) get a muted visual treatment (dashed underline, tooltip "file not found") but remain non-interactive. + +**`MemoryHoverCard.svelte`** +- Props: `file: MemoryFileMeta | null`, `anchorRect: DOMRect | null`. +- Wraps `bits-ui` `Popover.Root` / `Popover.Content` with manual open control driven by `file !== null`. +- Positioned via `anchorRect` using a virtual reference element (bits-ui supports this). +- Shows: name, type badge (color-coded per type), description, word count, relative modified time. +- 150ms open delay handled in the shell before setting `hoveredFilename` (prevents flicker on casual mouse movement). + +**`MemoryFilePanel.svelte`** +- Props: `filename: string | null`, `projectEncodedName: string`, `onClose()`. +- Wraps `bits-ui` `Dialog.Root` configured as a right-side sheet (560px desktop / full-width mobile). +- `$effect` on `filename` triggers a fetch to `/projects/{encoded_name}/memory/files/{filename}`. +- Internal state: `loading`, `error`, `fileData: ProjectMemoryFileResponse | null`, `renderedContent`. +- Layout: sticky header (type badge, file name, modified relative time, close button) + scrollable body (rendered markdown reusing the same pipeline and the existing `markdownCopyButtons` action). +- Swapping filenames while open: fetches new content, replaces the body, keeps the panel open (no animation flicker). +- Closes on Esc or click-outside (bits-ui default behavior). + +**`MemoryOrphanList.svelte`** +- Props: `files: MemoryFileMeta[]`, same hover/click callbacks as `MemoryIndex`. +- Only renders if `files.length > 0`. +- Collapsible section header: icon + "Other memory files (N)" + chevron. +- Collapsed by default; remembers expanded state via URL search param `?orphans=open` for shareable links. +- Each row: type badge, name, description preview (truncated), modified relative time, word count. + +**`rewriteMemoryLinks` Svelte action (`frontend/src/lib/actions/rewriteMemoryLinks.ts`)** +- Signature: `rewriteMemoryLinks(node: HTMLElement, params: { files: MemoryFileMeta[], onHover, onLeave, onSelect })`. +- On mount and on content change (via `update`): query `node.querySelectorAll('a[href$=".md"]')`, classify each, attach listeners. +- On destroy: remove all listeners it attached. +- Stores listener references on a `WeakMap` so re-runs don't leak. + +### TypeScript interfaces (`frontend/src/lib/api-types.ts`) + +- Replace `ProjectMemory` with: + - `MemoryFileMeta` — mirrors the backend schema. + - `ProjectMemoryIndexEntry` — the `index` field. + - `ProjectMemory` — `{ index: ProjectMemoryIndexEntry, files: MemoryFileMeta[] }`. + - `ProjectMemoryFile` — per-file response. + +### Integration point + +`frontend/src/routes/projects/[project_slug]/+page.svelte:1725` continues to render ``. No route or page changes. + +## Section 7: UX Details + +### Rewritten link styling + +In-app links (those resolving to a known child file) are styled distinctly from regular markdown links so the user understands they're interactive previews, not navigations: + +- Accent-colored underline, 1px. +- Subtle chevron icon (⤴ or Lucide `arrow-up-right`) appended after the text, half-opacity. +- Cursor: pointer. +- Hover: 100% opacity chevron + slight background tint. +- Broken links (`.md` hrefs not in `files[]`): dashed muted underline, `title` attribute "file not found in memory directory", cursor default. + +### Type badges + +Small pill badges with semantic colors (used in hover card, file panel header, and orphan list rows): + +| Type | Color (CSS var) | Label | +|------|-----------------|-------| +| `user` | `--accent-blue` | User | +| `feedback` | `--accent-amber` | Feedback | +| `project` | `--accent-violet` | Project | +| `reference` | `--accent-emerald` | Reference | +| `null` | `--text-muted` | — | + +Colors reuse existing design tokens where possible; new tokens added to `app.css` only if a needed color is missing. + +### Hover card + +- Width: 320px max, auto height. +- Border: 1px `--border`, background `--bg-subtle`, shadow `--shadow-lg`. +- Content order: type badge (top-left) + modified relative time (top-right) → name (semibold) → description (muted, truncated to 3 lines) → footer row with word count. +- Position: below the anchor by default; flips above if it would overflow the viewport (bits-ui handles this). +- Delay: 150ms on show, instant on hide. + +### Side panel + +- Slide-in from right, 300ms cubic-bezier ease-out. +- Dim overlay at 40% opacity over the rest of the page. +- Width: 560px desktop, 100vw mobile. +- Sticky header within the panel (remains visible as body scrolls). +- Content uses the same `markdown-preview` + `prose` classes as the index for consistent typography. +- Opening a different file while panel is open: header and body fade out briefly, new content fades in; no full re-open animation. + +### Orphan list + +- Collapsed by default; shows count in header: "Other memory files (3)". +- Visual weight intentionally low — orphans are either leftovers or new files Claude hasn't woven into the index yet. + +## Section 8: Error Handling + +| Condition | Backend response | Frontend treatment | +|-----------|------------------|--------------------| +| No `memory/` directory | `{ index: { exists: false, ... }, files: [] }` | Existing "No Project Memory Yet" empty state. | +| `memory/` exists but `MEMORY.md` missing, children present | `{ index: { exists: false, ... }, files: [...] }` | Render a synthetic header "Orphan memory files" and show only `MemoryOrphanList` with children. | +| `MEMORY.md` exists, no children | `{ index: {...}, files: [] }` | Render index only. Identical to current UI (backwards compat). | +| Malformed YAML frontmatter in a child | File still listed; `name`=filename-derived, `description`=``, `type`=null. | Rendered normally; type badge shows as `—`. | +| Individual file fetch fails (network, 500) | n/a | Panel shows inline error with "Retry" button; panel does not close automatically. | +| Individual file not found (404) | 404 | Panel shows "This memory file no longer exists" message; closes on user dismissal. | +| Invalid filename parameter | 400 with clear message | Panel shows "Invalid memory file" error; logs to console; surfaces as toast if toast system exists. | +| Path escape attempt | 403 | Same as 400 treatment; logged server-side. | + +## Section 9: Backwards Compatibility + +- Any project with only `MEMORY.md` and no children returns `files: []`. The UI renders an index-only view that matches the current experience (same card, same markdown rendering, same empty state for missing memory). +- Old URLs linking to the project page remain valid; no route changes. +- The `ProjectMemoryResponse` schema is a **breaking change** on the wire, but the API is consumed only by our frontend, and both land in the same commit. No external API contract concern. + +## Section 10: Testing Strategy + +### Backend (`api/tests/`) + +- `test_memory_router.py` (new file): + - Fixture: temp `memory/` dirs with various shapes (no dir, only index, index+children, children without index, malformed frontmatter, orphan files, non-.md files to ignore). + - Endpoint tests for both `/memory` and `/memory/files/{filename}`. + - Security: path traversal (`../etc/passwd`, `foo/bar.md`, `.hidden.md`, empty, non-`.md`, null byte) must all return 400/403. + - Frontmatter parsing: valid YAML, malformed YAML, missing fields, unknown fields, empty frontmatter block. + - `linked_from_index` computation: matches exact filename, ignores fragment/query, doesn't match false positives. + +### Frontend (`frontend/src/lib/components/memory/__tests__/`) + +- `MemoryViewer.test.ts` — mount with mocked fetch, assert loading → loaded → empty states; assert panel opens on link click. +- `rewriteMemoryLinks.test.ts` — action unit test with a DOM fixture; assert link rewriting and listener attach/detach. +- `MemoryHoverCard.test.ts` — snapshot for rendered popover content per file type. +- `MemoryFilePanel.test.ts` — fetches on filename change, handles loading/error states, swaps content when filename changes while open. + +### Manual QA checklist + +- [ ] Visit project with multi-file memory → index renders, links visible. +- [ ] Hover a link → popover appears after 150ms with correct metadata. +- [ ] Click a link → panel opens, full content loads, markdown renders. +- [ ] Click a different link while panel is open → content swaps. +- [ ] Esc closes panel; click-outside closes panel. +- [ ] Click an orphan file row → panel opens. +- [ ] Visit project with only MEMORY.md → renders exactly as before. +- [ ] Visit project with no memory → empty state unchanged. +- [ ] Visit project with malformed frontmatter in one child → other files unaffected. +- [ ] Mobile viewport: panel goes full-width, hover becomes tap-to-preview (or simply opens panel directly on tap — hover interactions on touch are a known limitation; acceptable for v1). + +## Section 11: Open Questions + +None remaining for v1. Items explicitly deferred to v2: +- Search across memory files. +- Type-based filtering. +- Edit-in-place or "open in editor" integration. +- Stale-memory detection (files older than N days without references). + +## Section 12: Success Criteria + +A user with a populated multi-file memory directory loads the project memory tab and can: +1. See the index narrative rendered with visually distinct in-app links. +2. Hover any link to preview the target file's metadata without opening it. +3. Click any link to read the full file in a side panel without losing the index position. +4. See orphan files in a dedicated section and read them the same way. +5. Return to the default (only-index) experience when the project has no child files. + +The implementation must not regress the existing empty-state, single-file, or loading-state rendering. diff --git a/frontend/src/app.css b/frontend/src/app.css index 79a27332..3e1b91e0 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -643,6 +643,94 @@ body { @apply mt-1 mb-0; } +/* ── Markdown inline copy buttons (injected by markdownCopyButtons action) ── */ + +/* pre needs relative positioning for the absolute code-copy button */ +.markdown-preview pre { + position: relative; +} + +.md-copy-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + border-radius: 0.375rem; + border: 1px solid var(--border); + background: var(--bg-base); + color: var(--text-muted); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease; + flex-shrink: 0; + line-height: 1; +} + +.md-copy-btn:hover { + color: var(--text-primary); + border-color: var(--accent); + opacity: 1; +} + +.md-copy-btn--copied { + color: var(--success) !important; + opacity: 1 !important; +} + +/* Code block button: always visible (no hover required) */ +.md-copy-btn--code { + position: absolute; + top: 0.5rem; + right: 0.5rem; + opacity: 1; +} + +/* Section button: inline after heading text, revealed on parent hover */ +.md-copy-btn--section { + vertical-align: middle; + margin-left: 0.5rem; + transform: translateY(-1px); +} + +/* All section copy buttons — always visible */ +.md-copy-btn--section { + opacity: 1; +} + + +/* ── Custom tooltip for the global "copy entire response" button ── */ +/* Uses data-tooltip attribute + ::before pseudo-element so it appears + instantly on hover without the OS-imposed delay of the native title attr. */ + +.md-global-copy { + position: relative; +} + +.md-global-copy::before { + content: attr(data-tooltip); + position: absolute; + right: calc(100% + 8px); + top: 50%; + transform: translateY(-50%); + background: var(--bg-muted); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 3px 8px; + font-size: 11px; + font-family: inherit; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.1s ease; + z-index: 50; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); +} + +.md-global-copy:hover::before { + opacity: 1; +} + /* ============================================ VIM-STYLE LIST NAVIGATION ============================================ */ @@ -807,6 +895,22 @@ body { } } +/* Session last-opened highlight ring — fades out after mount */ +@keyframes session-highlight-fade { + 0% { box-shadow: 0 0 0 2px var(--accent); } + 60% { box-shadow: 0 0 0 2px var(--accent); } + 100% { box-shadow: 0 0 0 2px transparent; } +} +.session-highlight { + animation: session-highlight-fade 1.8s ease-out forwards; +} +@media (prefers-reduced-motion: reduce) { + .session-highlight { + animation: none; + box-shadow: 0 0 0 2px var(--accent); + } +} + /* Search highlighting */ .search-highlight { background: color-mix(in srgb, var(--warning) 30%, transparent); diff --git a/frontend/src/lib/actions/markdownCopyButtons.ts b/frontend/src/lib/actions/markdownCopyButtons.ts new file mode 100644 index 00000000..4e1d826b --- /dev/null +++ b/frontend/src/lib/actions/markdownCopyButtons.ts @@ -0,0 +1,244 @@ +/** + * Svelte action that injects per-section and per-code-block copy buttons + * into a `.markdown-preview` container after `{@html}` has populated the DOM. + * + * Uses a MutationObserver to detect when {@html} changes the DOM — more + * reliable than queueMicrotask because it fires AFTER the DOM mutation, + * regardless of Svelte's internal flush ordering. + * + * Section detection covers two patterns Claude actually uses: + * 1. Proper markdown headings —

and

+ * 2. Bold-only paragraphs —

Title

preceded by
+ * (Claude's **Title** + --- style) + * + * Buttons are only injected when a section's content meets MIN_SECTION_CHARS. + * This is heading-level agnostic: a meaty h3 gets a button, a one-liner h2 + * does not. Code block copy buttons are always injected regardless of length. + * + * h2 sections include all h3 content beneath them in their character count, + * so a document with one h2 title + several h3 steps will correctly show + * a button on the h2 (whole plan) AND on each substantive h3 step. + */ + +/** Minimum plain-text characters a section must contain to earn a copy button. */ +const MIN_SECTION_CHARS = 150; + +const COPY_ICON = ``; +const CHECK_ICON = ``; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeCopyButton(extraClass: string, label: string): HTMLButtonElement { + const btn = document.createElement('button'); + btn.className = `md-copy-btn ${extraClass}`; + btn.setAttribute('aria-label', label); + // No title attribute — aria-label handles accessibility and avoids the + // native browser tooltip (which has an OS-imposed delay and can't be styled). + btn.innerHTML = COPY_ICON; + btn.type = 'button'; + return btn; +} + +function flashCopied(btn: HTMLButtonElement): ReturnType { + btn.innerHTML = CHECK_ICON; + btn.classList.add('md-copy-btn--copied'); + return setTimeout(() => { + btn.innerHTML = COPY_ICON; + btn.classList.remove('md-copy-btn--copied'); + }, 2000); +} + +/** + * Returns true if the element is a paragraph whose only non-whitespace child + * is a single — Claude's **bold heading** pattern. + */ +function isBoldHeading(el: Element): boolean { + if (el.tagName !== 'P') return false; + const meaningful = Array.from(el.childNodes).filter( + (n) => !(n.nodeType === Node.TEXT_NODE && n.textContent?.trim() === '') + ); + return ( + meaningful.length === 1 && + meaningful[0].nodeType === Node.ELEMENT_NODE && + (meaningful[0] as Element).tagName === 'STRONG' + ); +} + +/** + * What heading level stops the current section? + * + * h2 section → stops at next h2, HR, or top-level bold paragraph + * h3 section → stops at next h2, h3, HR, or top-level bold paragraph + * bold-para → stops at next HR, h2, or top-level bold paragraph + */ +function isSectionBoundary(el: Element, startTag: string): boolean { + const tag = el.tagName; + if (tag === 'HR' || tag === 'H2') return true; + if (startTag === 'H3' && tag === 'H3') return true; + // A bold paragraph that directly follows an HR is a top-level section title + if (isBoldHeading(el)) { + const prev = el.previousElementSibling; + if (prev === null || prev.tagName === 'HR') return true; + } + return false; +} + +/** + * Collect all plain text from `startEl` through siblings until the next + * section boundary. Returns { text, charCount }. + * + * The heading/title text itself is included so copy output is self-contained. + * charCount measures only the *prose* body content (siblings after the heading, + * excluding
 code blocks) so that a heading whose body is entirely code
+ * blocks — which already have their own copy buttons — doesn't pass the
+ * threshold. The full text for copying still includes code block content.
+ */
+function collectSection(startEl: Element): { text: string; charCount: number } {
+	const startTag = startEl.tagName; // H2, H3, or P
+	const titleText = (startEl.textContent ?? '').trim();
+
+	const bodyParts: string[] = [];
+	let proseCharCount = 0;
+	let sibling = startEl.nextElementSibling;
+
+	while (sibling) {
+		if (isSectionBoundary(sibling, startTag)) break;
+		const t = (sibling.textContent ?? '').trim();
+		if (t) {
+			bodyParts.push(t);
+			// Only count prose chars towards threshold — exclude 
 blocks
+			// since those already have their own copy buttons
+			if (sibling.tagName !== 'PRE') {
+				proseCharCount += t.length;
+			}
+		}
+		sibling = sibling.nextElementSibling;
+	}
+
+	const text = [titleText, ...bodyParts].filter(Boolean).join('\n\n');
+	return { text, charCount: proseCharCount };
+}
+
+// ─── Action ──────────────────────────────────────────────────────────────────
+
+export function markdownCopyButtons(node: HTMLElement, _content?: string) {
+	let setupPending = false;
+
+	function attachButton(
+		anchorEl: HTMLElement,
+		extraClass: string,
+		label: string,
+		getText: () => string
+	) {
+		const btn = makeCopyButton(extraClass, label);
+		let timer: ReturnType;
+
+		const onClick = (e: Event) => {
+			e.stopPropagation();
+			navigator.clipboard.writeText(getText()).then(() => {
+				clearTimeout(timer);
+				timer = flashCopied(btn);
+			});
+		};
+
+		btn.addEventListener('click', onClick);
+		anchorEl.appendChild(btn);
+	}
+
+	function setup() {
+		// Remove any previously injected buttons
+		node.querySelectorAll('.md-copy-btn').forEach((el) => el.remove());
+
+		// ── Code blocks — always injected, no length gate ────────────────────
+		// Code is the most-copied thing; length is irrelevant.
+		node.querySelectorAll('pre').forEach((pre) => {
+			attachButton(pre, 'md-copy-btn--code', 'Copy code', () => {
+				const code = pre.querySelector('code');
+				return (code ?? pre).textContent ?? '';
+			});
+		});
+
+		// ── Headings (h1, h2, h3) ────────────────────────────────────────────
+		//
+		// h1 and h2 ALWAYS get a button (top-level sections — matches the
+		// "every H1 and H2 gets a copy option" promise in the PR description).
+		// h3 is gated by MIN_SECTION_CHARS so trivial subheadings don't clutter
+		// the UI.
+		//
+		// h2 sections walk past h3 boundaries in collectSection(), so the h2
+		// button copies the entire h2 subtree including its h3 children.
+		node.querySelectorAll('h1, h2, h3').forEach((heading) => {
+			const { text, charCount } = collectSection(heading);
+
+			// H1 and H2 are always treated as top-level sections and get a button unconditionally.
+			// H3 only gets a button when its prose meets the minimum length.
+			if (heading.tagName === 'H3' && charCount < MIN_SECTION_CHARS) return;
+
+			attachButton(heading, 'md-copy-btn--section', 'Copy section', () => text);
+		});
+
+		// ── Bold-paragraph headings (Claude's **Title** + --- style) ─────────
+		//
+		// Only treat as a section title when immediately preceded by 
or + // at the very start — this excludes mid-section sub-labels like + // "Connection distances:" which appear inside another section's body. + node.querySelectorAll('p').forEach((p) => { + if (!isBoldHeading(p)) return; + + const prev = p.previousElementSibling; + const isTopLevel = prev === null || prev.tagName === 'HR'; + if (!isTopLevel) return; + + const { text, charCount } = collectSection(p); + if (charCount < MIN_SECTION_CHARS) return; + + // Append inside so the button sits right after the title text + const anchor = (p.querySelector('strong') ?? p) as HTMLElement; + attachButton(anchor, 'md-copy-btn--section', 'Copy section', () => text); + }); + + } + + // ── MutationObserver ───────────────────────────────────────────────────── + // + // subtree: false — only watch direct children of the markdown-preview node. + // {@html} replaces direct children, so this catches real content changes. + // subtree: true would also fire on our btn.innerHTML swaps (copy→check icon), + // causing setup() to wipe the check icon immediately after clicking. + const observer = new MutationObserver((mutations) => { + if (setupPending) return; + + // Ignore mutations that are entirely from our own injected buttons + const hasRealContentChange = mutations.some((m) => + Array.from(m.addedNodes).some( + (n) => !(n instanceof Element && n.classList.contains('md-copy-btn')) + ) + ); + + if (hasRealContentChange) { + setupPending = true; + setTimeout(() => { + setup(); + setupPending = false; + }, 0); + } + }); + + observer.observe(node, { childList: true, subtree: false }); + + // Run immediately in case content is already present on mount + if (node.children.length > 0) { + setup(); + } + + return { + update(_newContent?: string) { + // No-op: MutationObserver re-runs setup() whenever {@html} replaces + // the container's children, so we don't need to do anything here. + }, + destroy() { + observer.disconnect(); + node.querySelectorAll('.md-copy-btn').forEach((el) => el.remove()); + } + }; +} diff --git a/frontend/src/lib/actions/rewriteMemoryLinks.ts b/frontend/src/lib/actions/rewriteMemoryLinks.ts new file mode 100644 index 00000000..05614bdf --- /dev/null +++ b/frontend/src/lib/actions/rewriteMemoryLinks.ts @@ -0,0 +1,141 @@ +/** + * Svelte action that post-processes a rendered markdown container to convert + * `[text](some-file.md)` anchors into in-app interactive references. + * + * For every `
` inside the container: + * 1. If the basename matches a known file in `params.files`, mark it as a + * memory link, attach hover/leave/click listeners, prevent default + * navigation, and add styling hooks. + * 2. If the basename does NOT match, mark it as a broken memory link + * (dashed muted underline, native title tooltip). + * + * The action stores per-anchor listener references so cleanup is precise and + * doesn't leak. `update()` re-runs the wiring whenever params (most importantly + * the files list) change, and on each {@html} content swap (the parent calls + * `update` by passing fresh params via `use:rewriteMemoryLinks={...}`). + * + * Hover delay (150ms before showing the popover) is intentionally NOT + * implemented here — it lives in the parent shell, which receives the immediate + * onHover call and debounces visually. The action's job is only DOM wiring. + */ + +import type { MemoryFileMeta } from '$lib/api-types'; + +export interface RewriteMemoryLinksParams { + files: MemoryFileMeta[]; + onHover: (filename: string, rect: DOMRect) => void; + onLeave: () => void; + onSelect: (filename: string) => void; +} + +interface AttachedListeners { + enter: (e: MouseEvent) => void; + leave: (e: MouseEvent) => void; + click: (e: MouseEvent) => void; +} + +const MEMORY_LINK_CLASS = 'memory-link'; +const MEMORY_LINK_BROKEN_CLASS = 'memory-link--broken'; + +/** + * Extract the basename of a markdown link href, ignoring any fragment/query. + * Returns null if href doesn't end with `.md` (after stripping #/?). + */ +function extractMdBasename(href: string): string | null { + if (!href) return null; + // Strip fragment and query + const cleaned = href.split('#')[0].split('?')[0]; + if (!cleaned.toLowerCase().endsWith('.md')) return null; + // Take basename + const idx = cleaned.lastIndexOf('/'); + const base = idx >= 0 ? cleaned.slice(idx + 1) : cleaned; + return base || null; +} + +export function rewriteMemoryLinks(node: HTMLElement, params: RewriteMemoryLinksParams) { + let current = params; + const attached = new Map(); + + function teardownAll() { + attached.forEach((listeners, anchor) => { + anchor.removeEventListener('mouseenter', listeners.enter); + anchor.removeEventListener('mouseleave', listeners.leave); + anchor.removeEventListener('click', listeners.click); + anchor.classList.remove(MEMORY_LINK_CLASS); + anchor.classList.remove(MEMORY_LINK_BROKEN_CLASS); + anchor.removeAttribute('data-memory-file'); + }); + attached.clear(); + } + + function rewire() { + teardownAll(); + + const filenameSet = new Set(current.files.map((f) => f.filename)); + const anchors = node.querySelectorAll('a'); + + anchors.forEach((anchor) => { + const rawHref = anchor.getAttribute('href'); + if (!rawHref) return; + const basename = extractMdBasename(rawHref); + if (!basename) return; + + if (filenameSet.has(basename)) { + anchor.classList.add(MEMORY_LINK_CLASS); + anchor.setAttribute('data-memory-file', basename); + + const enter = (_e: MouseEvent) => { + const rect = anchor.getBoundingClientRect(); + current.onHover(basename, rect); + }; + const leave = (_e: MouseEvent) => { + current.onLeave(); + }; + const click = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + current.onSelect(basename); + }; + + anchor.addEventListener('mouseenter', enter); + anchor.addEventListener('mouseleave', leave); + anchor.addEventListener('click', click); + attached.set(anchor, { enter, leave, click }); + } else { + // Unknown .md target — mark as broken so it picks up muted styling + anchor.classList.add(MEMORY_LINK_BROKEN_CLASS); + anchor.setAttribute('title', 'file not found in memory directory'); + } + }); + } + + // MutationObserver: re-wire whenever {@html} swaps the container's children. + // Watch direct children only (matches markdownCopyButtons pattern) so our + // own class/attribute mutations don't trigger feedback loops. + const observer = new MutationObserver((mutations) => { + const hasContentChange = mutations.some((m) => + Array.from(m.addedNodes).some((n) => n instanceof Element) + ); + if (hasContentChange) { + // Defer to next microtask so the new DOM is fully attached + setTimeout(rewire, 0); + } + }); + observer.observe(node, { childList: true, subtree: false }); + + // Initial wiring (in case content is already populated on mount) + if (node.children.length > 0) { + rewire(); + } + + return { + update(newParams: RewriteMemoryLinksParams) { + current = newParams; + rewire(); + }, + destroy() { + observer.disconnect(); + teardownAll(); + } + }; +} diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index afdb41ba..3d2b591a 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -983,16 +983,61 @@ export interface PlanStats { newest_plan: string | null; } +/** + * Type classification for a memory file (from YAML frontmatter). + */ +export type MemoryFileType = 'user' | 'feedback' | 'project' | 'reference'; + +/** + * Per-file metadata for one entry in the memory directory. + * Returned in the `files` array of a ProjectMemory response. + */ +export interface MemoryFileMeta { + filename: string; + name: string; + description: string; + type: MemoryFileType | null; + word_count: number; + size_bytes: number; + modified: string; + linked_from_index: boolean; +} + +/** + * The MEMORY.md index entry returned alongside files[]. + * Contains the full content (no frontmatter expected) plus stats. + */ +export interface ProjectMemoryIndexEntry { + content: string; + word_count: number; + size_bytes: number; + modified: string; + exists: boolean; +} + /** * Response from /projects/{encoded_name}/memory endpoint. - * Contains the project's MEMORY.md content. + * Wraps the index entry plus metadata for every other *.md file + * in the project's memory/ directory. */ export interface ProjectMemory { + index: ProjectMemoryIndexEntry; + files: MemoryFileMeta[]; +} + +/** + * Response from /projects/{encoded_name}/memory/files/{filename} endpoint. + * Returns the full body content of one child memory file (frontmatter stripped). + */ +export interface ProjectMemoryFile { + filename: string; + name: string; + description: string; + type: MemoryFileType | null; content: string; word_count: number; size_bytes: number; modified: string; - exists: boolean; } // ============================================================================ diff --git a/frontend/src/lib/components/ExpandablePrompt.svelte b/frontend/src/lib/components/ExpandablePrompt.svelte index 3b87ab07..e4a4ce3c 100644 --- a/frontend/src/lib/components/ExpandablePrompt.svelte +++ b/frontend/src/lib/components/ExpandablePrompt.svelte @@ -10,6 +10,7 @@ Check, Maximize2 } from 'lucide-svelte'; + import { markdownCopyButtons } from '$lib/actions/markdownCopyButtons'; import Modal from '$lib/components/ui/Modal.svelte'; import ImageAttachments from '$lib/components/ImageAttachments.svelte'; import type { ImageAttachment } from '$lib/api-types'; @@ -222,6 +223,7 @@ class="markdown-preview text-sm prompt-content {!isExpanded && needsExpansion ? 'prompt-preview' : ''}" + use:markdownCopyButtons={renderedContent} > {@html renderedContent} @@ -288,6 +290,7 @@ {/if}
{@html renderedContent}
diff --git a/frontend/src/lib/components/GlobalSessionCard.svelte b/frontend/src/lib/components/GlobalSessionCard.svelte index 62bad90f..863ffeed 100644 --- a/frontend/src/lib/components/GlobalSessionCard.svelte +++ b/frontend/src/lib/components/GlobalSessionCard.svelte @@ -31,6 +31,7 @@ liveSession?: LiveSessionSummary | null; toolSource?: 'main' | 'subagent' | 'both'; subagentHref?: string; + highlighted?: boolean; } let { @@ -38,7 +39,8 @@ compact = false, liveSession = null, toolSource, - subagentHref + subagentHref, + highlighted = false }: Props = $props(); const showSubagentBadge = $derived(toolSource === 'subagent' || toolSource === 'both'); @@ -136,6 +138,7 @@ group focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-base)] {hasLiveStatus && !isRecentlyEnded ? 'ring-1 ring-opacity-50' : ''} + {highlighted ? 'session-highlight' : ''} overflow-hidden " style=" diff --git a/frontend/src/lib/components/SessionCard.svelte b/frontend/src/lib/components/SessionCard.svelte index cdcab8b4..f652518a 100644 --- a/frontend/src/lib/components/SessionCard.svelte +++ b/frontend/src/lib/components/SessionCard.svelte @@ -13,6 +13,7 @@ sessionHasTitle, getSessionDisplayPrompt } from '$lib/utils'; + import { getSessionUrlIdentifier } from '$lib/utils/sessionIdentifier'; interface Props { session: SessionSummary; @@ -20,6 +21,7 @@ showBranch?: boolean; // Hide branch when inside branch accordion compact?: boolean; // Compact mode for grid view liveSession?: LiveSessionSummary | null; // Live session data for real-time status + highlighted?: boolean; } let { @@ -27,7 +29,8 @@ projectEncodedName, showBranch = true, compact = false, - liveSession = null + liveSession = null, + highlighted = false }: Props = $props(); // Determine status (default to completed if not specified) @@ -105,12 +108,8 @@ // Determine URL identifier: // - If session is part of a chain (chain_info exists), use UUID to disambiguate // - Otherwise, use slug for human-readable URLs, or UUID prefix as fallback - const isPartOfChain = $derived(session.chain_info !== undefined && session.chain_info !== null); - const urlIdentifier = $derived( - isPartOfChain - ? session.uuid.slice(0, 8) // Use UUID for chain sessions to avoid ambiguity - : displaySlug || session.uuid.slice(0, 8) - ); + // Shared with last-opened-highlight comparisons via sessionIdentifier helper. + const urlIdentifier = $derived(getSessionUrlIdentifier(session, liveSession)); // Build live status text for accessibility const liveStatusText = $derived( @@ -131,6 +130,7 @@ group focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-base)] {hasLiveStatus && !isRecentlyEnded ? 'ring-1 ring-opacity-50' : ''} + {highlighted ? 'session-highlight' : ''} overflow-hidden " style=" diff --git a/frontend/src/lib/components/agents/AgentViewer.svelte b/frontend/src/lib/components/agents/AgentViewer.svelte index a4b79082..cad0b4bf 100644 --- a/frontend/src/lib/components/agents/AgentViewer.svelte +++ b/frontend/src/lib/components/agents/AgentViewer.svelte @@ -3,6 +3,7 @@ import { Bot, Loader2, Check, Eye, Code, Clock, HardDrive, Copy, Layers } from 'lucide-svelte'; import { marked } from 'marked'; import DOMPurify from 'isomorphic-dompurify'; + import { markdownCopyButtons } from '$lib/actions/markdownCopyButtons'; import { formatDistanceToNow } from 'date-fns'; import { API_BASE } from '$lib/config'; import { formatFileSize } from '$lib/utils'; @@ -186,7 +187,7 @@ >Preview -
+
{@html renderedContent}
{/if} diff --git a/frontend/src/lib/components/commands/CommandsPanel.svelte b/frontend/src/lib/components/commands/CommandsPanel.svelte index d7489f9f..5b7ce8f5 100644 --- a/frontend/src/lib/components/commands/CommandsPanel.svelte +++ b/frontend/src/lib/components/commands/CommandsPanel.svelte @@ -7,6 +7,7 @@ import Modal from '$lib/components/ui/Modal.svelte'; import { cleanSkillName, getCommandColorVars, getCommandCategoryColorVars, getCommandCategoryLabel } from '$lib/utils'; import { API_BASE } from '$lib/config'; + import { markdownCopyButtons } from '$lib/actions/markdownCopyButtons'; interface Props { commands: CommandUsage[]; @@ -241,7 +242,7 @@ {modalError}
{:else} -
+
{@html renderedContent}
{/if} diff --git a/frontend/src/lib/components/memory/MemoryFilePanel.svelte b/frontend/src/lib/components/memory/MemoryFilePanel.svelte new file mode 100644 index 00000000..dd3f0382 --- /dev/null +++ b/frontend/src/lib/components/memory/MemoryFilePanel.svelte @@ -0,0 +1,284 @@ + + + + + + + +
+
+ {#if fileData} +
+ + {badgeLabel(fileData.type)} + + + {formatRelative(fileData.modified)} + +
+ + {fileData.name} + + + {fileData.filename} + + {:else if loading} + + Loading… + + + Loading memory file content + + {:else if error} + + Memory file + + + {error} + + {/if} +
+ + + +
+ + +
+ {#if loading && !fileData} +
+ +
+ {:else if error} +
+ +

{error}

+

+ {filename ?? ''} +

+ +
+ {:else if fileData} +
+ {@html renderedContent} +
+ {/if} +
+
+
+
+ + diff --git a/frontend/src/lib/components/memory/MemoryHoverCard.svelte b/frontend/src/lib/components/memory/MemoryHoverCard.svelte new file mode 100644 index 00000000..382da4fb --- /dev/null +++ b/frontend/src/lib/components/memory/MemoryHoverCard.svelte @@ -0,0 +1,102 @@ + + +{#if file && position} + +{/if} + + diff --git a/frontend/src/lib/components/memory/MemoryIndex.svelte b/frontend/src/lib/components/memory/MemoryIndex.svelte new file mode 100644 index 00000000..5408ae35 --- /dev/null +++ b/frontend/src/lib/components/memory/MemoryIndex.svelte @@ -0,0 +1,84 @@ + + +
+ {@html renderedContent} +
+ + diff --git a/frontend/src/lib/components/memory/MemoryOrphanList.svelte b/frontend/src/lib/components/memory/MemoryOrphanList.svelte new file mode 100644 index 00000000..cc999fa1 --- /dev/null +++ b/frontend/src/lib/components/memory/MemoryOrphanList.svelte @@ -0,0 +1,120 @@ + + +{#if files.length > 0} +
+ + + {#if expanded} +
    + {#each files as file (file.filename)} +
  • + +
  • + {/each} +
+ {/if} +
+{/if} diff --git a/frontend/src/lib/components/memory/MemoryViewer.svelte b/frontend/src/lib/components/memory/MemoryViewer.svelte index 60ca3c3b..c103fc97 100644 --- a/frontend/src/lib/components/memory/MemoryViewer.svelte +++ b/frontend/src/lib/components/memory/MemoryViewer.svelte @@ -1,11 +1,13 @@