ADR-079: Trajectory Visualization System
Status
Accepted - January 16, 2026 Updated - January 23, 2026
Implementation complete with:
- TrajectoryLogger singleton (scripts/core/trajectory_logging.py)
PostToolUse hook integration (hooks/trajectory_logger_hook.py)DEPRECATED - See below- React visualization component (skills/trajectory-visualizer/src/components/TrajectoryViewer.tsx)
- Secret redaction for API keys/credentials (P1 MoE recommendation)
- 65+ TDD tests with full coverage
January 2026 Update: Batch Extraction via /cx
PostToolUse hook integration deprecated in favor of batch extraction:
- Problem: PostToolUse hooks caused error noise in Claude Code UI even when succeeding
- Solution: Moved trajectory extraction to
/cxpipeline (unified-message-extractor.py v4.2.0) - Implementation:
scripts/trajectory_extractor.py- Batch extraction from session JSONL files- Hash-based deduplication via
trajectory_hashcolumn intool_analyticstable - Automatic execution during
/cx(H.5.6 step)
Benefits of batch approach:
- Eliminates hook error noise
- Hash-based dedup prevents duplicates across runs
- Single source of truth in context.db
- SQL-queryable trajectory data
Context
Problem Statement
CODITECT sessions involve complex agent interactions, skill invocations, and tool calls, but debugging is difficult because:
- No Execution Trace: Cannot replay what happened in a session
- Hidden Reasoning: Agent reasoning not captured
- No Flow Visualization: Cannot see agent call graph
- Lost Context: Why decisions were made is not recorded
- Post-Mortem Difficulty: Hard to diagnose failures after the fact
Source Analysis: RLM Pattern
Analysis of submodules/rlm reveals a comprehensive trajectory logging system:
RLM's Trajectory Logging:
1. JSON-Lines Format (one object per line)
- Line 1: Metadata (config, model, depth)
- Line N: Iteration (prompt, response, code blocks, final answer)
2. Rich Metadata per Iteration
- iteration number
- full response text
- extracted code blocks
- execution results
- timestamps
- token usage
3. Interactive Visualizer
- Node.js + shadcn/ui
- Timeline view
- Code trace highlighting
- Sub-LM call tree
Key Innovation: Full execution history captured for replay and debugging.
Current CODITECT Limitation
/exportcaptures messages but not execution flow- Context watcher (ADR-066) monitors usage, not trajectory
- No visualization tools for agent flows
- Session logs are text-based, not structured
Requirements
- Structured Logging: JSON-Lines format for trajectories
- Execution Capture: Tool calls, skill invocations, agent dispatches
- Reasoning Trace: Model responses with reasoning
- Visualization: Interactive UI for trajectory exploration
- Integration: Works with existing /export and /cx systems
- TDD Compliance: Tests written before implementation
Decision
Implement Trajectory Visualization System based on RLM's logging pattern, extended for CODITECT's multi-agent workflows.
1. Architecture Overview
┌─────────────────────────────────────────────────────────────────────────────┐
│ TRAJECTORY VISUALIZATION ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ CAPTURE LAYER │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Session Events │ │
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │
│ │ │ Tool Calls │ │ Skill Invoke │ │ Agent Dispatch │ │ │
│ │ │ (Bash, Edit) │ │ (Skill tool) │ │ (Task tool) │ │ │
│ │ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │ │
│ │ │ │ │ │ │
│ │ └───────────────────┼───────────────────┘ │ │
│ │ ▼ │ │
│ │ ┌──────────────────────┐ │ │
│ │ │ TrajectoryLogger │ │ │
│ │ │ (Singleton) │ │ │
│ │ └──────────────────────┘ │ │
│ │ │ │ │
│ └───────────────────────────────┼────────────────────────────────────────┘ │
│ │ │
│ STORAGE LAYER ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ trajectory-{session_id}.jsonl │ │
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
│ │ │ {"type":"metadata","session_id":"abc","started_at":"..."} │ │ │
│ │ │ {"type":"tool_call","tool":"Bash","input":{...},"output":"..."}│ │ │
│ │ │ {"type":"skill_invoke","skill":"tdd-patterns","result":"..."} │ │ │
│ │ │ {"type":"agent_dispatch","agent":"backend","depth":1} │ │ │
│ │ │ {"type":"iteration","response":"...","reasoning":"..."} │ │ │
│ │ └────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ VISUALIZATION LAYER ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Trajectory Visualizer (Web UI) │ │
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │
│ │ │ │ TIMELINE VIEW │ │ │ │
│ │ │ │ ──●────●────●────●────●────●────●────●────●────●── │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ T1 T2 S1 A1 T3 A2 S2 T4 T5 END │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ T = Tool, S = Skill, A = Agent │ │ │ │
│ │ │ └─────────────────────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │
│ │ │ │ CALL GRAPH │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ [Controller] │ │ │ │
│ │ │ │ / | \ │ │ │ │
│ │ │ │ [A.1.1] [A.1.2] [A.1.3] │ │ │ │
│ │ │ │ | │ │ │ │
│ │ │ │ [A.1.1.1] │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ └─────────────────────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │
│ │ │ │ DETAIL PANEL │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ Selected: Tool Call #3 (Bash) │ │ │ │
│ │ │ │ Time: 2026-01-16T10:23:45Z │ │ │ │
│ │ │ │ Duration: 1.2s │ │ │ │
│ │ │ │ Input: git status │ │ │ │
│ │ │ │ Output: [collapsed, click to expand] │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ └─────────────────────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ └────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Core Data Structures
# scripts/core/trajectory_logging.py
from dataclasses import dataclass, field, asdict
from typing import List, Optional, Dict, Any, Literal
from datetime import datetime
from pathlib import Path
import json
from threading import Lock
EventType = Literal[
"metadata",
"tool_call",
"skill_invoke",
"agent_dispatch",
"agent_complete",
"iteration",
"error",
"final"
]
@dataclass
class TrajectoryEvent:
"""Base class for trajectory events."""
type: EventType
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
task_id: Optional[str] = None
def to_jsonl(self) -> str:
return json.dumps(asdict(self), default=str)
@dataclass
class MetadataEvent(TrajectoryEvent):
"""Session metadata (first line of trajectory)."""
type: EventType = "metadata"
session_id: str = ""
started_at: str = ""
model: str = ""
project_root: str = ""
coditect_version: str = ""
@dataclass
class ToolCallEvent(TrajectoryEvent):
"""Tool invocation event."""
type: EventType = "tool_call"
tool_name: str = ""
tool_input: Dict[str, Any] = field(default_factory=dict)
output: str = ""
duration_ms: float = 0
success: bool = True
error: Optional[str] = None
@dataclass
class SkillInvokeEvent(TrajectoryEvent):
"""Skill invocation event."""
type: EventType = "skill_invoke"
skill_name: str = ""
args: Optional[str] = None
result: str = ""
duration_ms: float = 0
@dataclass
class AgentDispatchEvent(TrajectoryEvent):
"""Agent dispatch event."""
type: EventType = "agent_dispatch"
agent_type: str = ""
prompt_preview: str = "" # First 500 chars
depth: int = 0
parent_task_id: Optional[str] = None
@dataclass
class AgentCompleteEvent(TrajectoryEvent):
"""Agent completion event."""
type: EventType = "agent_complete"
agent_type: str = ""
result_preview: str = "" # First 500 chars
duration_ms: float = 0
child_calls: int = 0
usage: Dict[str, int] = field(default_factory=dict)
@dataclass
class IterationEvent(TrajectoryEvent):
"""Model iteration event (reasoning step)."""
type: EventType = "iteration"
iteration: int = 0
prompt_preview: str = ""
response_preview: str = ""
thinking: Optional[str] = None # If extended thinking captured
tool_calls_in_response: int = 0
@dataclass
class ErrorEvent(TrajectoryEvent):
"""Error event."""
type: EventType = "error"
error_type: str = ""
error_message: str = ""
traceback: Optional[str] = None
@dataclass
class FinalEvent(TrajectoryEvent):
"""Session completion event."""
type: EventType = "final"
ended_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
total_duration_ms: float = 0
total_tool_calls: int = 0
total_skill_invokes: int = 0
total_agent_dispatches: int = 0
total_tokens: int = 0
final_status: str = "completed"
class TrajectoryLogger:
"""
Logger for capturing execution trajectories.
Thread-safe singleton that writes to JSONL file.
"""
_instance: Optional["TrajectoryLogger"] = None
_lock: Lock = Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._file = None
cls._instance._session_id = None
cls._instance._write_lock = Lock()
cls._instance._stats = {
"tool_calls": 0,
"skill_invokes": 0,
"agent_dispatches": 0
}
return cls._instance
def start_session(
self,
session_id: str,
output_dir: Optional[Path] = None,
model: str = "",
project_root: str = ""
) -> None:
"""Start trajectory logging for a session."""
self._session_id = session_id
output_dir = output_dir or Path.home() / ".coditect" / "trajectories"
output_dir.mkdir(parents=True, exist_ok=True)
filepath = output_dir / f"trajectory-{session_id}.jsonl"
self._file = open(filepath, 'w')
# Write metadata as first line
metadata = MetadataEvent(
session_id=session_id,
started_at=datetime.utcnow().isoformat(),
model=model,
project_root=project_root,
coditect_version="2.8.0"
)
self._write(metadata)
def log_tool_call(
self,
tool_name: str,
tool_input: Dict[str, Any],
output: str,
duration_ms: float,
task_id: Optional[str] = None,
success: bool = True,
error: Optional[str] = None
) -> None:
"""Log a tool call event."""
self._stats["tool_calls"] += 1
event = ToolCallEvent(
tool_name=tool_name,
tool_input=tool_input,
output=output[:5000], # Truncate large outputs
duration_ms=duration_ms,
task_id=task_id,
success=success,
error=error
)
self._write(event)
def log_skill_invoke(
self,
skill_name: str,
result: str,
duration_ms: float,
task_id: Optional[str] = None,
args: Optional[str] = None
) -> None:
"""Log a skill invocation event."""
self._stats["skill_invokes"] += 1
event = SkillInvokeEvent(
skill_name=skill_name,
args=args,
result=result[:5000],
duration_ms=duration_ms,
task_id=task_id
)
self._write(event)
def log_agent_dispatch(
self,
agent_type: str,
prompt: str,
depth: int,
task_id: str,
parent_task_id: Optional[str] = None
) -> None:
"""Log an agent dispatch event."""
self._stats["agent_dispatches"] += 1
event = AgentDispatchEvent(
agent_type=agent_type,
prompt_preview=prompt[:500],
depth=depth,
task_id=task_id,
parent_task_id=parent_task_id
)
self._write(event)
def log_agent_complete(
self,
agent_type: str,
result: str,
duration_ms: float,
task_id: str,
child_calls: int = 0,
usage: Optional[Dict[str, int]] = None
) -> None:
"""Log an agent completion event."""
event = AgentCompleteEvent(
agent_type=agent_type,
result_preview=result[:500],
duration_ms=duration_ms,
task_id=task_id,
child_calls=child_calls,
usage=usage or {}
)
self._write(event)
def log_iteration(
self,
iteration: int,
prompt: str,
response: str,
task_id: Optional[str] = None,
thinking: Optional[str] = None,
tool_calls: int = 0
) -> None:
"""Log a model iteration event."""
event = IterationEvent(
iteration=iteration,
prompt_preview=prompt[:500],
response_preview=response[:500],
task_id=task_id,
thinking=thinking[:1000] if thinking else None,
tool_calls_in_response=tool_calls
)
self._write(event)
def log_error(
self,
error_type: str,
error_message: str,
task_id: Optional[str] = None,
traceback: Optional[str] = None
) -> None:
"""Log an error event."""
event = ErrorEvent(
error_type=error_type,
error_message=error_message,
task_id=task_id,
traceback=traceback
)
self._write(event)
def end_session(
self,
total_duration_ms: float,
total_tokens: int = 0,
status: str = "completed"
) -> None:
"""End trajectory logging, write final event."""
event = FinalEvent(
total_duration_ms=total_duration_ms,
total_tool_calls=self._stats["tool_calls"],
total_skill_invokes=self._stats["skill_invokes"],
total_agent_dispatches=self._stats["agent_dispatches"],
total_tokens=total_tokens,
final_status=status
)
self._write(event)
if self._file:
self._file.close()
self._file = None
def _write(self, event: TrajectoryEvent) -> None:
"""Write event to JSONL file (thread-safe)."""
if self._file:
with self._write_lock:
self._file.write(event.to_jsonl() + '\n')
self._file.flush()
3. Visualizer Component
// tools/trajectory-visualizer/src/TrajectoryViewer.tsx
import React, { useState, useEffect } from 'react';
import { Timeline, TimelineItem } from './Timeline';
import { CallGraph } from './CallGraph';
import { DetailPanel } from './DetailPanel';
interface TrajectoryEvent {
type: string;
timestamp: string;
task_id?: string;
[key: string]: any;
}
interface TrajectoryViewerProps {
trajectoryPath: string;
}
export function TrajectoryViewer({ trajectoryPath }: TrajectoryViewerProps) {
const [events, setEvents] = useState<TrajectoryEvent[]>([]);
const [selectedEvent, setSelectedEvent] = useState<TrajectoryEvent | null>(null);
const [view, setView] = useState<'timeline' | 'graph'>('timeline');
useEffect(() => {
// Load trajectory JSONL
fetch(trajectoryPath)
.then(res => res.text())
.then(text => {
const lines = text.trim().split('\n');
const parsed = lines.map(line => JSON.parse(line));
setEvents(parsed);
});
}, [trajectoryPath]);
const metadata = events.find(e => e.type === 'metadata');
const final = events.find(e => e.type === 'final');
return (
<div className="trajectory-viewer">
<header className="viewer-header">
<h1>Trajectory: {metadata?.session_id}</h1>
<div className="stats">
<span>Tool Calls: {final?.total_tool_calls}</span>
<span>Skills: {final?.total_skill_invokes}</span>
<span>Agents: {final?.total_agent_dispatches}</span>
<span>Tokens: {final?.total_tokens?.toLocaleString()}</span>
</div>
<div className="view-toggle">
<button
className={view === 'timeline' ? 'active' : ''}
onClick={() => setView('timeline')}
>
Timeline
</button>
<button
className={view === 'graph' ? 'active' : ''}
onClick={() => setView('graph')}
>
Call Graph
</button>
</div>
</header>
<main className="viewer-main">
<div className="viewer-content">
{view === 'timeline' ? (
<Timeline events={events} onSelect={setSelectedEvent} />
) : (
<CallGraph events={events} onSelect={setSelectedEvent} />
)}
</div>
<aside className="detail-panel">
<DetailPanel event={selectedEvent} />
</aside>
</main>
</div>
);
}
4. TDD Test Specifications
# tests/core/test_trajectory_logging.py
import pytest
import json
from pathlib import Path
from scripts.core.trajectory_logging import (
TrajectoryLogger,
MetadataEvent,
ToolCallEvent,
SkillInvokeEvent,
AgentDispatchEvent
)
class TestTrajectoryEvent:
"""TDD tests for trajectory events."""
def test_event_to_jsonl(self):
"""RED→GREEN: Event serializes to JSONL."""
event = ToolCallEvent(
tool_name="Bash",
tool_input={"command": "ls"},
output="file.txt",
duration_ms=100
)
jsonl = event.to_jsonl()
parsed = json.loads(jsonl)
assert parsed["type"] == "tool_call"
assert parsed["tool_name"] == "Bash"
assert parsed["duration_ms"] == 100
def test_event_includes_timestamp(self):
"""RED→GREEN: Events auto-include timestamp."""
event = SkillInvokeEvent(skill_name="tdd-patterns")
assert event.timestamp is not None
assert "T" in event.timestamp # ISO format
class TestTrajectoryLogger:
"""TDD tests for TrajectoryLogger."""
@pytest.fixture
def logger(self, tmp_path):
logger = TrajectoryLogger()
logger._instance = None # Reset singleton
logger = TrajectoryLogger()
logger.start_session("test-123", tmp_path)
yield logger
if logger._file:
logger._file.close()
def test_creates_trajectory_file(self, logger, tmp_path):
"""RED→GREEN: Logger creates JSONL file."""
filepath = tmp_path / "trajectory-test-123.jsonl"
assert filepath.exists()
def test_first_line_is_metadata(self, logger, tmp_path):
"""RED→GREEN: First line is metadata event."""
logger.end_session(1000)
filepath = tmp_path / "trajectory-test-123.jsonl"
lines = filepath.read_text().strip().split('\n')
first = json.loads(lines[0])
assert first["type"] == "metadata"
assert first["session_id"] == "test-123"
def test_logs_tool_calls(self, logger, tmp_path):
"""RED→GREEN: Tool calls logged correctly."""
logger.log_tool_call(
tool_name="Bash",
tool_input={"command": "ls"},
output="file.txt",
duration_ms=100,
task_id="A.1.1"
)
logger.end_session(1000)
filepath = tmp_path / "trajectory-test-123.jsonl"
lines = filepath.read_text().strip().split('\n')
tool_event = json.loads(lines[1])
assert tool_event["type"] == "tool_call"
assert tool_event["tool_name"] == "Bash"
assert tool_event["task_id"] == "A.1.1"
def test_tracks_stats(self, logger, tmp_path):
"""RED→GREEN: Stats tracked across events."""
logger.log_tool_call("Bash", {}, "", 100)
logger.log_tool_call("Edit", {}, "", 100)
logger.log_skill_invoke("tdd-patterns", "", 100)
logger.log_agent_dispatch("backend", "prompt", 0, "A.1.1")
assert logger._stats["tool_calls"] == 2
assert logger._stats["skill_invokes"] == 1
assert logger._stats["agent_dispatches"] == 1
def test_final_event_includes_stats(self, logger, tmp_path):
"""RED→GREEN: Final event includes accumulated stats."""
logger.log_tool_call("Bash", {}, "", 100)
logger.log_skill_invoke("tdd", "", 100)
logger.end_session(5000, total_tokens=1000)
filepath = tmp_path / "trajectory-test-123.jsonl"
lines = filepath.read_text().strip().split('\n')
final = json.loads(lines[-1])
assert final["type"] == "final"
assert final["total_tool_calls"] == 1
assert final["total_skill_invokes"] == 1
assert final["total_tokens"] == 1000
def test_thread_safety(self, logger, tmp_path):
"""RED→GREEN: Logger is thread-safe."""
import threading
def log_events():
for i in range(100):
logger.log_tool_call(f"Tool{i}", {}, "", 10)
threads = [threading.Thread(target=log_events) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
assert logger._stats["tool_calls"] == 500
Consequences
Positive
- Full Replay: Can replay entire session execution
- Debugging: Easy to diagnose failures
- Optimization: Identify slow paths
- Audit Trail: Complete record of actions
- Visualization: Interactive exploration
Negative
- Storage: Trajectory files can be large
- Performance: Logging adds overhead
- Privacy: Captures prompt/response content
Mitigations
- Truncation: Preview fields limited to 500 chars
- Async Writes: Non-blocking file writes
- Redaction: Option to redact sensitive content
Implementation
Files to Create
| File | Purpose | LOC Est. |
|---|---|---|
scripts/core/trajectory_logging.py | Core logging classes | ~400 |
tools/trajectory-visualizer/ | Web visualizer | ~1000 |
tests/core/test_trajectory_logging.py | TDD tests | ~250 |
commands/trajectory.md | /trajectory command | ~100 |
Files to Modify
| File | Changes |
|---|---|
hooks/post-tool-use.py | |
commands/cx.md | Reference trajectory extraction (automatic via H.5.6) |
scripts/unified-message-extractor.py | Calls trajectory_extractor.py (v4.2.0+) |
Files Added (January 2026)
| File | Purpose |
|---|---|
scripts/trajectory_extractor.py | Batch extraction with hash-based dedup |
context.db:tool_analytics.trajectory_hash | Unique hash for deduplication |
References
- Source Analysis:
submodules/rlm/rlm/logger/ - RLM Visualizer:
submodules/rlm/visualizer/ - Related ADR: ADR-066 (Context Watcher), ADR-075 (Token Usage)
Author: CODITECT Architecture Team Reviewers: Architecture Council Source Commit: Analysis of submodules/rlm