Skip to main content

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:

  1. Problem: PostToolUse hooks caused error noise in Claude Code UI even when succeeding
  2. Solution: Moved trajectory extraction to /cx pipeline (unified-message-extractor.py v4.2.0)
  3. Implementation:
    • scripts/trajectory_extractor.py - Batch extraction from session JSONL files
    • Hash-based deduplication via trajectory_hash column in tool_analytics table
    • 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:

  1. No Execution Trace: Cannot replay what happened in a session
  2. Hidden Reasoning: Agent reasoning not captured
  3. No Flow Visualization: Cannot see agent call graph
  4. Lost Context: Why decisions were made is not recorded
  5. 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

  • /export captures 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

  1. Structured Logging: JSON-Lines format for trajectories
  2. Execution Capture: Tool calls, skill invocations, agent dispatches
  3. Reasoning Trace: Model responses with reasoning
  4. Visualization: Interactive UI for trajectory exploration
  5. Integration: Works with existing /export and /cx systems
  6. 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

  1. Full Replay: Can replay entire session execution
  2. Debugging: Easy to diagnose failures
  3. Optimization: Identify slow paths
  4. Audit Trail: Complete record of actions
  5. Visualization: Interactive exploration

Negative

  1. Storage: Trajectory files can be large
  2. Performance: Logging adds overhead
  3. Privacy: Captures prompt/response content

Mitigations

  1. Truncation: Preview fields limited to 500 chars
  2. Async Writes: Non-blocking file writes
  3. Redaction: Option to redact sensitive content

Implementation

Files to Create

FilePurposeLOC Est.
scripts/core/trajectory_logging.pyCore logging classes~400
tools/trajectory-visualizer/Web visualizer~1000
tests/core/test_trajectory_logging.pyTDD tests~250
commands/trajectory.md/trajectory command~100

Files to Modify

FileChanges
hooks/post-tool-use.pyIntegrate trajectory logging DEPRECATED
commands/cx.mdReference trajectory extraction (automatic via H.5.6)
scripts/unified-message-extractor.pyCalls trajectory_extractor.py (v4.2.0+)

Files Added (January 2026)

FilePurpose
scripts/trajectory_extractor.pyBatch extraction with hash-based dedup
context.db:tool_analytics.trajectory_hashUnique 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