diff --git a/CLAUDE.md b/CLAUDE.md index 8b278d0..26d74a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ uv tool install . --reinstall - Session IDs are provided directly by Claude Code (no generated IDs) - Hook handler reads JSON from stdin (provided by Claude Code) -- Tool name mapping is done via `TOOL_TYPE_MAP` in hook_handler.py +- Tool name mapping is done via `get_tool_type` in core/models/hook.py - Database uses raw SQL with migration support for flexibility - All timestamps are stored as ISO format strings @@ -127,9 +127,8 @@ echo '{"session_id": "test123", "transcript_path": "/tmp/transcript.jsonl", "too ## Adding New Tool Types -1. Add to `ToolType` enum in models.py -2. Update `TOOL_TYPE_MAP` in hook_handler.py -3. No database migration needed (sqlite-utils handles schema) +1. Add to `ToolType` enum in src/slopometry/core/models/hook.py +2. Update `get_tool_type` in src/slopometry/core/models/hook.py ## Experiment Tracking diff --git a/src/slopometry/cli.py b/src/slopometry/cli.py index 1dfa04b..678b7d7 100644 --- a/src/slopometry/cli.py +++ b/src/slopometry/cli.py @@ -4,6 +4,7 @@ import sys import warnings from importlib.metadata import version +from pathlib import Path # REASON: analyzed repos may contain invalid escape sequences that emit SyntaxWarnings during AST parsing warnings.filterwarnings("ignore", category=SyntaxWarning) @@ -53,45 +54,45 @@ def hook_handler() -> None: def hook_pre_tool_use() -> None: """Internal command for processing PreToolUse hook events.""" from slopometry.core.hook_handler import handle_hook - from slopometry.core.models.hook import HookEventType + from slopometry.core.protocol.adapters.claude import CLAUDE_HOOK_KIND_MAP - sys.exit(handle_hook(event_type_override=HookEventType.PRE_TOOL_USE)) + sys.exit(handle_hook(event_type_override=CLAUDE_HOOK_KIND_MAP["PreToolUse"])) @cli.command("hook-post-tool-use", hidden=True) def hook_post_tool_use() -> None: """Internal command for processing PostToolUse hook events.""" from slopometry.core.hook_handler import handle_hook - from slopometry.core.models.hook import HookEventType + from slopometry.core.protocol.adapters.claude import CLAUDE_HOOK_KIND_MAP - sys.exit(handle_hook(event_type_override=HookEventType.POST_TOOL_USE)) + sys.exit(handle_hook(event_type_override=CLAUDE_HOOK_KIND_MAP["PostToolUse"])) @cli.command("hook-notification", hidden=True) def hook_notification() -> None: """Internal command for processing Notification hook events.""" from slopometry.core.hook_handler import handle_hook - from slopometry.core.models.hook import HookEventType + from slopometry.core.protocol.adapters.claude import CLAUDE_HOOK_KIND_MAP - sys.exit(handle_hook(event_type_override=HookEventType.NOTIFICATION)) + sys.exit(handle_hook(event_type_override=CLAUDE_HOOK_KIND_MAP["Notification"])) @cli.command("hook-stop", hidden=True) def hook_stop() -> None: """Internal command for processing Stop hook events.""" from slopometry.core.hook_handler import handle_hook - from slopometry.core.models.hook import HookEventType + from slopometry.core.protocol.adapters.claude import CLAUDE_HOOK_KIND_MAP - sys.exit(handle_hook(event_type_override=HookEventType.STOP)) + sys.exit(handle_hook(event_type_override=CLAUDE_HOOK_KIND_MAP["Stop"])) @cli.command("hook-subagent-stop", hidden=True) def hook_subagent_stop() -> None: """Internal command for processing SubagentStop hook events.""" from slopometry.core.hook_handler import handle_hook - from slopometry.core.models.hook import HookEventType + from slopometry.core.protocol.adapters.claude import CLAUDE_HOOK_KIND_MAP - sys.exit(handle_hook(event_type_override=HookEventType.SUBAGENT_STOP)) + sys.exit(handle_hook(event_type_override=CLAUDE_HOOK_KIND_MAP["SubagentStop"])) @cli.command("hook-opencode", hidden=True) @@ -113,6 +114,59 @@ def hook_opencode(event_type: str) -> None: sys.exit(handle_opencode_hook(event_type)) +@cli.command("ingest") +@click.option("--source", required=True, help="Agent tool that produced the events, e.g. 'mmkr'.") +@click.option( + "--file", + "input_file", + type=click.Path(exists=True, dir_okay=False, path_type=Path), + default=None, + help="JSONL file of event envelopes; reads from stdin when omitted.", +) +@click.option( + "--working-directory", + type=click.Path(exists=True, file_okay=False, path_type=Path), + default=None, + help="Working directory recorded on ingested events; defaults to the current directory.", +) +def ingest(source: str, input_file: Path | None, working_directory: Path | None) -> None: + """Ingest hook-protocol event envelopes (JSONL) from any agent tool. + + Each line must be an EventEnvelope JSON object; see + slopometry.core.protocol.schema for the stable schema. Re-ingesting the + same (source, event_id) pairs is an idempotent no-op. + """ + import json + + from pydantic import ValidationError + + from slopometry.core.protocol.ingest import ingest_envelopes + from slopometry.core.protocol.schema import EventEnvelope + + content = input_file.read_text() if input_file else sys.stdin.read() + envelopes: list[EventEnvelope] = [] + for line_number, line in enumerate(content.splitlines(), start=1): + if not line.strip(): + continue + try: + envelope = EventEnvelope.model_validate(json.loads(line)) + except (json.JSONDecodeError, ValidationError) as e: + console.print(f"[red]Invalid envelope at line {line_number}: {e}[/red]") + sys.exit(2) + if envelope.source != source: + console.print( + f"[red]Envelope source '{envelope.source}' at line {line_number} does not match --source '{source}'[/red]" + ) + sys.exit(2) + envelopes.append(envelope) + + report = ingest_envelopes(envelopes, working_directory=str(working_directory) if working_directory else None) + console.print( + f"[green]Ingested {report.inserted} events from source '{source}'" + f" (skipped {report.skipped_duplicates} duplicates)[/green]" + ) + + @cli.command("shell-completion") @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"])) def shell_completion(shell: str) -> None: diff --git a/src/slopometry/core/database.py b/src/slopometry/core/database.py index 6b85fcd..cb4bdcb 100644 --- a/src/slopometry/core/database.py +++ b/src/slopometry/core/database.py @@ -15,10 +15,11 @@ from slopometry.core.models.complexity import ComplexityDelta, ExtendedComplexityMetrics from slopometry.core.models.display import LeaderboardEntry, SessionDisplayData from slopometry.core.models.experiment import ExperimentProgress, ExperimentRun, ExperimentStatus, FeatureBoundary -from slopometry.core.models.hook import GitState, HookEvent, HookEventType, Project, ProjectSource, ToolType +from slopometry.core.models.hook import GitState, HookEvent, Project, ProjectSource, ToolType from slopometry.core.models.session import BehavioralPatterns, ContextCoverage, PlanEvolution, SessionStatistics from slopometry.core.models.user_story import NextFeaturePrediction, UserStory, UserStoryEntry from slopometry.core.plan_analyzer import PlanAnalyzer +from slopometry.core.protocol.kinds import EventKind, KnownSource from slopometry.core.settings import settings @@ -87,7 +88,8 @@ def _create_tables(self) -> None: project_source TEXT, transcript_path TEXT, source TEXT DEFAULT 'claude_code', - parent_session_id TEXT + parent_session_id TEXT, + event_id TEXT ) """) conn.execute(""" @@ -111,6 +113,10 @@ def _create_tables(self) -> None: ON hook_events(session_id, event_type, source) """) + # NOTE: the (source, event_id) unique partial index is created by + # Migration019CanonicalEventKinds; it must not be created here since + # _create_tables runs before migrations on pre-existing databases. + conn.execute(""" CREATE TABLE IF NOT EXISTS experiment_runs ( id TEXT PRIMARY KEY, @@ -320,6 +326,26 @@ def _create_tables(self) -> None: conn.commit() + def has_event(self, source: str, event_id: str) -> bool: + """Check whether an event with the given (source, event_id) is already stored. + + Used by envelope ingestion to make backfills idempotent.""" + with self._get_db_connection() as conn: + row = conn.execute( + "SELECT 1 FROM hook_events WHERE source = ? AND event_id = ? LIMIT 1", + (source, event_id), + ).fetchone() + return row is not None + + def get_max_sequence_number(self, session_id: str) -> int: + """Get the highest stored sequence number for a session (0 when the session has no events).""" + with self._get_db_connection() as conn: + row = conn.execute( + "SELECT COALESCE(MAX(sequence_number), 0) FROM hook_events WHERE session_id = ?", + (session_id,), + ).fetchone() + return row[0] + def save_event(self, event: HookEvent) -> int: """Save a hook event to the database.""" with self._get_db_connection() as conn: @@ -327,11 +353,11 @@ def save_event(self, event: HookEvent) -> int: """ INSERT INTO hook_events ( session_id, event_type, timestamp, sequence_number, - tool_name, tool_type, metadata, duration_ms, - exit_code, error_message, git_state, working_directory, - project_name, project_source, transcript_path, - source, parent_session_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + tool_name, tool_type, metadata, duration_ms, + exit_code, error_message, git_state, working_directory, + project_name, project_source, transcript_path, + source, parent_session_id, event_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( event.session_id, @@ -348,10 +374,11 @@ def save_event(self, event: HookEvent) -> int: event.working_directory, event.project.name if event.project else None, event.project.source.value if event.project else None, - event.transcript_path, - event.source.value, - event.parent_session_id, - ), + event.transcript_path, + event.source, + event.parent_session_id, + event.event_id, + ), ) return cursor.lastrowid or 0 @@ -384,18 +411,13 @@ def get_session_events(self, session_id: str) -> list[HookEvent]: source=ProjectSource(row["project_source"]), ) - # Handle source column (may be NULL for pre-migration rows) - from slopometry.core.models.hook import EventSource - - source_val = row["source"] if "source" in row.keys() else None - source = EventSource(source_val) if source_val else EventSource.CLAUDE_CODE parent_session_id = row["parent_session_id"] if "parent_session_id" in row.keys() else None events.append( HookEvent( id=row["id"], session_id=row["session_id"], - event_type=HookEventType(row["event_type"]), + event_type=EventKind(row["event_type"]), timestamp=datetime.fromisoformat(row["timestamp"]), sequence_number=row["sequence_number"], tool_name=row["tool_name"], @@ -407,17 +429,18 @@ def get_session_events(self, session_id: str) -> list[HookEvent]: git_state=git_state, working_directory=working_directory, project=project, - transcript_path=row["transcript_path"], - source=source, - parent_session_id=parent_session_id, - ) + transcript_path=row["transcript_path"], + source=row["source"], + parent_session_id=parent_session_id, + event_id=row["event_id"], + ) ) return events def get_session_source(self, session_id: str) -> str: - """Get the source agent tool for a session ('claude_code' or 'opencode'). + """Get the source agent tool string for a session. - Returns 'claude_code' for pre-migration sessions or if the column is missing. + Returns 'claude_code' for pre-migration sessions stored before the source column existed. """ with self._get_db_connection() as conn: conn.row_factory = sqlite3.Row @@ -427,7 +450,7 @@ def get_session_source(self, session_id: str) -> str: ).fetchone() if row and row["source"]: return row["source"] - return "claude_code" + return str(KnownSource.CLAUDE_CODE) def get_opencode_transcript(self, session_id: str) -> list[dict] | None: """Extract the transcript from an OpenCode session's Stop event metadata. @@ -440,10 +463,10 @@ def get_opencode_transcript(self, session_id: str) -> list[dict] | None: row = conn.execute( """ SELECT metadata FROM hook_events - WHERE session_id = ? AND event_type = 'Stop' AND source = 'opencode' + WHERE session_id = ? AND event_type = ? AND source = ? ORDER BY sequence_number DESC LIMIT 1 """, - (session_id,), + (session_id, EventKind.STOP, KnownSource.OPENCODE), ).fetchone() if not row: return None @@ -563,7 +586,7 @@ def get_session_statistics(self, session_id: str) -> SessionStatistics | None: (session_id,), ).fetchall() - events_by_type = {HookEventType(row["event_type"]): row["count"] for row in event_type_rows} + events_by_type = {EventKind(row["event_type"]): row["count"] for row in event_type_rows} tool_usage_rows = conn.execute( """ @@ -834,12 +857,12 @@ def _calculate_plan_evolution(self, session_id: str) -> PlanEvolution: WHERE session_id = ? AND event_type IN (?, ?) ORDER BY sequence_number """, - (session_id, HookEventType.POST_TOOL_USE.value, HookEventType.TODO_UPDATED.value), + (session_id, EventKind.TOOL_RESULT.value, EventKind.TODO_UPDATED.value), ).fetchall() # Check if this session has TODO_UPDATED events (OpenCode). # If so, skip POST_TOOL_USE todowrite events to avoid duplicate analysis. - has_todo_updated = any(row["event_type"] == HookEventType.TODO_UPDATED.value for row in rows) + has_todo_updated = any(row["event_type"] == EventKind.TODO_UPDATED.value for row in rows) for row in rows: timestamp = datetime.fromisoformat(row["timestamp"]) @@ -849,7 +872,7 @@ def _calculate_plan_evolution(self, session_id: str) -> PlanEvolution: metadata = json.loads(row["metadata"]) if row["metadata"] else {} - if event_type == HookEventType.TODO_UPDATED.value: + if event_type == EventKind.TODO_UPDATED.value: # OpenCode todo.updated bus event — canonical source for OpenCode todos todos = metadata.get("todos", []) if todos: diff --git a/src/slopometry/core/event_capture.py b/src/slopometry/core/event_capture.py new file mode 100644 index 0000000..78bbad2 --- /dev/null +++ b/src/slopometry/core/event_capture.py @@ -0,0 +1,75 @@ +"""Shared event capture factory for all event ingestion paths. + +Owns the assembly of a stored `HookEvent`: sequence numbering, git state, +project detection, tool classification, and persistence. Used by the Claude +and OpenCode live handlers and by the envelope ingest path, so no fourth +construction site for stored events should appear. +""" + +from datetime import datetime +from pathlib import Path + +from slopometry.core.database import EventDatabase, SessionManager +from slopometry.core.git_tracker import GitTracker +from slopometry.core.models.hook import HookEvent, get_tool_type +from slopometry.core.project_tracker import ProjectTracker +from slopometry.core.protocol.kinds import EventKind + + +def capture_event( + db: EventDatabase, + session_manager: SessionManager, + working_directory: str, + *, + session_id: str, + kind: EventKind, + source: str, + metadata: dict, + sequence_number: int | None = None, + timestamp: datetime | None = None, + tool_name: str | None = None, + duration_ms: int | None = None, + exit_code: int | None = None, + error_message: str | None = None, + transcript_path: str | None = None, + parent_session_id: str | None = None, + event_id: str | None = None, + capture_session_context: bool = True, +) -> HookEvent: + """Build and persist one stored event for a session. + + When `capture_session_context` is True (live harness paths), git state is + recorded on the session's first event and on stop events, and the project + is detected from the working directory. Envelope ingestion passes False + because the ingester's repository is unrelated to the traced session. + """ + sequence_number = sequence_number or session_manager.get_next_sequence_number(session_id) + + git_state = None + project = None + if capture_session_context: + if sequence_number == 1 or kind in (EventKind.STOP, EventKind.SUBAGENT_STOP): + git_state = GitTracker().get_git_state() + project = ProjectTracker(working_dir=Path(working_directory)).get_project() + + event = HookEvent( + session_id=session_id, + event_type=kind, + timestamp=timestamp or datetime.now(), + sequence_number=sequence_number, + tool_name=tool_name, + tool_type=get_tool_type(tool_name) if tool_name else None, + metadata=metadata, + duration_ms=duration_ms, + exit_code=exit_code, + error_message=error_message, + git_state=git_state, + working_directory=working_directory, + project=project, + transcript_path=transcript_path, + source=source, + parent_session_id=parent_session_id, + event_id=event_id, + ) + event.id = db.save_event(event) + return event diff --git a/src/slopometry/core/hook_handler.py b/src/slopometry/core/hook_handler.py index 3063bcc..3bf3dc1 100644 --- a/src/slopometry/core/hook_handler.py +++ b/src/slopometry/core/hook_handler.py @@ -9,24 +9,22 @@ from pathlib import Path from slopometry.core.database import EventDatabase, SessionManager +from slopometry.core.event_capture import capture_event from slopometry.core.git_tracker import GitTracker from slopometry.core.lock import SlopometryLock from slopometry.core.models.complexity import ComplexityDelta, ExtendedComplexityMetrics from slopometry.core.models.hook import ( FeedbackCacheState, - HookEvent, - HookEventType, HookInputUnion, NotificationInput, PostToolUseInput, PreToolUseInput, StopInput, SubagentStopInput, - ToolType, ) from slopometry.core.models.session import ContextCoverage from slopometry.core.models.smell import ScopedSmell -from slopometry.core.project_tracker import ProjectTracker +from slopometry.core.protocol.kinds import EventKind, KnownSource from slopometry.core.settings import settings from slopometry.core.working_tree_state import WorkingTreeStateCalculator from slopometry.display.formatters import truncate_path @@ -34,57 +32,6 @@ logger = logging.getLogger(__name__) -def get_tool_type(tool_name: str) -> ToolType: - """Map tool name to ToolType enum.""" - tool_map = { - "bash": ToolType.BASH, - "read": ToolType.READ, - "write": ToolType.WRITE, - "edit": ToolType.EDIT, - "multiedit": ToolType.MULTI_EDIT, - "grep": ToolType.GREP, - "glob": ToolType.GLOB, - "ls": ToolType.LS, - "task": ToolType.TASK, - "todoread": ToolType.TODO_READ, - "todowrite": ToolType.TODO_WRITE, - "taskcreate": ToolType.TASK_CREATE, - "taskupdate": ToolType.TASK_UPDATE, - "tasklist": ToolType.TASK_LIST, - "taskget": ToolType.TASK_GET, - "webfetch": ToolType.WEB_FETCH, - "websearch": ToolType.WEB_SEARCH, - "notebookread": ToolType.NOTEBOOK_READ, - "notebookedit": ToolType.NOTEBOOK_EDIT, - "exit_plan_mode": ToolType.EXIT_PLAN_MODE, - "mcp__ide__getdiagnostics": ToolType.MCP_IDE_GET_DIAGNOSTICS, - "mcp__ide__executecode": ToolType.MCP_IDE_EXECUTE_CODE, - "mcp__ide__getworkspaceinfo": ToolType.MCP_IDE_GET_WORKSPACE_INFO, - "mcp__ide__getfilecontents": ToolType.MCP_IDE_GET_FILE_CONTENTS, - "mcp__ide__createfile": ToolType.MCP_IDE_CREATE_FILE, - "mcp__ide__deletefile": ToolType.MCP_IDE_DELETE_FILE, - "mcp__ide__renamefile": ToolType.MCP_IDE_RENAME_FILE, - "mcp__ide__searchfiles": ToolType.MCP_IDE_SEARCH_FILES, - "mcp__filesystem__read": ToolType.MCP_FILESYSTEM_READ, - "mcp__filesystem__write": ToolType.MCP_FILESYSTEM_WRITE, - "mcp__filesystem__list": ToolType.MCP_FILESYSTEM_LIST, - "mcp__database__query": ToolType.MCP_DATABASE_QUERY, - "mcp__database__schema": ToolType.MCP_DATABASE_SCHEMA, - "mcp__web__scrape": ToolType.MCP_WEB_SCRAPE, - "mcp__web__search": ToolType.MCP_WEB_SEARCH, - "mcp__github__getrepo": ToolType.MCP_GITHUB_GET_REPO, - "mcp__github__createissue": ToolType.MCP_GITHUB_CREATE_ISSUE, - "mcp__github__listissues": ToolType.MCP_GITHUB_LIST_ISSUES, - "mcp__slack__sendmessage": ToolType.MCP_SLACK_SEND_MESSAGE, - "mcp__slack__listchannels": ToolType.MCP_SLACK_LIST_CHANNELS, - } - - if tool_name.lower().startswith("mcp__") and tool_name.lower() not in tool_map: - return ToolType.MCP_OTHER - - return tool_map.get(tool_name.lower(), ToolType.OTHER) - - def parse_hook_input(raw_data: dict) -> HookInputUnion: """Parse and validate hook input using appropriate Pydantic model. @@ -133,7 +80,7 @@ def _read_stdin_with_timeout(timeout_seconds: float = 5.0) -> str: return sys.stdin.read().strip() -def handle_hook(event_type_override: HookEventType | None = None) -> int: +def handle_hook(event_type_override: EventKind | None = None) -> int: """Main hook handler function. Reads and parses stdin BEFORE acquiring the lock to prevent hung pipes @@ -167,7 +114,7 @@ def handle_hook(event_type_override: HookEventType | None = None) -> int: def _handle_hook_internal( - event_type_override: HookEventType | None, + event_type_override: EventKind | None, parsed_input: HookInputUnion, raw_data: dict, ) -> int: @@ -183,49 +130,32 @@ def _handle_hook_internal( session_id = parsed_input.session_id - session_manager = SessionManager() - sequence_number = session_manager.get_next_sequence_number(session_id) - - git_tracker = GitTracker() - git_state = None - match (event_type, sequence_number): - case (HookEventType.PRE_TOOL_USE, 1) | (HookEventType.STOP, 1): - git_state = git_tracker.get_git_state() - case (HookEventType.STOP, _): - git_state = git_tracker.get_git_state() - - working_directory = os.getcwd() - project_tracker = ProjectTracker(working_dir=Path(working_directory)) - project = project_tracker.get_project() - - event = HookEvent( + tool_name = None + duration_ms = None + exit_code = None + error_message = None + if isinstance(parsed_input, PreToolUseInput | PostToolUseInput): + tool_name = parsed_input.tool_name + if isinstance(parsed_input, PostToolUseInput) and isinstance(parsed_input.tool_response, dict): + duration_ms = parsed_input.tool_response.get("duration_ms") + exit_code = parsed_input.tool_response.get("exit_code") + error_message = parsed_input.tool_response.get("error") + + event = capture_event( + EventDatabase(), + SessionManager(), + os.getcwd(), session_id=session_id, - event_type=event_type, - sequence_number=sequence_number, + kind=event_type, + source=KnownSource.CLAUDE_CODE, metadata=raw_data, - git_state=git_state, - working_directory=working_directory, - project=project, + tool_name=tool_name, + duration_ms=duration_ms, + exit_code=exit_code, + error_message=error_message, transcript_path=parsed_input.transcript_path, ) - if isinstance(parsed_input, PreToolUseInput | PostToolUseInput): - event.tool_name = parsed_input.tool_name - event.tool_type = get_tool_type(parsed_input.tool_name) - - if isinstance(parsed_input, PostToolUseInput): - if isinstance(parsed_input.tool_response, dict): - event.duration_ms = parsed_input.tool_response.get("duration_ms") - event.exit_code = parsed_input.tool_response.get("exit_code") - event.error_message = parsed_input.tool_response.get("error") - else: - event.duration_ms = None - event.exit_code = None - event.error_message = None - - db = EventDatabase() - db.save_event(event) - if settings.enable_complexity_analysis and isinstance(parsed_input, StopInput | SubagentStopInput): return handle_stop_event(session_id, parsed_input) @@ -234,7 +164,7 @@ def _handle_hook_internal( "slopometry_event": { "session_id": session_id, "event_type": event_type.value, - "sequence_number": sequence_number, + "sequence_number": event.sequence_number, "tool_name": event.tool_name, "tool_type": event.tool_type.value if event.tool_type else None, "timestamp": event.timestamp.isoformat(), @@ -904,19 +834,19 @@ def format_code_smell_feedback( return "", False, False -def detect_event_type_from_parsed(parsed_input: HookInputUnion) -> HookEventType: - """Detect event type from parsed input model.""" +def detect_event_type_from_parsed(parsed_input: HookInputUnion) -> EventKind: + """Detect canonical event kind from parsed Claude Code input model.""" match parsed_input: case PreToolUseInput(): - return HookEventType.PRE_TOOL_USE + return EventKind.TOOL_CALL case PostToolUseInput(): - return HookEventType.POST_TOOL_USE + return EventKind.TOOL_RESULT case NotificationInput(): - return HookEventType.NOTIFICATION + return EventKind.NOTIFICATION case StopInput(): - return HookEventType.STOP + return EventKind.STOP case SubagentStopInput(): - return HookEventType.SUBAGENT_STOP + return EventKind.SUBAGENT_STOP case _: raise ValueError(f"Unknown input type: {type(parsed_input)}") diff --git a/src/slopometry/core/migrations.py b/src/slopometry/core/migrations.py index 0c4182c..bab9744 100644 --- a/src/slopometry/core/migrations.py +++ b/src/slopometry/core/migrations.py @@ -508,6 +508,55 @@ def up(self, conn: sqlite3.Connection) -> None: ) + +class Migration019CanonicalEventKinds(Migration): + """Adopt the extracted hook protocol on stored hook_events. + + - Remaps every non-canonical event_type value onto canonical EventKind + values, covering both the legacy PascalCase wire names (e.g. + 'PreToolUse' -> 'tool_call') and the started/completed dialect produced + by a parallel abstract-protocol implementation. + - Backfills NULL source values to 'claude_code'. + - Adds the event_id column with a partial unique index on + (source, event_id) so envelope backfills are idempotent. + + Numbered 019 because a parallel implementation already claimed 016-018 on + live databases. + """ + + @property + def version(self) -> str: + return "019" + + @property + def description(self) -> str: + return "Canonical event kinds, source backfill, and event_id idempotency on hook_events" + + def up(self, conn: sqlite3.Connection) -> None: + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='hook_events'") + if not cursor.fetchone(): + return + + columns = {row[1] for row in conn.execute("PRAGMA table_info(hook_events)").fetchall()} + from slopometry.core.protocol.kinds import ALT_DIALECT_EVENT_TYPE_MAP, LEGACY_EVENT_TYPE_MAP + + if "event_type" in columns: + for legacy_value, kind in {**LEGACY_EVENT_TYPE_MAP, **ALT_DIALECT_EVENT_TYPE_MAP}.items(): + conn.execute("UPDATE hook_events SET event_type = ? WHERE event_type = ?", (kind, legacy_value)) + + if "source" in columns: + conn.execute("UPDATE hook_events SET source = 'claude_code' WHERE source IS NULL") + + if "event_id" not in columns: + conn.execute("ALTER TABLE hook_events ADD COLUMN event_id TEXT") + + conn.execute(""" + CREATE UNIQUE INDEX IF NOT EXISTS idx_hook_events_source_event_id + ON hook_events(source, event_id) + WHERE event_id IS NOT NULL + """) + + class MigrationRunner: """Manages database migrations.""" @@ -528,6 +577,7 @@ def __init__(self, db_path: Path): Migration012AddNFPObjectiveToExperimentRuns(), Migration013AddSourceAndParentSession(), Migration014AddBehavioralPatternHistory(), + Migration019CanonicalEventKinds(), ] @contextmanager diff --git a/src/slopometry/core/models/__init__.py b/src/slopometry/core/models/__init__.py index 5699124..9b356ee 100644 --- a/src/slopometry/core/models/__init__.py +++ b/src/slopometry/core/models/__init__.py @@ -63,7 +63,6 @@ AgentTool, AnalysisSource, GitState, - HookEventType, Project, ProjectLanguage, ToolType, @@ -109,7 +108,6 @@ "AgentTool", "AnalysisSource", "GitState", - "HookEventType", "Project", "ProjectLanguage", "ToolType", diff --git a/src/slopometry/core/models/hook.py b/src/slopometry/core/models/hook.py index 16c84e6..7ae2495 100644 --- a/src/slopometry/core/models/hook.py +++ b/src/slopometry/core/models/hook.py @@ -1,4 +1,4 @@ -"""Hook-related models for Claude Code integration.""" +"""Hook-related models for agent session capture.""" from datetime import datetime from enum import Enum @@ -6,6 +6,8 @@ from pydantic import BaseModel, Field +from slopometry.core.protocol.kinds import EventKind, KnownSource + class AgentTool(str, Enum): """Agent tool that produced the session.""" @@ -35,25 +37,55 @@ class Project(BaseModel): source: ProjectSource -class EventSource(str, Enum): - """Source agent tool that generated the event.""" - - CLAUDE_CODE = "claude_code" - OPENCODE = "opencode" - - -class HookEventType(str, Enum): - """Types of hook events from Claude Code and OpenCode.""" - - PRE_TOOL_USE = "PreToolUse" - POST_TOOL_USE = "PostToolUse" - NOTIFICATION = "Notification" - STOP = "Stop" - SUBAGENT_STOP = "SubagentStop" - # OpenCode-specific event types - TODO_UPDATED = "TodoUpdated" - MESSAGE_UPDATED = "MessageUpdated" - SUBAGENT_START = "SubagentStart" +def get_tool_type(tool_name: str) -> "ToolType": + """Map tool name to ToolType enum.""" + tool_map = { + "bash": ToolType.BASH, + "read": ToolType.READ, + "write": ToolType.WRITE, + "edit": ToolType.EDIT, + "multiedit": ToolType.MULTI_EDIT, + "grep": ToolType.GREP, + "glob": ToolType.GLOB, + "ls": ToolType.LS, + "task": ToolType.TASK, + "todoread": ToolType.TODO_READ, + "todowrite": ToolType.TODO_WRITE, + "taskcreate": ToolType.TASK_CREATE, + "taskupdate": ToolType.TASK_UPDATE, + "tasklist": ToolType.TASK_LIST, + "taskget": ToolType.TASK_GET, + "webfetch": ToolType.WEB_FETCH, + "websearch": ToolType.WEB_SEARCH, + "notebookread": ToolType.NOTEBOOK_READ, + "notebookedit": ToolType.NOTEBOOK_EDIT, + "exit_plan_mode": ToolType.EXIT_PLAN_MODE, + "mcp__ide__getdiagnostics": ToolType.MCP_IDE_GET_DIAGNOSTICS, + "mcp__ide__executecode": ToolType.MCP_IDE_EXECUTE_CODE, + "mcp__ide__getworkspaceinfo": ToolType.MCP_IDE_GET_WORKSPACE_INFO, + "mcp__ide__getfilecontents": ToolType.MCP_IDE_GET_FILE_CONTENTS, + "mcp__ide__createfile": ToolType.MCP_IDE_CREATE_FILE, + "mcp__ide__deletefile": ToolType.MCP_IDE_DELETE_FILE, + "mcp__ide__renamefile": ToolType.MCP_IDE_RENAME_FILE, + "mcp__ide__searchfiles": ToolType.MCP_IDE_SEARCH_FILES, + "mcp__filesystem__read": ToolType.MCP_FILESYSTEM_READ, + "mcp__filesystem__write": ToolType.MCP_FILESYSTEM_WRITE, + "mcp__filesystem__list": ToolType.MCP_FILESYSTEM_LIST, + "mcp__database__query": ToolType.MCP_DATABASE_QUERY, + "mcp__database__schema": ToolType.MCP_DATABASE_SCHEMA, + "mcp__web__scrape": ToolType.MCP_WEB_SCRAPE, + "mcp__web__search": ToolType.MCP_WEB_SEARCH, + "mcp__github__getrepo": ToolType.MCP_GITHUB_GET_REPO, + "mcp__github__createissue": ToolType.MCP_GITHUB_CREATE_ISSUE, + "mcp__github__listissues": ToolType.MCP_GITHUB_LIST_ISSUES, + "mcp__slack__sendmessage": ToolType.MCP_SLACK_SEND_MESSAGE, + "mcp__slack__listchannels": ToolType.MCP_SLACK_LIST_CHANNELS, + } + + if tool_name.lower().startswith("mcp__") and tool_name.lower() not in tool_map: + return ToolType.MCP_OTHER + + return tool_map.get(tool_name.lower(), ToolType.OTHER) class ToolType(str, Enum): @@ -146,7 +178,7 @@ class HookEvent(BaseModel): id: int | None = None session_id: str - event_type: HookEventType + event_type: EventKind timestamp: datetime = Field(default_factory=datetime.now) sequence_number: int tool_name: str | None = None @@ -159,8 +191,16 @@ class HookEvent(BaseModel): working_directory: str project: Project | None = None transcript_path: str | None = None - source: EventSource = Field(default=EventSource.CLAUDE_CODE, description="Agent tool that generated this event") + source: str = Field( + default=KnownSource.CLAUDE_CODE, + min_length=1, + description="Agent tool that generated this event; open string, built-ins in KnownSource", + ) parent_session_id: str | None = Field(default=None, description="Parent session ID for subagent sessions") + event_id: str | None = Field( + default=None, + description="Source-unique event id from envelope ingestion; (source, event_id) is unique for idempotent backfills", + ) class PreToolUseInput(BaseModel): diff --git a/src/slopometry/core/models/session.py b/src/slopometry/core/models/session.py index e1c10c4..1b065f0 100644 --- a/src/slopometry/core/models/session.py +++ b/src/slopometry/core/models/session.py @@ -5,7 +5,8 @@ from pydantic import BaseModel, Field from slopometry.core.models.complexity import ComplexityDelta, ExtendedComplexityMetrics -from slopometry.core.models.hook import AgentTool, GitState, HookEventType, Project, ToolType +from slopometry.core.models.hook import AgentTool, GitState, Project, ToolType +from slopometry.core.protocol.kinds import EventKind class TodoItem(BaseModel): @@ -248,7 +249,7 @@ class SessionStatistics(BaseModel): end_time: datetime | None = None total_events: int = 0 working_directory: str - events_by_type: dict[HookEventType, int] = Field(default_factory=dict) + events_by_type: dict[EventKind, int] = Field(default_factory=dict) tool_usage: dict[ToolType, int] = Field(default_factory=dict) error_count: int = 0 total_duration_ms: int = 0 diff --git a/src/slopometry/core/opencode_handler.py b/src/slopometry/core/opencode_handler.py index b9e88d4..b561558 100644 --- a/src/slopometry/core/opencode_handler.py +++ b/src/slopometry/core/opencode_handler.py @@ -12,14 +12,8 @@ import sys from slopometry.core.database import EventDatabase, SessionManager -from slopometry.core.git_tracker import GitTracker +from slopometry.core.event_capture import capture_event from slopometry.core.lock import SlopometryLock -from slopometry.core.models.hook import ( - EventSource, - HookEvent, - HookEventType, - ToolType, -) from slopometry.core.models.opencode import ( OpenCodeMessageEvent, OpenCodeSessionEvent, @@ -27,33 +21,12 @@ OpenCodeTodoEvent, OpenCodeToolEvent, ) -from slopometry.core.project_tracker import ProjectTracker +from slopometry.core.protocol.adapters.opencode import OPENCODE_EVENT_KIND_MAP +from slopometry.core.protocol.kinds import KnownSource from slopometry.core.settings import settings logger = logging.getLogger(__name__) -# Map OpenCode event type strings to HookEventType -EVENT_TYPE_MAP: dict[str, HookEventType] = { - "pre_tool_use": HookEventType.PRE_TOOL_USE, - "post_tool_use": HookEventType.POST_TOOL_USE, - "stop": HookEventType.STOP, - "subagent_stop": HookEventType.SUBAGENT_STOP, - "subagent_start": HookEventType.SUBAGENT_START, - "todo_updated": HookEventType.TODO_UPDATED, - "message_updated": HookEventType.MESSAGE_UPDATED, -} - - -def get_tool_type(tool_name: str) -> ToolType: - """Map OpenCode tool name to ToolType enum. - - OpenCode uses the same tool names as Claude Code (Bash, Read, Edit, etc.) - plus some OpenCode-specific ones. - """ - from slopometry.core.hook_handler import get_tool_type as cc_get_tool_type - - return cc_get_tool_type(tool_name) - def parse_opencode_event( event_type: str, raw_data: dict @@ -147,53 +120,33 @@ def _handle_opencode_internal( Exit code. """ try: - hook_event_type = EVENT_TYPE_MAP.get(event_type) - if not hook_event_type: - if settings.debug_mode: - print(f"Slopometry: Unknown event type '{event_type}'", file=sys.stderr) - return 0 + hook_event_type = OPENCODE_EVENT_KIND_MAP[event_type] # Extract session_id from the parsed event session_id = _get_session_id(parsed_event) if not session_id: return 0 - session_manager = SessionManager() - sequence_number = session_manager.get_next_sequence_number(session_id) - - working_directory = os.getcwd() - project_tracker = ProjectTracker(working_dir=__import__("pathlib").Path(working_directory)) - project = project_tracker.get_project() - - # Get git state for first event or stop events - git_tracker = GitTracker() - git_state = None - if sequence_number == 1 or event_type in ("stop", "subagent_stop"): - git_state = git_tracker.get_git_state() + tool_name = None + duration_ms = None + if isinstance(parsed_event, OpenCodeToolEvent): + tool_name = parsed_event.tool + if event_type == "post_tool_use": + duration_ms = parsed_event.duration_ms - # Build the HookEvent - event = HookEvent( + event = capture_event( + EventDatabase(), + SessionManager(), + os.getcwd(), session_id=session_id, - event_type=hook_event_type, - sequence_number=sequence_number, + kind=hook_event_type, + source=KnownSource.OPENCODE, metadata=raw_data, - git_state=git_state, - working_directory=working_directory, - project=project, - source=EventSource.OPENCODE, + tool_name=tool_name, + duration_ms=duration_ms, parent_session_id=_get_parent_id(parsed_event), ) - # Set tool-specific fields for tool events - if isinstance(parsed_event, OpenCodeToolEvent): - event.tool_name = parsed_event.tool - event.tool_type = get_tool_type(parsed_event.tool) - if event_type == "post_tool_use": - event.duration_ms = parsed_event.duration_ms - - db = EventDatabase() - db.save_event(event) - # Handle stop events with feedback if event_type in ("stop", "subagent_stop") and settings.enable_complexity_analysis: return _handle_opencode_stop(session_id, parsed_event, event_type) @@ -204,7 +157,7 @@ def _handle_opencode_internal( "session_id": session_id, "event_type": event_type, "hook_event_type": hook_event_type.value, - "sequence_number": sequence_number, + "sequence_number": event.sequence_number, "source": "opencode", } } diff --git a/src/slopometry/core/protocol/__init__.py b/src/slopometry/core/protocol/__init__.py new file mode 100644 index 0000000..65eabb5 --- /dev/null +++ b/src/slopometry/core/protocol/__init__.py @@ -0,0 +1,15 @@ +"""Extracted hook protocol: harness-independent event ingestion. + +- `kinds`: canonical `EventKind` taxonomy and open `KnownSource` constants. +- `schema`: `EventEnvelope`, the stable external contract for agent tools. +- `ingest`: expansion and idempotent storage of envelope batches. +- `adapters`: mappings from harness-specific event names to canonical kinds. + +`ingest` is intentionally not re-exported here to avoid a circular import +with the database layer; import it from `slopometry.core.protocol.ingest`. +""" + +from slopometry.core.protocol.kinds import EventKind, KnownSource +from slopometry.core.protocol.schema import EventEnvelope + +__all__ = ["EventEnvelope", "EventKind", "KnownSource"] diff --git a/src/slopometry/core/protocol/adapters/__init__.py b/src/slopometry/core/protocol/adapters/__init__.py new file mode 100644 index 0000000..930b691 --- /dev/null +++ b/src/slopometry/core/protocol/adapters/__init__.py @@ -0,0 +1 @@ +"""Mappings from harness-specific event names to canonical event kinds.""" diff --git a/src/slopometry/core/protocol/adapters/claude.py b/src/slopometry/core/protocol/adapters/claude.py new file mode 100644 index 0000000..a5aa358 --- /dev/null +++ b/src/slopometry/core/protocol/adapters/claude.py @@ -0,0 +1,11 @@ +"""Claude Code adapter: maps harness hook names onto the canonical taxonomy.""" + +from slopometry.core.protocol.kinds import EventKind + +CLAUDE_HOOK_KIND_MAP: dict[str, EventKind] = { + "PreToolUse": EventKind.TOOL_CALL, + "PostToolUse": EventKind.TOOL_RESULT, + "Notification": EventKind.NOTIFICATION, + "Stop": EventKind.STOP, + "SubagentStop": EventKind.SUBAGENT_STOP, +} diff --git a/src/slopometry/core/protocol/adapters/opencode.py b/src/slopometry/core/protocol/adapters/opencode.py new file mode 100644 index 0000000..1ccfada --- /dev/null +++ b/src/slopometry/core/protocol/adapters/opencode.py @@ -0,0 +1,13 @@ +"""OpenCode adapter: maps plugin event type strings onto the canonical taxonomy.""" + +from slopometry.core.protocol.kinds import EventKind + +OPENCODE_EVENT_KIND_MAP: dict[str, EventKind] = { + "pre_tool_use": EventKind.TOOL_CALL, + "post_tool_use": EventKind.TOOL_RESULT, + "stop": EventKind.STOP, + "subagent_stop": EventKind.SUBAGENT_STOP, + "subagent_start": EventKind.SUBAGENT_START, + "todo_updated": EventKind.TODO_UPDATED, + "message_updated": EventKind.MESSAGE_UPDATED, +} diff --git a/src/slopometry/core/protocol/ingest.py b/src/slopometry/core/protocol/ingest.py new file mode 100644 index 0000000..fa415d1 --- /dev/null +++ b/src/slopometry/core/protocol/ingest.py @@ -0,0 +1,82 @@ +"""Ingestion of `EventEnvelope` batches into slopometry storage. + +This is the shared path used by `slopometry ingest` for third-party agent +traces. Live harness adapters may bypass it because they perform additional +enrichment (git state, transcript analysis) tied to the running session. +""" + +import os +import sqlite3 + +from pydantic import BaseModel + +from slopometry.core.database import EventDatabase, SessionManager +from slopometry.core.event_capture import capture_event +from slopometry.core.protocol.schema import EventEnvelope + + +class IngestReport(BaseModel): + """Outcome of ingesting a batch of envelopes.""" + + inserted: int = 0 + skipped_duplicates: int = 0 + + +def ingest_envelopes( + envelopes: list[EventEnvelope], + db: EventDatabase | None = None, + working_directory: str | None = None, +) -> IngestReport: + """Store envelopes, skipping duplicates identified by (source, event_id). + + Sequence numbers come from the envelope when provided; otherwise a + per-session counter continuing after the highest sequence number already + used (stored or earlier in the batch) is assigned. + """ + db = db or EventDatabase() + report = IngestReport() + next_sequence: dict[str, int] = {} + + for envelope in envelopes: + if db.has_event(envelope.source, envelope.event_id): + report.skipped_duplicates += 1 + continue + + if envelope.seq is not None: + sequence_number = envelope.seq + next_sequence[envelope.session_id] = max( + envelope.seq + 1, + next_sequence.get(envelope.session_id, 1 + db.get_max_sequence_number(envelope.session_id)), + ) + else: + next_sequence.setdefault(envelope.session_id, 1 + db.get_max_sequence_number(envelope.session_id)) + sequence_number = next_sequence[envelope.session_id] + next_sequence[envelope.session_id] = sequence_number + 1 + + try: + capture_event( + db, + SessionManager(), + working_directory or os.getcwd(), + session_id=envelope.session_id, + kind=envelope.kind, + source=envelope.source, + metadata=envelope.raw, + sequence_number=sequence_number, + timestamp=envelope.occurred_at, + tool_name=envelope.tool_name, + duration_ms=envelope.duration_ms, + exit_code=envelope.exit_code, + error_message=envelope.error_message, + parent_session_id=envelope.parent_session_id, + event_id=envelope.event_id, + capture_session_context=False, + ) + except sqlite3.IntegrityError: + # Concurrent ingest of the same (source, event_id); the unique + # partial index makes this safe to treat as a duplicate. + report.skipped_duplicates += 1 + continue + report.inserted += 1 + + return report diff --git a/src/slopometry/core/protocol/kinds.py b/src/slopometry/core/protocol/kinds.py new file mode 100644 index 0000000..2aa7c40 --- /dev/null +++ b/src/slopometry/core/protocol/kinds.py @@ -0,0 +1,67 @@ +"""Canonical, harness-independent taxonomy for the extracted hook protocol. + +This module defines the open-world vocabulary of the slopometry event +protocol: event kinds are a closed, validated taxonomy, while event sources +are open - any agent tool can ingest events without patching core code. +""" + +from enum import StrEnum + + +class EventKind(StrEnum): + """Canonical event taxonomy shared by all agent sources. + + Harness-specific event names (Claude Code hook names, OpenCode plugin + events, third-party trace kinds) are mapped onto these kinds at the + adapter boundary and never stored or consumed downstream. + + Extension policy: new kinds are added only when a real adapter needs a + semantic that existing kinds cannot express; kinds are never added + speculatively. Adapters mapping several source concepts onto one kind + (e.g. informational messages onto `notification`) should preserve the + distinction in the stored event's metadata instead. + """ + + TOOL_CALL = "tool_call" + TOOL_RESULT = "tool_result" + NOTIFICATION = "notification" + STOP = "stop" + SUBAGENT_STOP = "subagent_stop" + SUBAGENT_START = "subagent_start" + TODO_UPDATED = "todo_updated" + MESSAGE_UPDATED = "message_updated" + + +class KnownSource(StrEnum): + """Built-in event sources shipped with slopometry. + + This enum provides constants for the first-party adapters only; the + protocol itself is open and any source string is valid for ingested + events. Do NOT gate functionality on membership in this enum. + """ + + CLAUDE_CODE = "claude_code" + OPENCODE = "opencode" + + +LEGACY_EVENT_TYPE_MAP: dict[str, EventKind] = { + "PreToolUse": EventKind.TOOL_CALL, + "PostToolUse": EventKind.TOOL_RESULT, + "Notification": EventKind.NOTIFICATION, + "Stop": EventKind.STOP, + "SubagentStop": EventKind.SUBAGENT_STOP, + "TodoUpdated": EventKind.TODO_UPDATED, + "MessageUpdated": EventKind.MESSAGE_UPDATED, + "SubagentStart": EventKind.SUBAGENT_START, +} + +# Dialect produced by a parallel abstract-protocol implementation that used +# started/completed suffixes and turn_completed for the stop lifecycle. +# Canonicalized in Migration019 so both dialects coalesce onto one taxonomy. +ALT_DIALECT_EVENT_TYPE_MAP: dict[str, EventKind] = { + "tool_call_started": EventKind.TOOL_CALL, + "tool_call_completed": EventKind.TOOL_RESULT, + "turn_completed": EventKind.STOP, + "subagent_started": EventKind.SUBAGENT_START, + "subagent_completed": EventKind.SUBAGENT_STOP, +} diff --git a/src/slopometry/core/protocol/schema.py b/src/slopometry/core/protocol/schema.py new file mode 100644 index 0000000..5777807 --- /dev/null +++ b/src/slopometry/core/protocol/schema.py @@ -0,0 +1,48 @@ +"""Stable external schema for the hook protocol. + +`EventEnvelope` is the public contract for non-Claude-Code / non-OpenCode +agent tools to feed events into slopometry (see issue #46). Collectors emit +JSONL envelopes and hand them to `slopometry ingest` instead of emulating +Claude Code hook payloads. +""" + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from slopometry.core.protocol.kinds import EventKind + +PROTOCOL_SCHEMA_VERSION = 1 + + +class EventEnvelope(BaseModel): + """One event as ingested over the stable hook protocol. + + Third-party collectors identify events via `source` + `event_id`; the + combination is unique in storage so re-ingesting a trace file is an + idempotent backfill. + """ + + model_config = ConfigDict(extra="allow") + + schema_version: int = Field(default=PROTOCOL_SCHEMA_VERSION, description="Protocol schema version, currently 1") + source: str = Field(min_length=1, description="Agent tool that produced the event, e.g. 'mmkr'") + session_id: str = Field(min_length=1, description="Session the event belongs to") + event_id: str = Field(min_length=1, description="Source-unique event id; required so backfill is always idempotent") + parent_session_id: str | None = Field(default=None, description="Parent session id for subagent sessions") + seq: int | None = Field(default=None, ge=1, description="Source-provided sequence number for the event") + occurred_at: datetime = Field(default_factory=datetime.now, description="When the event occurred at the source") + kind: EventKind = Field(description="Canonical event kind; validated against the closed taxonomy") + tool_name: str | None = Field(default=None, description="Tool name for tool_call/tool_result events") + duration_ms: int | None = Field(default=None, ge=0, description="Tool execution duration for tool_result events") + exit_code: int | None = Field(default=None, description="Tool exit code for tool_result events") + error_message: str | None = Field(default=None, description="Tool error message for tool_result events") + raw: dict[str, Any] = Field(default_factory=dict, description="Source-specific payload kept as event metadata") + + @field_validator("schema_version") + @classmethod + def schema_version_must_be_supported(cls, value: int) -> int: + if value != PROTOCOL_SCHEMA_VERSION: + raise ValueError(f"Unsupported schema_version {value}; this slopometry version supports 1") + return value diff --git a/src/slopometry/display/formatters.py b/src/slopometry/display/formatters.py index 6f2146a..14af406 100644 --- a/src/slopometry/display/formatters.py +++ b/src/slopometry/display/formatters.py @@ -21,7 +21,7 @@ SessionDisplayData, ) from slopometry.core.models.experiment import ProgressDisplayData -from slopometry.core.models.hook import HookEventType, ToolType +from slopometry.core.models.hook import ToolType from slopometry.core.models.session import ( BehavioralPatterns, BehavioralPatternTrends, @@ -30,6 +30,7 @@ ) from slopometry.core.models.smell import SMELL_REGISTRY, SmellCategory, get_smell_label, get_smells_by_category from slopometry.core.models.user_story import UserStoryDisplayData +from slopometry.core.protocol.kinds import EventKind from slopometry.core.settings import settings logger = logging.getLogger(__name__) @@ -316,7 +317,7 @@ def _display_behavioral_pattern_trends(trends: BehavioralPatternTrends) -> None: console.print(table) -def _display_events_by_type_table(events_by_type: dict[HookEventType, int]) -> None: +def _display_events_by_type_table(events_by_type: dict[EventKind, int]) -> None: """Display events by type table.""" table = Table(title="Events by Type") table.add_column("Event Type", style="cyan") diff --git a/src/slopometry/solo/cli/commands.py b/src/slopometry/solo/cli/commands.py index 0b52924..f79416b 100644 --- a/src/slopometry/solo/cli/commands.py +++ b/src/slopometry/solo/cli/commands.py @@ -665,6 +665,7 @@ def save_transcript(session_id: str | None, output_dir: str, yes: bool) -> None: from slopometry.core.database import EventDatabase from slopometry.core.models import AgentTool, SessionMetadata + from slopometry.core.protocol.kinds import EventKind, KnownSource output_path_dir = Path(output_dir) session_dir = output_path_dir / ".slopometry" / session_id @@ -739,10 +740,10 @@ def save_transcript(session_id: str | None, output_dir: str, yes: bool) -> None: row = conn.execute( """ SELECT metadata FROM hook_events - WHERE session_id = ? AND event_type = 'MessageUpdated' AND source = 'opencode' + WHERE session_id = ? AND event_type = ? AND source = ? ORDER BY sequence_number ASC LIMIT 1 """, - (session_id,), + (session_id, EventKind.MESSAGE_UPDATED, KnownSource.OPENCODE), ).fetchone() if row: try: @@ -754,10 +755,10 @@ def save_transcript(session_id: str | None, output_dir: str, yes: bool) -> None: stop_row = conn.execute( """ SELECT metadata FROM hook_events - WHERE session_id = ? AND event_type = 'Stop' AND source = 'opencode' + WHERE session_id = ? AND event_type = ? AND source = ? ORDER BY sequence_number DESC LIMIT 1 """, - (session_id,), + (session_id, EventKind.STOP, KnownSource.OPENCODE), ).fetchone() if stop_row: try: diff --git a/tests/test_database.py b/tests/test_database.py index fd3d6e1..8132ca0 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -6,8 +6,9 @@ from slopometry.core.database import EventDatabase from slopometry.core.models.display import LeaderboardEntry, SessionDisplayData -from slopometry.core.models.hook import HookEvent, HookEventType, Project, ProjectSource, ToolType +from slopometry.core.models.hook import HookEvent, Project, ProjectSource, ToolType from slopometry.core.models.user_story import UserStoryEntry +from slopometry.core.protocol.kinds import EventKind def test_user_story_export_functionality() -> None: @@ -244,7 +245,7 @@ def test_list_sessions_by_repository__filters_correctly() -> None: db.save_event( HookEvent( session_id="session-repo-a", - event_type=HookEventType.PRE_TOOL_USE, + event_type=EventKind.TOOL_CALL, sequence_number=1, working_directory="/path/to/repo-a", tool_name="Read", @@ -256,7 +257,7 @@ def test_list_sessions_by_repository__filters_correctly() -> None: db.save_event( HookEvent( session_id="session-repo-b", - event_type=HookEventType.PRE_TOOL_USE, + event_type=EventKind.TOOL_CALL, sequence_number=1, working_directory="/path/to/repo-b", tool_name="Read", @@ -268,7 +269,7 @@ def test_list_sessions_by_repository__filters_correctly() -> None: db.save_event( HookEvent( session_id="session-repo-a-2", - event_type=HookEventType.PRE_TOOL_USE, + event_type=EventKind.TOOL_CALL, sequence_number=1, working_directory="/path/to/repo-a", tool_name="Write", @@ -293,7 +294,7 @@ def test_list_sessions_by_repository__returns_empty_for_unknown_repo() -> None: db.save_event( HookEvent( session_id="session-known", - event_type=HookEventType.PRE_TOOL_USE, + event_type=EventKind.TOOL_CALL, sequence_number=1, working_directory="/path/to/known-repo", tool_name="Read", @@ -316,7 +317,7 @@ def test_list_sessions_by_repository__respects_limit() -> None: db.save_event( HookEvent( session_id=f"session-{i}", - event_type=HookEventType.PRE_TOOL_USE, + event_type=EventKind.TOOL_CALL, sequence_number=1, working_directory="/path/to/repo", tool_name="Read", @@ -337,7 +338,7 @@ def test_get_session_basic_info__returns_minimal_info() -> None: db.save_event( HookEvent( session_id="test-session", - event_type=HookEventType.PRE_TOOL_USE, + event_type=EventKind.TOOL_CALL, sequence_number=1, working_directory="/path/to/repo", tool_name="Read", @@ -347,7 +348,7 @@ def test_get_session_basic_info__returns_minimal_info() -> None: db.save_event( HookEvent( session_id="test-session", - event_type=HookEventType.POST_TOOL_USE, + event_type=EventKind.TOOL_RESULT, sequence_number=2, working_directory="/path/to/repo", tool_name="Write", @@ -381,7 +382,7 @@ def test_get_session_working_directory__returns_first_event_working_dir() -> Non db.save_event( HookEvent( session_id="test-wd-session", - event_type=HookEventType.PRE_TOOL_USE, + event_type=EventKind.TOOL_CALL, sequence_number=1, working_directory="/first/working/dir", tool_name="Read", @@ -391,7 +392,7 @@ def test_get_session_working_directory__returns_first_event_working_dir() -> Non db.save_event( HookEvent( session_id="test-wd-session", - event_type=HookEventType.POST_TOOL_USE, + event_type=EventKind.TOOL_RESULT, sequence_number=2, working_directory="/second/working/dir", tool_name="Write", @@ -422,7 +423,7 @@ def test_get_sessions_summary__returns_session_display_data() -> None: for i in range(3): event = HookEvent( session_id="sess-abc", - event_type=HookEventType.PRE_TOOL_USE, + event_type=EventKind.TOOL_CALL, timestamp=datetime(2025, 1, 1, 10, i), sequence_number=i + 1, working_directory="/repo", @@ -451,7 +452,7 @@ def test_get_sessions_summary__handles_null_tool_type() -> None: event = HookEvent( session_id="sess-null", - event_type=HookEventType.NOTIFICATION, + event_type=EventKind.NOTIFICATION, timestamp=datetime(2025, 1, 1, 12, 0), sequence_number=1, working_directory="/repo", @@ -474,7 +475,7 @@ def test_get_sessions_summary__respects_limit() -> None: for i in range(5): event = HookEvent( session_id=f"sess-{i:03d}", - event_type=HookEventType.PRE_TOOL_USE, + event_type=EventKind.TOOL_CALL, timestamp=datetime(2025, 1, 1, 10 + i, 0), sequence_number=1, working_directory="/repo", diff --git a/tests/test_hook_handler.py b/tests/test_hook_handler.py index a99f9b4..df16188 100644 --- a/tests/test_hook_handler.py +++ b/tests/test_hook_handler.py @@ -27,7 +27,6 @@ from slopometry.core.models.complexity import ComplexityDelta, ExtendedComplexityMetrics from slopometry.core.models.hook import ( FeedbackCacheState, - HookEventType, NotificationInput, PostToolUseInput, PreToolUseInput, @@ -36,6 +35,7 @@ ) from slopometry.core.models.session import ContextCoverage, FileCoverageStatus from slopometry.core.models.smell import SmellField +from slopometry.core.protocol.kinds import EventKind from slopometry.display.formatters import _interpret_z_score @@ -43,7 +43,7 @@ class TestEventTypeDetection: """Test the pattern match logic for detecting event types.""" def test_pre_tool_use_input_detection(self): - """Test that PreToolUseInput maps to PRE_TOOL_USE event type.""" + """Test that PreToolUseInput maps to TOOL_CALL event kind.""" input_data = PreToolUseInput( session_id="test-session", transcript_path="/tmp/test.jsonl", @@ -53,10 +53,10 @@ def test_pre_tool_use_input_detection(self): result = detect_event_type_from_parsed(input_data) - assert result == HookEventType.PRE_TOOL_USE + assert result == EventKind.TOOL_CALL def test_post_tool_use_input_detection(self): - """Test that PostToolUseInput maps to POST_TOOL_USE event type.""" + """Test that PostToolUseInput maps to TOOL_RESULT event kind.""" input_data = PostToolUseInput( session_id="test-session", transcript_path="/tmp/test.jsonl", @@ -67,7 +67,7 @@ def test_post_tool_use_input_detection(self): result = detect_event_type_from_parsed(input_data) - assert result == HookEventType.POST_TOOL_USE + assert result == EventKind.TOOL_RESULT def test_notification_input_detection(self): """Test that NotificationInput maps to NOTIFICATION event type.""" @@ -80,7 +80,7 @@ def test_notification_input_detection(self): result = detect_event_type_from_parsed(input_data) - assert result == HookEventType.NOTIFICATION + assert result == EventKind.NOTIFICATION def test_stop_input_detection(self): """Test that StopInput maps to STOP event type.""" @@ -92,7 +92,7 @@ def test_stop_input_detection(self): result = detect_event_type_from_parsed(input_data) - assert result == HookEventType.STOP + assert result == EventKind.STOP def test_subagent_stop_input_detection(self): """Test that SubagentStopInput maps to SUBAGENT_STOP event type.""" @@ -104,7 +104,7 @@ def test_subagent_stop_input_detection(self): result = detect_event_type_from_parsed(input_data) - assert result == HookEventType.SUBAGENT_STOP + assert result == EventKind.SUBAGENT_STOP def test_all_input_types_are_handled(self): """Test that all defined input types have corresponding pattern matches. @@ -139,11 +139,11 @@ def test_all_input_types_are_handled(self): ] expected_types = [ - HookEventType.PRE_TOOL_USE, - HookEventType.POST_TOOL_USE, - HookEventType.NOTIFICATION, - HookEventType.STOP, - HookEventType.SUBAGENT_STOP, + EventKind.TOOL_CALL, + EventKind.TOOL_RESULT, + EventKind.NOTIFICATION, + EventKind.STOP, + EventKind.SUBAGENT_STOP, ] for input_data, expected_type in zip(input_types, expected_types): @@ -1034,7 +1034,7 @@ def test_handle_hook__pre_tool_use_does_not_crash(self): } with patch("slopometry.core.hook_handler._read_stdin_with_timeout", return_value=json.dumps(input_data)): - result = handle_hook(event_type_override=HookEventType.PRE_TOOL_USE) + result = handle_hook(event_type_override=EventKind.TOOL_CALL) assert result == 0 @@ -1050,7 +1050,7 @@ def test_handle_hook__post_tool_use_does_not_crash(self): } with patch("slopometry.core.hook_handler._read_stdin_with_timeout", return_value=json.dumps(input_data)): - result = handle_hook(event_type_override=HookEventType.POST_TOOL_USE) + result = handle_hook(event_type_override=EventKind.TOOL_RESULT) assert result == 0 @@ -1064,7 +1064,7 @@ def test_handle_hook__notification_does_not_crash(self): } with patch("slopometry.core.hook_handler._read_stdin_with_timeout", return_value=json.dumps(input_data)): - result = handle_hook(event_type_override=HookEventType.NOTIFICATION) + result = handle_hook(event_type_override=EventKind.NOTIFICATION) assert result == 0 @@ -1088,7 +1088,7 @@ def test_handle_hook__stop_does_not_crash(self): patch("slopometry.core.hook_handler._read_stdin_with_timeout", return_value=json.dumps(input_data)), patch("os.getcwd", return_value=str(tmppath)), ): - result = handle_hook(event_type_override=HookEventType.STOP) + result = handle_hook(event_type_override=EventKind.STOP) # Stop hook returns 0 (no feedback) or 2 (with feedback) - both are valid assert result in (0, 2) @@ -1103,7 +1103,7 @@ def test_handle_hook__subagent_stop_does_not_crash(self): } with patch("slopometry.core.hook_handler._read_stdin_with_timeout", return_value=json.dumps(input_data)): - result = handle_hook(event_type_override=HookEventType.STOP) + result = handle_hook(event_type_override=EventKind.STOP) # Subagent stops should return 0 (no feedback for subagents) assert result == 0 diff --git a/tests/test_ingest.py b/tests/test_ingest.py new file mode 100644 index 0000000..1d2ac4f --- /dev/null +++ b/tests/test_ingest.py @@ -0,0 +1,178 @@ +"""Tests for envelope ingestion into slopometry storage.""" + +from datetime import datetime +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from slopometry.cli import cli +from slopometry.core.database import EventDatabase +from slopometry.core.protocol.ingest import ingest_envelopes +from slopometry.core.protocol.kinds import EventKind +from slopometry.core.protocol.schema import EventEnvelope + + +@pytest.fixture +def isolated_storage(tmp_path): + """Isolated database for ingestion tests.""" + return EventDatabase(db_path=tmp_path / "test.db") + + +def _mmkr_envelope( + session_id: str, + event_id: str, + kind: str, + seq: int | None = None, + tool_name: str | None = None, + raw: dict | None = None, + **tool_result_fields, +) -> EventEnvelope: + return EventEnvelope( + source="mmkr", + session_id=session_id, + event_id=event_id, + kind=kind, + seq=seq, + tool_name=tool_name, + occurred_at=datetime(2026, 6, 30, 12, 0, 0), + raw=raw or {}, + **tool_result_fields, + ) + + +class TestIngestEnvelopes: + def test_ingest_envelopes__stores_third_party_events_with_canonical_kinds(self, isolated_storage): + db = isolated_storage + envelopes = [ + _mmkr_envelope("s-1", "evt-1", "tool_call", seq=1, tool_name="check_issue_responses", raw={"tick": 31}), + _mmkr_envelope("s-1", "evt-2", "tool_result", seq=2, tool_name="check_issue_responses", raw={"ok": True}), + _mmkr_envelope("s-1", "evt-3", "stop", seq=3), + ] + + report = ingest_envelopes(envelopes, db=db, working_directory="/repo") + + assert report.inserted == 3 + events = db.get_session_events("s-1") + assert [e.event_type for e in events] == [EventKind.TOOL_CALL, EventKind.TOOL_RESULT, EventKind.STOP] + assert all(e.source == "mmkr" for e in events) + assert [e.sequence_number for e in events] == [1, 2, 3] + assert events[0].tool_name == "check_issue_responses" + assert events[0].metadata == {"tick": 31} + assert all(e.id is not None for e in events) + + def test_ingest_envelopes__stores_tool_result_timing_and_error_fields(self, isolated_storage): + db = isolated_storage + envelopes = [ + _mmkr_envelope( + "s-1", + "evt-1", + "tool_result", + seq=1, + tool_name="Bash", + duration_ms=120, + exit_code=1, + error_message="command failed", + ), + ] + + ingest_envelopes(envelopes, db=db) + + event = db.get_session_events("s-1")[0] + assert event.duration_ms == 120 + assert event.exit_code == 1 + assert event.error_message == "command failed" + + def test_ingest_envelopes__assigns_sequence_numbers_when_envelope_lacks_seq(self, isolated_storage): + db = isolated_storage + envelopes = [ + _mmkr_envelope("s-2", "evt-1", "tool_call"), + _mmkr_envelope("s-2", "evt-2", "tool_result"), + ] + + ingest_envelopes(envelopes, db=db) + + events = db.get_session_events("s-2") + assert [e.sequence_number for e in events] == [1, 2] + + def test_ingest_envelopes__skips_duplicates_when_backfilling_identical_trace(self, isolated_storage): + db = isolated_storage + envelopes = [_mmkr_envelope("s-3", "evt-1", "stop", seq=1)] + + first = ingest_envelopes(envelopes, db=db) + second = ingest_envelopes(envelopes, db=db) + + assert (first.inserted, first.skipped_duplicates) == (1, 0) + assert (second.inserted, second.skipped_duplicates) == (0, 1) + assert len(db.get_session_events("s-3")) == 1 + + def test_ingest_envelopes__interleaves_source_and_assigned_sequences_without_collision(self, isolated_storage): + db = isolated_storage + envelopes = [ + _mmkr_envelope("s-4", "evt-1", "tool_call", seq=1), + _mmkr_envelope("s-4", "evt-2", "tool_result"), + _mmkr_envelope("s-4", "evt-3", "stop", seq=7), + _mmkr_envelope("s-4", "evt-4", "notification"), + ] + + ingest_envelopes(envelopes, db=db) + + events = db.get_session_events("s-4") + assert [e.sequence_number for e in sorted(events, key=lambda e: e.event_id)] == [1, 2, 7, 8] + + def test_ingest_envelopes__continues_after_highest_stored_sequence_for_existing_session(self, isolated_storage): + db = isolated_storage + ingest_envelopes([_mmkr_envelope("s-5", "evt-1", "stop", seq=5)], db=db) + + ingest_envelopes([_mmkr_envelope("s-5", "evt-2", "notification")], db=db) + + events = db.get_session_events("s-5") + assert [e.sequence_number for e in events] == [5, 6] + + +class TestIngestCli: + def test_ingest__ingests_jsonl_from_stdin_for_third_party_source(self, tmp_path): + jsonl = ( + '{"source": "mmkr", "session_id": "s-cli", "event_id": "e1", "kind": "tool_call", "seq": 1}\n' + '{"source": "mmkr", "session_id": "s-cli", "event_id": "e2", "kind": "stop", "seq": 2}\n' + ) + db_path = tmp_path / "test.db" + + with patch("slopometry.core.settings.settings.database_path", db_path): + result = CliRunner().invoke(cli, ["ingest", "--source", "mmkr"], input=jsonl) + + assert result.exit_code == 0 + assert "Ingested 2 events" in result.output + events = EventDatabase(db_path=db_path).get_session_events("s-cli") + assert [e.event_type for e in events] == [EventKind.TOOL_CALL, EventKind.STOP] + + def test_ingest__fails_loudly_when_envelope_source_mismatches_flag(self, tmp_path): + jsonl = '{"source": "mmkr", "session_id": "s-cli", "event_id": "e1", "kind": "stop"}\n' + + with patch("slopometry.core.settings.settings.database_path", tmp_path / "test.db"): + result = CliRunner().invoke(cli, ["ingest", "--source", "other-agent"], input=jsonl) + + assert result.exit_code == 2 + assert "does not match --source" in result.output + + def test_ingest__fails_loudly_when_envelope_is_invalid(self, tmp_path): + jsonl = '{"source": "mmkr", "session_id": "s-cli", "kind": "tick_complete"}\n' + + with patch("slopometry.core.settings.settings.database_path", tmp_path / "test.db"): + result = CliRunner().invoke(cli, ["ingest", "--source", "mmkr"], input=jsonl) + + assert result.exit_code == 2 + assert "Invalid envelope at line 1" in result.output + + def test_ingest__records_explicit_working_directory_on_envelope_events(self, tmp_path): + jsonl = '{"source": "mmkr", "session_id": "s-wd", "event_id": "e1", "kind": "stop", "seq": 1}\n' + db_path = tmp_path / "test.db" + + with patch("slopometry.core.settings.settings.database_path", db_path): + result = CliRunner().invoke( + cli, ["ingest", "--source", "mmkr", "--working-directory", str(tmp_path)], input=jsonl + ) + + assert result.exit_code == 0 + events = EventDatabase(db_path=db_path).get_session_events("s-wd") + assert events[0].working_directory == str(tmp_path) diff --git a/tests/test_list_sessions_performance.py b/tests/test_list_sessions_performance.py index a3767bd..c42a5b5 100644 --- a/tests/test_list_sessions_performance.py +++ b/tests/test_list_sessions_performance.py @@ -5,7 +5,8 @@ from tempfile import TemporaryDirectory from slopometry.core.database import EventDatabase -from slopometry.core.models.hook import HookEvent, HookEventType, Project, ProjectSource +from slopometry.core.models.hook import HookEvent, Project, ProjectSource +from slopometry.core.protocol.kinds import EventKind class TestListSessionsPerformance: @@ -27,7 +28,7 @@ def test_list_sessions__respects_limit_parameter(self): event = HookEvent( session_id=session_id, - event_type=HookEventType.PRE_TOOL_USE, + event_type=EventKind.TOOL_CALL, timestamp=timestamp, sequence_number=1, working_directory="/test", @@ -59,7 +60,7 @@ def test_list_sessions__handles_no_limit_parameter(self): session_id = f"session-{i}" event = HookEvent( session_id=session_id, - event_type=HookEventType.PRE_TOOL_USE, + event_type=EventKind.TOOL_CALL, timestamp=datetime.now() + timedelta(minutes=i), sequence_number=1, working_directory="/test", diff --git a/tests/test_migrations.py b/tests/test_migrations.py index eac0e80..f971f73 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -1,8 +1,11 @@ """Tests for database migrations.""" +import sqlite3 from pathlib import Path from tempfile import TemporaryDirectory +import pytest + from slopometry.core.migrations import MigrationRunner # Derive expected count from the runner itself so adding a migration doesn't @@ -137,3 +140,57 @@ def test_migration_001__handles_existing_column_gracefully(self): columns = [row[1] for row in cursor.fetchall()] transcript_path_count = columns.count("transcript_path") assert transcript_path_count == 1 + + def test_migration_019__remaps_harness_and_alt_dialect_event_types_and_enables_idempotent_ingest(self): + """Legacy and alt-dialect event types become canonical kinds with an event_id index.""" + with TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "test.db" + runner = MigrationRunner(db_path) + + with runner._get_db_connection() as conn: + conn.execute(""" + CREATE TABLE hook_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + event_type TEXT NOT NULL, + timestamp TEXT NOT NULL, + source TEXT + ) + """) + conn.execute( + "INSERT INTO hook_events (session_id, event_type, timestamp, source) VALUES ('s1', 'PreToolUse', '2026-01-01T00:00:00', NULL)" + ) + conn.execute( + "INSERT INTO hook_events (session_id, event_type, timestamp, source) VALUES ('s1', 'SubagentStop', '2026-01-01T00:01:00', 'opencode')" + ) + conn.execute( + "INSERT INTO hook_events (session_id, event_type, timestamp, source) VALUES ('s1', 'turn_completed', '2026-01-01T00:02:00', 'opencode')" + ) + conn.execute( + "INSERT INTO hook_events (session_id, event_type, timestamp, source) VALUES ('s1', 'tool_call_completed', '2026-01-01T00:03:00', 'opencode')" + ) + conn.commit() + + runner.run_migrations() + + with runner._get_db_connection() as conn: + rows = conn.execute("SELECT event_type, source FROM hook_events ORDER BY id").fetchall() + assert rows == [ + ("tool_call", "claude_code"), + ("subagent_stop", "opencode"), + ("stop", "opencode"), + ("tool_result", "opencode"), + ] + + columns = {row[1] for row in conn.execute("PRAGMA table_info(hook_events)").fetchall()} + indexes = {row[1] for row in conn.execute("PRAGMA index_list(hook_events)").fetchall()} + assert "event_id" in columns + assert "idx_hook_events_source_event_id" in indexes + + conn.execute( + "INSERT INTO hook_events (session_id, event_type, timestamp, source, event_id) VALUES ('s2', 'stop', '2026-01-01T02:00:00', 'mmkr', 'evt-1')" + ) + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO hook_events (session_id, event_type, timestamp, source, event_id) VALUES ('s2', 'stop', '2026-01-01T02:01:00', 'mmkr', 'evt-1')" + ) diff --git a/tests/test_opencode_handler.py b/tests/test_opencode_handler.py index 1b331c3..7eef4e3 100644 --- a/tests/test_opencode_handler.py +++ b/tests/test_opencode_handler.py @@ -9,7 +9,7 @@ import pytest from slopometry.core.database import SessionManager -from slopometry.core.models.hook import HookEventType, ToolType +from slopometry.core.models.hook import ToolType, get_tool_type from slopometry.core.models.opencode import ( OpenCodeMessageEvent, OpenCodeSessionEvent, @@ -18,37 +18,37 @@ OpenCodeToolEvent, ) from slopometry.core.opencode_handler import ( - EVENT_TYPE_MAP, _get_parent_id, _get_session_id, _handle_opencode_stop, - get_tool_type, handle_opencode_hook, parse_opencode_event, ) +from slopometry.core.protocol.adapters.opencode import OPENCODE_EVENT_KIND_MAP +from slopometry.core.protocol.kinds import EventKind class TestEventTypeMap: def test_event_type_map__pre_tool_use_maps_correctly(self): - assert EVENT_TYPE_MAP["pre_tool_use"] == HookEventType.PRE_TOOL_USE + assert OPENCODE_EVENT_KIND_MAP["pre_tool_use"] == EventKind.TOOL_CALL def test_event_type_map__post_tool_use_maps_correctly(self): - assert EVENT_TYPE_MAP["post_tool_use"] == HookEventType.POST_TOOL_USE + assert OPENCODE_EVENT_KIND_MAP["post_tool_use"] == EventKind.TOOL_RESULT def test_event_type_map__stop_maps_correctly(self): - assert EVENT_TYPE_MAP["stop"] == HookEventType.STOP + assert OPENCODE_EVENT_KIND_MAP["stop"] == EventKind.STOP def test_event_type_map__subagent_stop_maps_correctly(self): - assert EVENT_TYPE_MAP["subagent_stop"] == HookEventType.SUBAGENT_STOP + assert OPENCODE_EVENT_KIND_MAP["subagent_stop"] == EventKind.SUBAGENT_STOP def test_event_type_map__subagent_start_maps_correctly(self): - assert EVENT_TYPE_MAP["subagent_start"] == HookEventType.SUBAGENT_START + assert OPENCODE_EVENT_KIND_MAP["subagent_start"] == EventKind.SUBAGENT_START def test_event_type_map__todo_updated_maps_correctly(self): - assert EVENT_TYPE_MAP["todo_updated"] == HookEventType.TODO_UPDATED + assert OPENCODE_EVENT_KIND_MAP["todo_updated"] == EventKind.TODO_UPDATED def test_event_type_map__message_updated_maps_correctly(self): - assert EVENT_TYPE_MAP["message_updated"] == HookEventType.MESSAGE_UPDATED + assert OPENCODE_EVENT_KIND_MAP["message_updated"] == EventKind.MESSAGE_UPDATED def test_event_type_map__covers_all_opencode_event_types(self): expected_keys = { @@ -60,7 +60,7 @@ def test_event_type_map__covers_all_opencode_event_types(self): "todo_updated", "message_updated", } - assert set(EVENT_TYPE_MAP.keys()) == expected_keys + assert set(OPENCODE_EVENT_KIND_MAP.keys()) == expected_keys class TestParseOpenCodeEvent: diff --git a/tests/test_protocol_schema.py b/tests/test_protocol_schema.py new file mode 100644 index 0000000..c3ba0a8 --- /dev/null +++ b/tests/test_protocol_schema.py @@ -0,0 +1,66 @@ +"""Tests for the hook protocol event envelope schema.""" + +import pytest +from pydantic import ValidationError + +from slopometry.core.protocol.kinds import EventKind +from slopometry.core.protocol.schema import EventEnvelope + + +class TestEventEnvelopeValidation: + def test_event_envelope__accepts_minimal_envelope_with_defaults(self): + envelope = EventEnvelope(source="mmkr", session_id="s1", event_id="e1", kind="tool_call") + + assert envelope.kind is EventKind.TOOL_CALL + assert envelope.schema_version == 1 + assert envelope.seq is None + assert envelope.duration_ms is None + assert envelope.exit_code is None + assert envelope.error_message is None + assert envelope.raw == {} + + def test_event_envelope__requires_event_id_for_idempotent_backfill(self): + with pytest.raises(ValidationError): + EventEnvelope(source="mmkr", session_id="s1", kind="tool_call") + + def test_event_envelope__accepts_arbitrary_third_party_source_strings(self): + envelope = EventEnvelope(source="my-custom-agent", session_id="s1", event_id="e1", kind="notification") + + assert envelope.source == "my-custom-agent" + + def test_event_envelope__rejects_unsupported_schema_version(self): + with pytest.raises(ValidationError, match="Unsupported schema_version"): + EventEnvelope(source="mmkr", session_id="s1", kind="tool_call", schema_version=2) + + def test_event_envelope__rejects_unknown_kind_with_helpful_message(self): + with pytest.raises(ValidationError) as exc_info: + EventEnvelope(source="mmkr", session_id="s1", kind="tick_complete") + + assert "tool_call" in str(exc_info.value) + + def test_event_envelope__rejects_empty_source(self): + with pytest.raises(ValidationError): + EventEnvelope(source="", session_id="s1", kind="tool_call") + + def test_event_envelope__rejects_sequence_below_one(self): + with pytest.raises(ValidationError): + EventEnvelope(source="mmkr", session_id="s1", kind="tool_call", seq=0) + + def test_event_envelope__preserves_unknown_fields_for_forward_compatibility(self): + envelope = EventEnvelope(source="mmkr", session_id="s1", event_id="e1", kind="stop", harness_trace_url="https://example.com") + + assert envelope.model_extra == {"harness_trace_url": "https://example.com"} + + def test_event_envelope__round_trips_through_jsonl_line(self): + payload = ( + '{"source": "mmkr", "schema_version": 1, "session_id": "s-42", "event_id": "tick-31",' + ' "parent_id": null, "seq": 31, "occurred_at": "2026-06-30T12:00:00",' + ' "kind": "tool_call", "raw": {"tool": "check_issue_responses"}}' + ) + + envelope = EventEnvelope.model_validate_json(payload) + + assert envelope.session_id == "s-42" + assert envelope.event_id == "tick-31" + assert envelope.seq == 31 + assert envelope.raw == {"tool": "check_issue_responses"} diff --git a/tests/test_sessions_performance.py b/tests/test_sessions_performance.py index 86d9aee..5ff4aa1 100644 --- a/tests/test_sessions_performance.py +++ b/tests/test_sessions_performance.py @@ -5,7 +5,8 @@ from tempfile import TemporaryDirectory from slopometry.core.database import EventDatabase -from slopometry.core.models.hook import HookEvent, HookEventType, Project, ProjectSource, ToolType +from slopometry.core.models.hook import HookEvent, Project, ProjectSource, ToolType +from slopometry.core.protocol.kinds import EventKind from slopometry.solo.services.session_service import SessionService @@ -23,7 +24,7 @@ def test_get_sessions_for_display__uses_single_query(self): for i in range(5): event = HookEvent( session_id="session-001", - event_type=HookEventType.PRE_TOOL_USE, + event_type=EventKind.TOOL_CALL, timestamp=base_time + timedelta(minutes=i), sequence_number=i + 1, working_directory="/test", @@ -36,7 +37,7 @@ def test_get_sessions_for_display__uses_single_query(self): for i in range(3): event = HookEvent( session_id="session-002", - event_type=HookEventType.PRE_TOOL_USE, + event_type=EventKind.TOOL_CALL, timestamp=base_time + timedelta(hours=1, minutes=i), sequence_number=i + 1, working_directory="/test2", @@ -79,7 +80,7 @@ def test_get_sessions_for_display__respects_limit(self): for session_num in range(5): event = HookEvent( session_id=f"session-{session_num:03d}", - event_type=HookEventType.PRE_TOOL_USE, + event_type=EventKind.TOOL_CALL, timestamp=base_time + timedelta(minutes=session_num), sequence_number=1, working_directory="/test", @@ -119,7 +120,7 @@ def test_get_sessions_for_display__handles_null_tool_types(self): event = HookEvent( session_id="session-001", - event_type=HookEventType.NOTIFICATION, + event_type=EventKind.NOTIFICATION, timestamp=datetime.now(), sequence_number=1, working_directory="/test",