A Comprehensive Walkthrough of the Sage AI Code Reviewer
This document provides a detailed technical breakdown of the Sage codebase. It's designed to be read sequentially, walking you through the architecture, implementation details, and design decisions that make Sage work.
- Project Overview
- Architecture & Design Patterns
- Core Data Structures
- Entry Point & Application Flow
- File-by-File Breakdown
- Key Algorithms & Logic
- State Management
- Error Handling Patterns
- Integration Points
- Testing Strategy
- Code Patterns & Conventions
- Future Considerations
Sage is a passive AI code reviewer that monitors Claude Code sessions and provides automated second opinions. It:
- Reads Claude Code conversation transcripts (JSONL format)
- Watches for new responses via Claude Code hooks
- Reviews Claude's suggestions using OpenAI Codex SDK
- Displays structured critiques in a terminal UI
- Never modifies files or executes code (read-only by design)
Developers using Claude Code often want a second opinion but don't want to:
- Break their workflow to copy conversations
- Lose repository context
- Manually trigger reviews
Sage solves this by integrating seamlessly into the workflow with zero additional commands after initial setup.
- Runtime: Node.js 18+ (ES modules)
- Language: TypeScript (strict mode)
- UI Framework: React + Ink (terminal UI)
- AI Agent: OpenAI Codex SDK (
@openai/codex-sdk) - File Watching: Chokidar
- Build Tool: TypeScript compiler (
tsc) - Execution:
tsx(TypeScript execution)
┌─────────────────────────────────────────────────────────────┐
│ Claude Code (External) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ SessionStart │ │ Stop │ │UserPromptSubmit │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────────┘ │
│ │ │ │ │
│ └─────────────────┼─────────────────┘ │
│ │ │
│ ▼ │
└─────────────────────── sageHook.ts ─────────────────────────┘
│
│ Writes metadata & signals
▼
┌────────────────────────────────────────────────────┐
│ ~/.sage/{project-path}/runtime/sessions/*.json │
│ ~/.sage/{project-path}/runtime/needs-review/*.json │
└────────────────────────────────────────────────────┘
│
│ Reads & watches
▼
┌─────────────────────────────────────────────────────────────┐
│ Sage TUI (App.tsx) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │Session Picker│ │ Signal Watch │ │ Review Queue │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └─────────────────┼─────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ review.ts │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ codex.ts │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
└───────────────────── Codex SDK ─────────────────────────────┘
- Singleton Pattern: Codex instance (
codexInstanceincodex.ts) - Observer Pattern: File watcher (chokidar) observes signal directory
- Queue Pattern: FIFO queue for pending reviews
- State Machine: Screen transitions (
loading→session-list→running→chat) - Factory Pattern: Thread creation/resumption (
getOrCreateThread) - Strategy Pattern: Different prompt builders for initial vs. incremental reviews
- Cache Pattern: Review history persistence (
reviewsCache.ts)
User Action → Claude Code → Hook Event → Signal File
↓
File Watcher
↓
Queue Item
↓
JSONL Parser
↓
Turn Extraction
↓
Codex Review
↓
Critique Card
export interface Critique {
verdict: 'Approved' | 'Concerns' | 'Critical Issues';
why: string;
alternatives?: string;
raw: string;
}
export interface Session {
id: string;
filePath: string;
timestamp: Date;
}Note: These types appear to be legacy. The codebase primarily uses types defined in other modules.
export interface ActiveSession {
sessionId: string;
transcriptPath: string; // Path to Claude's JSONL log file
cwd: string; // Working directory
lastPrompt?: string; // Last user prompt text
lastStopTime?: number; // Timestamp of last Stop hook
lastUpdated: number; // Last metadata update timestamp
title: string; // Display title (derived from lastPrompt)
}Purpose: Represents a discoverable Claude Code session that Sage can review.
export interface TurnSummary {
user: string; // User's prompt text
agent?: string; // Claude's response text
userUuid?: string; // UUID of user message entry
assistantUuid?: string; // UUID of assistant response entry
isPartial?: boolean; // True if Claude was still responding
}Purpose: Represents a single user-Claude exchange. Used for:
- Formatting conversation history for Codex prompts
- Tracking which turns have been reviewed
- Incremental processing (only new turns)
export interface CritiqueResponse {
verdict: 'Approved' | 'Concerns' | 'Critical Issues';
why: string; // Required: Main reasoning
alternatives: string; // Optional: Alternative approaches
message_for_agent: string; // Optional: Direct message to Claude
}Purpose: Structured output from Codex reviews. All fields are required by JSON schema (OpenAI constraint), but empty strings indicate optional sections.
export interface ReviewResult {
critique: CritiqueResponse;
transcriptPath: string;
completedAt: string; // ISO timestamp
turnSignature?: string; // assistantUuid of reviewed turn
latestPrompt?: string; // User prompt that triggered review
debugInfo?: {
artifactPath: string; // Path to ~/.sage/{project}/debug/review-*.txt
promptText: string; // Full prompt sent to Codex
};
isFreshCritique: boolean; // false if resumed from cache
streamEvents: StreamEvent[]; // Events captured during streaming review
isPartial?: boolean; // True if reviewing incomplete response
}Purpose: Complete result of a review operation, including metadata for caching and display.
interface ThreadMetadata {
threadId: string; // Codex thread ID
sessionId: string; // Claude session ID
timestamp: number; // Creation timestamp
lastUsed: number; // Last access timestamp
lastReviewedTurnCount: number; // Number of turns last reviewed
}Purpose: Persists Codex thread state across Sage restarts. Enables:
- Resuming existing Codex threads (context preservation)
- Detecting if new turns exist since last review
- Avoiding duplicate reviews
export interface SessionReviewCache {
sessionId: string;
lastTurnSignature: string | null; // UUID of last reviewed turn
reviews: StoredReview[]; // Cached critique history
}
export interface StoredReview {
turnSignature: string;
completedAt: string;
latestPrompt?: string | null;
critique: CritiqueResponse;
artifactPath?: string;
promptText?: string;
}Purpose: Persists critique history so Sage can:
- Restore previous critiques when re-selecting a session
- Skip reviews for already-reviewed turns
- Display critique history even after restart
export function getProjectRoot(): string; // CLAUDE_PROJECT_DIR or cwd()
export function encodeProjectPath(path: string): string; // /Users/you/foo → Users-you-foo
export function getSageDir(): string; // ~/.sage/{encoded-project-path}/
export function getRuntimeDir(): string; // ~/.sage/{project}/runtime/
export function getSessionsDir(): string; // ~/.sage/{project}/runtime/sessions/
export function getQueueDir(): string; // ~/.sage/{project}/runtime/needs-review/
export function getErrorLogPath(): string; // ~/.sage/{project}/runtime/hook-errors.log
export function getThreadsDir(): string; // ~/.sage/{project}/threads/
export function getReviewsDir(): string; // ~/.sage/{project}/reviews/
export function getDebugDir(): string; // ~/.sage/{project}/debug/Purpose: Centralizes all path configuration for per-project isolation. Each project gets its own Sage data directory under ~/.sage/ based on its full path (e.g., /Users/you/projects/foo → ~/.sage/Users-you-projects-foo/).
Key Design Decision: Uses CLAUDE_PROJECT_DIR environment variable when available (set by Claude Code hooks), otherwise falls back to process.cwd().
export type StreamEventTag =
| 'assistant' // Assistant message content
| 'reasoning' // Reasoning/thinking trace
| 'command' // Command execution
| 'file' // File changes
| 'todo' // Todo list updates
| 'status' // General status updates
| 'error'; // Error events
export interface StreamEvent {
id: string; // Unique event ID
timestamp: number; // Event timestamp
tag: StreamEventTag; // Event category
message: string; // Human-readable message
}Purpose: Represents streaming events from Codex reviews, displayed in the Stream Overlay (Ctrl+O).
#!/usr/bin/env node
import React from 'react';
import { render } from 'ink';
import App from './ui/App.js';
render(<App />);Flow:
- Shebang (
#!/usr/bin/env node) makes it executable - Imports React and Ink's
renderfunction - Renders the root
Appcomponent - Ink handles terminal rendering and input
Key Point: This is a React app that renders to the terminal, not a browser.
index.tsx
↓
App.tsx (mount)
↓
useEffect → init()
↓
loadSettings() // Load user preferences (model, debugMode)
↓
ensureHooksConfigured() // Auto-configure Claude hooks
↓
Validate Claude Code // Check Claude binary exists and version >= 2.0.50
↓
Validate Codex CLI // Check `codex` command is installed
↓
Validate Codex Auth // Check CODEX_API_KEY or ~/.codex/auth.json exists
↓
reloadSessions()
↓
listActiveSessions()
↓
Read .sage/runtime/sessions/*.json
↓
Filter warmup sessions
↓
Display session picker (with "✓ Hooks configured" if first run)
Startup Validation: Sage performs several checks before loading sessions:
- Claude Code check: Finds Claude binary via
CLAUDE_BINenv,which claude, or~/.claude/local/claude - Version check: Requires Claude Code >= 2.0.50 for hook support
- Codex CLI check: Ensures
codexcommand is available in PATH - Codex auth check: Verifies
CODEX_API_KEYenv var or~/.codex/auth.jsonexists
User presses Enter
↓
handleSessionSelection(session)
↓
loadReviewCache(sessionId) // Restore cached critiques
↓
performInitialReview() // Initial Codex review
↓
initializeSignalWatcher() // Start watching for new signals
↓
drainSignals() // Process any pending signals
↓
Enter continuous mode (screen = 'running')
Claude Code Stop hook fires
↓
sageHook.ts writes signal file
↓
File watcher detects new file
↓
processSignalFile()
↓
extractTurns() with sinceUuid filter
↓
enqueueJob() → FIFO queue
↓
processQueue() (FIFO worker)
↓
performIncrementalReview()
↓
Display CritiqueCard
Purpose: Central state management and UI orchestration for the entire application.
Key Responsibilities:
- Session discovery and selection
- File watching for hook signals
- FIFO queue management
- Review state persistence
- Screen state machine
- Keyboard input handling
State Variables:
const [screen, setScreen] = useState<Screen>('loading');
const [sessions, setSessions] = useState<ActiveSession[]>([]);
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
const [reviews, setReviews] = useState<CompletedReview[]>([]);
const [queue, setQueue] = useState<ReviewQueueItem[]>([]);
const [currentJob, setCurrentJob] = useState<ReviewQueueItem | null>(null);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);Refs (for values that shouldn't trigger re-renders):
const queueRef = useRef<ReviewQueueItem[]>([]);
const workerRunningRef = useRef(false);
const watcherRef = useRef<FSWatcher | null>(null);
const codexThreadRef = useRef<Thread | null>(null);
const lastTurnSignatureRef = useRef<string | null>(null);
const processedSignalsRef = useRef<Set<string>>(new Set());Why Refs?: Refs don't trigger re-renders when updated. Used for:
- Queue state (updated frequently, but UI reads from
queuestate) - Worker lock (prevents concurrent queue processing)
- File watcher instance (lifecycle management)
- Codex thread (persisted across renders)
- Turn signature tracking (avoids unnecessary re-renders)
Key Functions:
-
reloadSessions()- Calls
listActiveSessions()to discover sessions - Filters warmup-only sessions automatically
- Sorts by
lastUpdated(most recent first) - Handles errors gracefully
- Calls
-
handleSessionSelection()- Loads cached reviews for the session
- Restores previous critique history
- Performs initial review (or resumes if no new turns)
- Initializes file watcher
- Drains any pending signals
-
processQueue()- FIFO worker that processes review queue
- Uses
workerRunningRefto prevent concurrent execution - Calls
performIncrementalReview()for each job - Handles errors without stopping the queue
- Cleans up signal files after processing
-
processSignalFile()- Reads signal file from
.sage/runtime/needs-review/ - Extracts new turns since last reviewed signature
- Enqueues job if new turns exist
- Deduplicates signals using
processedSignalsRef
- Reads signal file from
-
handleChatSubmit()- Handles user questions to Sage
- Calls
chatWithSage()with Codex thread - Adds messages to chat history
- Prevents duplicate submissions with
isWaitingForChat
Screen State Machine:
type Screen = 'loading' | 'error' | 'session-list' | 'running' | 'chat' | 'settings';loading: Initial session discoveryerror: Error state (can retry with 'R')session-list: Session picker (arrow keys + Enter)running: Continuous review mode (watching for signals)chat: Chat mode with Sage (press 'C' in running mode)settings: Model selection screen (press 'S' in session-list)
Keyboard Controls:
- Session List: ↑/↓ navigate, Enter select, R refresh, S settings
- Settings: ↑/↓ navigate models, Enter select, ESC/B back
- Running Mode: Ctrl+O stream overlay, M manual sync, B back to list, C chat
- Chat: ESC exit, Enter send
Performance Considerations:
- Queue processing uses refs to avoid re-renders during processing
- Signal deduplication prevents processing same file twice
- Debounced status messages prevent UI flicker
- WHY section hidden for Approved verdicts reduces terminal noise
Purpose: Parses Claude Code's JSONL log files to extract user-Claude conversation turns.
Key Challenges:
- JSONL format (one JSON object per line)
- Filtering sidechain/internal entries
- Matching user prompts to assistant responses
- Handling resume sessions (same UUIDs reused)
- Detecting warmup-only sessions
Core Function: extractTurns()
Algorithm:
1. Stream JSONL file line-by-line
2. Parse each line as JSON
3. Filter entries:
- Skip if isSidechain === true
- Skip if isCompactSummary === true
- Skip if isMeta === true
4. Build two collections:
- primaryUserPrompts: [{ uuid, text }]
- assistantEntries: [{ uuid, parentUuid, message }]
5. For each assistant entry:
- Resolve root user UUID (traverse parentUuid chain)
- Group responses by root user UUID
6. Build TurnSummary[]:
- Pair each user prompt with its responses
- Format assistant messages (text + tool_use)
- Track UUIDs for signature matching
7. If sinceUuid provided:
- Filter to only turns after that UUID
Helper Functions:
-
isPrimaryUserPrompt()- Validates entry is a primary user prompt
- Checks:
type === 'user',message.role === 'user' - Requires
thinkingMetadata(indicates primary chain) - Excludes empty text
-
resolveRootUserUuid()- Traverses
parentUuidchain upward - Finds root user prompt UUID
- Prevents infinite loops with visited set
- Returns
nullif no root found
- Traverses
-
formatAssistantMessage()- Formats Claude's response message
- Handles string, object, or array content
- Includes tool_use entries (except Read/Task)
- Joins text chunks with double newlines
-
isWarmupSession()- Checks if session's only primary prompt is "Warmup"
- Used to filter warmup-only sessions from picker
- Returns
trueif first primary prompt is "Warmup" (case-insensitive)
Edge Cases Handled:
- Invalid JSON lines (warns and skips)
- Missing UUIDs (skips entry)
- Circular parent chains (returns null)
- Empty messages (skips)
- Missing files (returns empty turns)
Performance:
- Streams file (doesn't load entire file into memory)
- Uses
readlineinterface for efficient line-by-line reading - Single pass through file
- Early exit for warmup detection
Purpose: Wraps OpenAI Codex SDK, builds prompts, and structures output.
Key Components:
- Singleton Codex Instance
const singleton = new Codex();
export const codexInstance = singleton;Why Singleton?: Codex SDK manages connections internally. One instance is sufficient and more efficient.
- JSON Schema for Structured Output
const CRITIQUE_SCHEMA = {
type: 'object',
properties: {
verdict: { type: 'string', enum: ['Approved', 'Concerns', 'Critical Issues'] },
why: { type: 'string' },
alternatives: { type: 'string' },
message_for_agent: { type: 'string' },
},
required: ['verdict', 'why', 'alternatives', 'message_for_agent'],
additionalProperties: false,
};Purpose: Ensures Codex returns structured JSON matching our CritiqueResponse interface. All fields must be in required array (OpenAI constraint).
- Initial Review Prompt Builder (
buildInitialPromptPayload(), lines 104-180)
Prompt Structure:
# Role
You are Sage, an AI code reviewer...
# Audience
You are speaking directly to the DEVELOPER...
- Use "you/your" for developer
- Use "Claude" or "it" for the AI assistant
# CRITICAL CONSTRAINTS
Your role is OBSERVATION AND ANALYSIS ONLY...
NEVER modify, write, or delete any files
# Task
1. Explore the codebase
2. Review the conversation
3. Critique the latest Claude turn
4. Verify alignment
# Conversation Transcript Details
[Explains sidechain filtering]
# Output Format
[Structured critique card format]
# message_for_agent Guidelines
[When to use message_for_agent field]
# Guidelines
[Focus areas and style]
Session ID: {sessionId}
Latest Claude turn:
{latestTurnSummary}
Full conversation transcript follows between <conversation> tags.
<conversation>
{formattedTurns}
</conversation>
Key Prompt Design Decisions:
- Audience Clarity: Explicitly states who Sage is addressing (developer vs. Claude)
- Read-Only Enforcement: Repeated emphasis on never modifying files
- Sidechain Explanation: Tells Codex not to flag missing tool calls (they're filtered)
- Latest Turn Focus: Emphasizes critiquing only the most recent response
- Structured Output: Clear JSON schema requirements
- Followup Review Prompt Builder (
buildFollowupPromptPayload(), lines 182-257)
Differences from Initial:
- Reminds Codex it already explored the codebase
- Focuses on new turns only
- Shorter context (doesn't repeat full conversation)
- References prior context when needed
- Review Execution (
runInitialReview(),runFollowupReview())
Both review functions use streaming to capture real-time events for the Stream Overlay:
export async function runInitialReview(
context: InitialReviewContext,
options?: RunReviewOptions,
): Promise<RunInitialReviewResult> {
const reviewThread = options?.thread ?? singleton.startThread(getConfiguredThreadOptions(options?.model));
const payload = options?.promptPayload ?? buildInitialPromptPayload(context);
const { critique, events } = await executeStreamedTurn(reviewThread, payload.prompt, options?.onEvent);
return { thread: reviewThread, critique, promptPayload: payload, streamEvents: events };
}Streaming Architecture (executeStreamedTurn()):
async function executeStreamedTurn(
thread: Thread,
prompt: string,
onEvent?: (event: StreamEvent) => void,
): Promise<{ critique: CritiqueResponse; events: StreamEvent[] }> {
const { events } = await thread.runStreamed(prompt, { outputSchema: CRITIQUE_SCHEMA });
const collected: StreamEvent[] = [];
for await (const event of events) {
const derived = convertThreadEventToStreamEvents(event);
for (const entry of derived) {
collected.push(entry);
onEvent?.(entry); // Callback for live overlay updates
}
if (event.type === 'turn.completed') break;
if (event.type === 'turn.failed') throw new Error(event.error?.message);
}
return { critique, events: collected };
}Process:
- Get or create Codex thread
- Build prompt payload
- Call
thread.runStreamed()with JSON schema - Iterate async event stream, converting to
StreamEventobjects - Fire
onEventcallback for each event (feeds Stream Overlay) - Extract structured critique from
item.completedevent - Return thread, critique, payload, and collected
streamEvents
Stream Event Conversion: convertThreadEventToStreamEvents() maps Codex SDK events (item.started, item.updated, item.completed, turn.completed, turn.failed) to Sage's StreamEvent format with appropriate tags (assistant, reasoning, command, file, todo, status, error).
Error Handling: Stream iteration handles turn.failed events by throwing. Timeouts are applied at the review.ts layer.
Purpose: Coordinates initial and incremental reviews, manages thread lifecycle, handles caching.
Key Functions:
performInitialReview()
Flow:
1. Extract turns from JSONL
2. Build initial prompt payload
3. Write artifact to `~/.sage/{project}/debug/` (always)
4. Load thread metadata
5. Get or create Codex thread
6. Check if thread resumed:
a. If resumed && no new turns:
→ Return cached critique (isFreshCritique: false)
b. If resumed && new turns:
→ Review only new turns
c. If new thread:
→ Full initial review
7. Save thread metadata
8. Return ReviewResult
Resume Detection Logic:
const metadata = await loadThreadMetadata(sessionId);
const thread = await getOrCreateThread(codexInstance, sessionId, onProgress);
const isResumedThread = metadata !== null;
const currentTurnCount = turns.length;
const lastReviewedTurnCount = metadata?.lastReviewedTurnCount ?? 0;
const hasNewTurns = currentTurnCount > lastReviewedTurnCount;
if (isResumedThread && !hasNewTurns) {
// No new work → resume without new critique
critique = { verdict: 'Approved', why: 'Session previously reviewed...', ... };
isFreshCritique = false;
} else if (isResumedThread && hasNewTurns) {
// New turns → review incrementally
const newTurns = turns.slice(lastReviewedTurnCount);
critique = await runFollowupReview(thread, { sessionId, newTurns });
await updateThreadTurnCount(sessionId, currentTurnCount);
} else {
// New thread → full review
critique = await runInitialReview({ sessionId, turns, latestTurnSummary }, thread);
await saveThreadMetadata(sessionId, threadId, currentTurnCount);
}Why This Matters: Prevents duplicate reviews when re-selecting a session with no new turns.
performIncrementalReview()
Flow:
1. Validate turns exist
2. Build followup prompt payload
3. Write artifact to `~/.sage/{project}/debug/` (always)
4. Validate thread exists
5. Call runFollowupReview()
6. Return ReviewResult
Timeout Handling: Both initial and incremental reviews have 5-minute timeouts.
chatWithSage()
Purpose: Allows users to have conversational exchanges with Sage about the codebase or critiques.
export async function chatWithSage(
thread: Thread | null,
userQuestion: string,
sessionId: string,
): Promise<{ response: string }>Behavior:
- Uses the existing Codex thread (preserves context from prior reviews)
- Responds conversationally without structured JSON output
- 2-minute timeout for responses
- Throws error if no active thread exists
Chat Mode Access: Press 'C' in running mode to enter chat, ESC to exit.
Purpose: Manages Codex thread lifecycle and persistence across Sage restarts.
Key Functions:
saveThreadMetadata()
export async function saveThreadMetadata(
sessionId: string,
threadId: string,
turnCount: number = 0,
): Promise<void> {
await ensureThreadsDir();
const metadata: ThreadMetadata = {
threadId,
sessionId,
timestamp: Date.now(),
lastUsed: Date.now(),
lastReviewedTurnCount: turnCount,
};
const filePath = path.join(THREADS_DIR, `${sessionId}.json`);
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2), 'utf8');
}Storage: ~/.sage/{project-path}/threads/{sessionId}.json
loadThreadMetadata()
- Reads metadata file
- Updates
lastUsedtimestamp on access - Returns
nullif file doesn't exist or is corrupted
getOrCreateThread()
Flow:
1. Try to load metadata
2. If metadata exists:
a. Try to resume thread via codex.resumeThread()
b. If resume fails:
→ Delete metadata (thread deleted on Codex side)
→ Fall through to create new
3. Create new thread via codex.startThread()
4. Save metadata if thread.id available
5. Return thread
Resume Benefits:
- Preserves Codex's context (files read, reasoning)
- Faster incremental reviews (doesn't re-read codebase)
- Maintains conversation continuity
Error Handling: If resume fails (thread deleted externally), silently creates new thread.
Purpose: Stores critique history so Sage can restore previous critiques when re-selecting sessions.
Storage: ~/.sage/{project-path}/reviews/{sessionId}.json
Key Functions:
loadReviewCache()
- Reads cache file
- Normalizes data (validates structure)
- Sorts reviews by
completedAttimestamp - Returns
nullif file doesn't exist
appendReviewToCache()
- Appends new review to cache
- Deduplicates by
turnSignature(replaces if exists) - Updates
lastTurnSignature - Enforces
MAX_REVIEWS_PER_SESSION(500) limit
Why Deduplicate?: Same turn might be reviewed multiple times (edge case during race conditions).
normalizeCache()
Purpose: Validates and sanitizes cache data structure.
Validations:
- Ensures
reviewsis an array - Validates each review has required fields (
turnSignature,completedAt,critique) - Sorts by timestamp
- Computes
lastTurnSignaturefrom reviews if missing
Safety: Prevents crashes from corrupted cache files.
Purpose: Artifact generation utilities for inspecting Codex prompts.
Artifact Generation (writeDebugReviewArtifact()):
Always Created (for all reviews):
~/.sage/{project}/debug/review-{sanitized-prompt-label}.txt
File Format:
================================================================================
CODEX PROMPT DEBUG ARTIFACT
================================================================================
Session: {sessionId}
Review Type: {Initial Review | Incremental Review}
================================================================================
INSTRUCTIONS
================================================================================
{promptText}
================================================================================
CONTEXT (Conversation Turns)
================================================================================
{contextText}
Purpose: Allows inspection of exactly what was sent to Codex during reviews. Useful for debugging and understanding how Sage formulates critiques.
Filename Sanitization (sanitizeFilename()):
- Replaces spaces with hyphens
- Removes non-alphanumeric characters (except
-,_,.) - Collapses multiple hyphens
- Truncates to 60 characters
- Deduplicates with numeric suffix (
-1,-2, etc.)
Purpose: Receives hook events from Claude Code and writes metadata/signals for Sage.
Hook Events Handled:
SessionStart: Creates session metadata fileStop: Updates metadata, creates review signalUserPromptSubmit: Updates last prompt in metadata
Note: SessionEnd hook was removed due to unreliability. Metadata files accumulate over time but are harmless.
Input: JSON payload via stdin
Payload Structure:
interface HookPayload {
session_id?: string;
transcript_path?: string;
cwd?: string;
hook_event_name?: string;
prompt?: string;
}Output:
- Session Metadata (
~/.sage/{project-path}/runtime/sessions/{sessionId}.json):
{
"sessionId": "...",
"transcriptPath": "...",
"cwd": "...",
"lastPrompt": "...",
"lastStopTime": 1234567890,
"lastUpdated": 1234567890
}- Review Signal (
~/.sage/{project-path}/runtime/needs-review/{sessionId}-{timestamp}-{random}.json):
{
"sessionId": "...",
"transcriptPath": "...",
"queuedAt": 1234567890
}Key Functions:
handlePayload()
Flow:
1. Parse JSON from stdin
2. Validate required fields (session_id, transcript_path, hook_event_name)
3. Ensure runtime directories exist
4. Load existing session metadata (if exists)
5. Update metadata:
- sessionId, transcriptPath
- cwd (if provided)
- lastUpdated
- lastPrompt (if UserPromptSubmit)
- lastStopTime (if Stop)
7. Write metadata atomically
8. If Stop event:
→ Create review signal file
Atomic Writes (writeFileAtomic(), lines 42-47):
async function writeFileAtomic(filePath: string, contents: string): Promise<void> {
const dir = path.dirname(filePath);
const tempPath = path.join(dir, `.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`);
await fs.promises.writeFile(tempPath, contents, 'utf8');
await fs.promises.rename(tempPath, filePath);
}Why Atomic?: Prevents Sage from reading partial files if hook is interrupted.
Error Handling (appendError(), lines 59-66):
- Writes errors to
~/.sage/{project-path}/runtime/hook-errors.log - Best-effort (doesn't throw if logging fails)
- Includes timestamps
Project Root Detection:
const projectRoot = process.env.CLAUDE_PROJECT_DIR
? path.resolve(process.env.CLAUDE_PROJECT_DIR)
: process.cwd();Why?: Claude Code sets CLAUDE_PROJECT_DIR to the project root, not the Sage repo root.
Purpose: Automatically configures Sage hooks on startup. Also available as CLI script.
Automatic: Called during App initialization (App.tsx useEffect)
Manual Command: npm run configure-hooks
Target File: .claude/settings.local.json (in the project directory)
Hook Configuration:
{
"hooks": {
"SessionStart": [{
"hooks": [{
"type": "command",
"command": "node \"/path/to/sage/dist/hooks/sageHook.js\"",
"timeout": 30
}]
}],
"Stop": [...],
"UserPromptSubmit": [...]
}
}Key Design Decisions:
-
Absolute Path: Uses absolute path to Sage's compiled hook script (
dist/hooks/sageHook.js), computed at runtime fromimport.meta.url. This ensures hooks work regardless of where Sage is installed (local dev, npm global, etc.). -
Compiled JS: Points to
dist/hooks/sageHook.js(notsrc/), so it works with npm packages (which only includedist/). -
Node instead of tsx: Uses
nodedirectly for faster hook execution (no TypeScript compilation overhead).
Exported Function:
export interface HookConfigResult {
configured: boolean;
alreadyConfigured: boolean;
}
export async function ensureHooksConfigured(): Promise<HookConfigResult>Algorithm (ensureHooksConfigured()):
1. Compute SAGE_ROOT from import.meta.url (works for both dev and npm install)
2. Read existing settings.local.json (or create empty object)
3. Ensure hooks object exists
4. For each target event:
a. Get existing hooks array (or empty array)
b. Find any existing Sage hook (by checking for "sageHook.ts" in command)
c. If not present:
→ Append Sage hook entry
→ Set anyAdded = true
d. If present but wrong path:
→ Update to correct path
→ Set anyAdded = true
5. Write updated settings file
6. Return { configured: true, alreadyConfigured: !anyAdded }
Features:
- Auto-update: If an old/broken Sage hook exists, updates it to the correct path
- Deduplication: Detects existing Sage hooks by looking for "sageHook" in command
- Non-destructive: Preserves other hooks configured by the user
- Graceful errors: Failures are caught in App.tsx and shown as warnings (non-blocking)
Purpose: Renders structured critique cards in the terminal UI.
Props:
interface CritiqueCardProps {
critique: CritiqueResponse;
prompt?: string;
index: number;
isPartial?: boolean; // True if reviewing incomplete response
}Visual Design:
-
Verdict: Symbol + color-coded text
✓Approved (green)⚠Concerns (yellow)✗Critical Issues (red)
-
Sections:
- WHY (only shown for non-Approved verdicts)
- ALTERNATIVES (blue, only if non-empty)
- QUESTIONS (magenta, only if non-empty)
- MESSAGE FOR AGENT (cyan, only if non-empty)
Terminal Width Handling:
const { stdout } = useStdout();
const terminalWidth = (stdout?.columns ?? 80) - 2;Why?: Accounts for App container padding to draw separator lines correctly.
Truncation (truncatePrompt(), lines 83-87):
- Cleans whitespace
- Truncates to 60 chars by default
- Adds ellipsis
Purpose: Renders user questions and Sage responses in chat mode.
Visual Design:
- User messages:
> {content} - Sage messages:
● {content}
Simple Component: Just displays role and content, no complex logic.
Purpose: Full-screen overlay that surfaces streamed Codex events for the current review. Toggled with Ctrl+O while in continuous mode.
Highlights:
- Displays timestamped events with color-coded tags for assistant messages, reasoning traces, command executions, file changes, todos, and errors.
- Updates live during a review and keeps the most recent stream log available after completion.
- Shows the active session/prompt context plus instructions for exiting (
Ctrl+Oagain).
Purpose: Defines available AI models for Sage to use.
Exports:
export interface ModelConfig {
id: string; // Model identifier (e.g., 'gpt-5.1-codex')
name: string; // Display name (e.g., 'GPT-5.1 Codex')
}
export const AVAILABLE_MODELS: ModelConfig[]; // List of available models
export const DEFAULT_MODEL: string; // Default model ID ('gpt-5.1-codex')
export type ModelId = (typeof AVAILABLE_MODELS)[number]['id']; // Union of model IDsAvailable Models:
- GPT-5.1 Codex, GPT-5.1 Codex Mini
- GPT-5.1, GPT-5, GPT-5 Mini, GPT-5 Nano
- GPT-4.1, GPT-4.1 Mini, GPT-4.1 Nano
Purpose: Manages user preferences storage and retrieval.
Storage Location: ~/.sage/settings.json (global, not per-project)
Key Functions:
getSettingsPath(): Returns path to settings fileloadSettings(): Loads settings from disk, returns defaults if missingsaveSettings(): Saves settings to disk
Settings Interface:
export interface SageSettings {
selectedModel: string; // Currently selected model ID
debugMode: boolean; // Show verbose status messages
}Purpose: Terminal UI for selecting AI models and toggling debug mode.
Props:
interface SettingsScreenProps {
currentModel: string; // Currently selected model
debugMode: boolean; // Current debug mode state
onSelectModel: (modelId: string) => void; // Called when user selects model
onToggleDebugMode: () => void; // Called when user toggles debug
onBack: () => void; // Called when user exits settings
}Features:
- Lists all available models from
AVAILABLE_MODELS - Shows checkmark (✓) next to current selection
- Toggle for debug mode (shows verbose status messages)
- Arrow key navigation with Enter to select/toggle
- ESC or B to go back
Purpose: Animated spinner for loading states.
Implementation:
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
useEffect(() => {
const timer = setInterval(() => {
setFrame((prevFrame) => (prevFrame + 1) % SPINNER_FRAMES.length);
}, 80);
return () => clearInterval(timer);
}, []);Animation: 10-frame cycle, 80ms per frame = 800ms full cycle.
Problem: Claude's JSONL logs contain:
- Primary user prompts
- Assistant responses
- Sidechain/internal entries (filtered)
- Tool calls (some included, some filtered)
- Resume sessions (UUIDs reused)
Solution: Two-pass algorithm:
Pass 1: Collection
const primaryUserPrompts: Array<{ uuid: string; text: string }> = [];
const assistantEntries: Array<{ uuid: string; parentUuid: string | null; message: any }> = [];
const entriesByUuid = new Map<string, any>();
// Stream JSONL file
for await (const line of rl) {
const entry = JSON.parse(line);
// Skip sidechains
if (entry?.isSidechain) continue;
// Store for UUID resolution
if (entry?.uuid) {
entriesByUuid.set(entry.uuid, entry);
}
// Collect primary user prompts
if (entry?.type === 'user' && isPrimaryUserPrompt(entry)) {
primaryUserPrompts.push({ uuid: entry.uuid, text: extractText(entry.message) });
}
// Collect assistant responses
if (entry?.type === 'assistant') {
assistantEntries.push({
uuid: entry.uuid ?? entry.parentUuid,
parentUuid: entry.parentUuid,
message: entry.message,
});
}
}Pass 2: Matching
const responsesByUser = new Map<string, Array<{ uuid: string; message: any }>>();
// Group assistant responses by root user UUID
for (const entry of assistantEntries) {
const rootUuid = resolveRootUserUuid(entry.parentUuid, entriesByUuid, primaryUserSet);
if (!rootUuid) continue;
if (!responsesByUser.has(rootUuid)) {
responsesByUser.set(rootUuid, []);
}
responsesByUser.get(rootUuid)!.push({ uuid: entry.uuid, message: entry.message });
}
// Build turn pairs
for (const userEntry of primaryUserPrompts) {
const responses = responsesByUser.get(userEntry.uuid) ?? [];
const agentText = formatAssistantMessage(responses);
turns.push({
user: userEntry.text,
agent: agentText,
userUuid: userEntry.uuid,
assistantUuid: responses[responses.length - 1]?.uuid,
});
}Complexity: O(n) where n = number of JSONL lines. Single pass through file.
Problem: Assistant responses have parentUuid pointing to their immediate parent, but we need the root user prompt UUID (which may be several levels up).
Solution: Traverse parent chain upward:
function resolveRootUserUuid(
parentUuid: string | null,
entriesByUuid: Map<string, any>,
primaryUserSet: Set<string>,
): string | null {
let current = parentUuid ?? null;
const visited = new Set<string>(); // Prevent infinite loops
while (current) {
if (visited.has(current)) {
return null; // Circular reference
}
visited.add(current);
if (primaryUserSet.has(current)) {
return current; // Found root user prompt
}
const parentEntry = entriesByUuid.get(current);
if (!parentEntry) {
return null; // Chain broken
}
current = parentEntry.parentUuid ?? null;
}
return null; // No root found
}Complexity: O(d) where d = depth of parent chain (typically < 10).
Problem: Multiple review signals may arrive while Sage is processing. Need FIFO ordering without race conditions.
Solution: Single worker with ref-based queue:
const workerRunningRef = useRef(false);
const queueRef = useRef<ReviewQueueItem[]>([]);
async function processQueue(): Promise<void> {
if (workerRunningRef.current) return; // Already processing
if (!activeSession) return;
if (queueRef.current.length === 0) return;
workerRunningRef.current = true;
while (queueRef.current.length > 0 && activeSession) {
const job = queueRef.current[0];
setCurrentJob(job); // Update UI
try {
const result = await performIncrementalReview(...);
appendReview(result);
await fs.unlink(job.signalPath); // Cleanup
} catch (err) {
// Log error, continue to next job
}
queueRef.current = queueRef.current.slice(1);
setQueue(queueRef.current); // Sync to state
}
workerRunningRef.current = false;
}Why Refs?: queueRef can be updated without triggering re-renders during processing. State (queue) is synced only when needed for UI.
Deduplication: processedSignalsRef tracks processed signal files to prevent duplicates.
Problem: When re-selecting a session, Sage should:
- Skip reviews for already-reviewed turns
- Review only new turns
- Avoid duplicate critiques
Solution: Turn count comparison:
const metadata = await loadThreadMetadata(sessionId);
const { turns } = await extractTurns({ transcriptPath });
const currentTurnCount = turns.length;
const lastReviewedTurnCount = metadata?.lastReviewedTurnCount ?? 0;
const hasNewTurns = currentTurnCount > lastReviewedTurnCount;
if (metadata && !hasNewTurns) {
// No new turns → resume without new critique
return { critique: cachedCritique, isFreshCritique: false };
} else if (metadata && hasNewTurns) {
// New turns → review incrementally
const newTurns = turns.slice(lastReviewedTurnCount);
return await runFollowupReview(thread, { sessionId, newTurns });
} else {
// New thread → full review
return await runInitialReview({ sessionId, turns }, thread);
}Why Turn Count?: More reliable than UUID matching for resume detection (UUIDs may be reused in resumed sessions).
Edge Case: If turn count decreases (session truncated), treats as new session.
State (triggers re-renders):
screen: Current screen modesessions: List of available sessionsreviews: Completed critique cardsqueue: Review queue (synced from ref)currentJob: Currently processing job
Refs (no re-renders):
queueRef: Queue state during processingworkerRunningRef: Worker lock flagwatcherRef: File watcher instancecodexThreadRef: Codex thread (persisted across renders)lastTurnSignatureRef: Last reviewed turn UUIDprocessedSignalsRef: Set of processed signal files
Why This Split?:
- State updates trigger React re-renders (expensive)
- Refs allow mutating values without re-renders
- Queue processing updates frequently; UI only needs updates at key moments
Queue Sync Pattern:
// Enqueue (updates both ref and state)
function enqueueJob(job: ReviewQueueItem) {
queueRef.current = [...queueRef.current, job];
setQueue(queueRef.current); // Sync to state
}
// Process (updates ref, syncs state at end)
async function processQueue() {
while (queueRef.current.length > 0) {
// ... process job ...
queueRef.current = queueRef.current.slice(1);
}
setQueue(queueRef.current); // Sync to state
}Why?: Ref allows fast mutations during processing; state sync ensures UI updates.
Review Cache (reviewCacheRef):
- Loaded on session selection
- Updated after each review
- Persisted to disk after each review
- Used to restore critiques on re-selection
Thread Metadata:
- Loaded before review
- Saved after successful review
- Updated with turn count after incremental review
- Deleted if resume fails
Session Discovery (listActiveSessions()):
try {
entries.push(...fs.readdirSync(SESSIONS_DIR));
} catch (error: any) {
if (error?.code === 'ENOENT') {
return []; // Directory doesn't exist yet
}
throw error; // Real error
}Signal Processing (processSignalFile()):
try {
// ... process signal ...
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to process review signal.';
setStatusMessages((prev) => [...prev, message]);
} finally {
if (!enqueued) {
processedSignalsRef.current.delete(filePath);
}
}Pattern: Catch errors, log to UI, continue processing.
Review Timeouts (review.ts):
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Codex review timed out after 10 minutes')), 5 * 60 * 1000);
});
const reviewPromise = runInitialReview(...);
const result = await Promise.race([reviewPromise, timeoutPromise]);Timeouts:
- Initial review: 5 minutes
- Incremental review: 5 minutes
- Chat: 2 minutes
Why?: Codex reviews can hang; timeouts prevent indefinite blocking.
Pattern: Write to temp file, then rename (atomic on Unix):
async function writeFileAtomic(filePath: string, contents: string): Promise<void> {
const tempPath = path.join(dir, `.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`);
await fs.promises.writeFile(tempPath, contents, 'utf8');
await fs.promises.rename(tempPath, filePath);
}Used In:
sageHook.ts: Session metadata writesreviewsCache.ts: Cache file writesthreads.ts: Thread metadata writes
Why?: Prevents Sage from reading partial/corrupted files.
Cache Normalization (normalizeCache()):
function normalizeCache(raw: Partial<SessionReviewCache> | null, sessionId: string): SessionReviewCache | null {
if (!raw || typeof raw !== 'object') return null;
const reviews = Array.isArray(raw.reviews) ? raw.reviews : [];
const sanitized: StoredReview[] = [];
for (const entry of reviews) {
// Validate required fields
if (!entry?.turnSignature || typeof entry.turnSignature !== 'string') continue;
if (!entry?.completedAt || typeof entry.completedAt !== 'string') continue;
if (!entry?.critique || typeof entry.critique !== 'object') continue;
sanitized.push({
turnSignature: entry.turnSignature,
completedAt: entry.completedAt,
critique: entry.critique,
latestPrompt: entry.latestPrompt ?? null,
artifactPath: entry.artifactPath,
promptText: entry.promptText,
});
}
// Sort by timestamp
sanitized.sort((a, b) => Date.parse(a.completedAt) - Date.parse(b.completedAt));
return { sessionId, lastTurnSignature: ..., reviews: sanitized };
}Purpose: Validates cache structure, prevents crashes from corrupted files.
Hook Registration (configureHooks.ts):
- Writes to
.claude/settings.local.json - Command:
node "/absolute/path/to/sage/dist/hooks/sageHook.js" - Events:
SessionStart,Stop,UserPromptSubmit - Auto-configured: Runs automatically on Sage startup (no manual setup needed)
Hook Execution (sageHook.ts):
- Receives JSON payload via stdin
- Writes metadata to
.sage/runtime/sessions/ - Writes signals to
.sage/runtime/needs-review/
Transcript Access:
- Reads Claude's JSONL log files (path from hook payload)
- Location: Provided by Claude Code via
transcript_path
Thread Lifecycle:
// Create thread
const thread = codex.startThread({ model: 'gpt-4.1-nano' });
// Resume thread
const thread = codex.resumeThread(threadId, { model: 'gpt-4.1-nano' });
// Run review
const result = await thread.run(prompt, { outputSchema: CRITIQUE_SCHEMA });Structured Output:
- Uses JSON schema to enforce response format
- Codex returns structured object matching
CritiqueResponse - Parsed automatically by SDK
Read-Only Enforcement:
- Enforced via prompt instructions (repeated emphasis)
- Codex SDK may support permission settings (future enhancement)
Codex Local Config Inheritance:
- Sage does not override Codex settings; it inherits whatever is in
~/.codex/config.toml(andmanaged_config.tomlif present). Approval policy, sandbox mode, and allowed MCP servers all carry through to Sage reviews. - Treat this as both an opportunity and caution. Sage is intended for read only use. MCP servers such as Context7 will only give you more reliable, helpful verdicts.
- TODO: Add support for creating a
.codex/config.tomlpreset for Sage use.
Runtime Directories:
Global settings are stored at the root level:
~/.sage/settings.json: User preferences (model selection)
Each project gets its own directory under ~/.sage/ based on its full path (e.g., /Users/you/projects/foo → ~/.sage/Users-you-projects-foo/):
~/.sage/{project-path}/runtime/sessions/: Session metadata~/.sage/{project-path}/runtime/needs-review/: Review signals~/.sage/{project-path}/threads/: Thread metadata~/.sage/{project-path}/reviews/: Review cache~/.sage/{project-path}/debug/: Debug artifacts
All Created Automatically: No manual setup required.
src/lib/codex.test.ts:
- Tests prompt builders (
buildInitialPromptPayload,buildFollowupPromptPayload) - Validates prompt structure
- Checks turn formatting
src/lib/jsonl.test.ts (likely exists):
- Tests turn extraction
- Validates sidechain filtering
- Tests warmup detection
Run Tests:
# Individual test files use Node assert
tsx src/lib/codex.test.tsNo Test Framework: Tests use Node's built-in assert module.
Strict Mode: Enabled in tsconfig.json
Module System: ES modules ("module": "Node16")
Import Patterns:
// Always use .js extension for imports (TypeScript requirement)
import App from './ui/App.js';Type Definitions:
- Interfaces for object shapes
typealiases for unions/enumsas constfor literal types
Consistent Usage: All async functions use async/await (no raw promises)
Error Propagation: Errors bubble up to UI layer (caught in App.tsx)
Timeout Pattern:
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Timeout')), duration);
});
const result = await Promise.race([actualPromise, timeoutPromise]);Streaming for Large Files:
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
// Process line
}Atomic Writes:
const tempPath = `${filePath}.tmp`;
await fs.writeFile(tempPath, content);
await fs.rename(tempPath, filePath);Functional Components: All components use function syntax
Hooks Usage:
useState: Component stateuseEffect: Side effects (file watching, cleanup)useRef: Values that don't trigger re-rendersuseInput: Keyboard input handling (Ink)
Effect Cleanup:
useEffect(() => {
const watcher = chokidar.watch(...);
return () => {
watcher.close();
};
}, []);User-Facing:
const message = err instanceof Error ? err.message : 'Default message';
setError(message);Logging:
console.warn(`[Sage] Failed to ...: ${error?.message ?? error}`);Hook Errors:
await appendError(`Hook error: ${error instanceof Error ? error.message : String(error)}`);- Single-Instance Assumption: Multiple Sage processes can race on cache/thread files
- Incomplete Responses: Manual selection during Claude typing may review partial responses
- Resume Chains: Doesn't follow resumed session chains back to parent
- No Arrow Navigation: Can't navigate critique history with keyboard
- Multi-Instance Support: File locking or database for shared state
- Streaming Reviews: Review as Claude types (partial critiques)
- Rich Followups: More interactive chat mode with history navigation
- Review Filtering: Filter critiques by verdict, date, etc.
- Export Critiques: Save critiques to markdown/files
- Thread Sharing: Share Codex threads across sessions (if same codebase)
Current: Centralized state in App.tsx
Future: Consider:
- State management library (Zustand, Redux)
- Separate queue worker process
- WebSocket for real-time updates
- Plugin system for custom review types
Sage is a well-architected code reviewer that integrates seamlessly into the Claude Code workflow. Key strengths:
- Read-only design: Never modifies files
- Persistent state: Threads and reviews survive restarts
- Graceful error handling: Continues operating despite failures
- Clean separation: UI, business logic, and integrations are separated
- Extensible: Easy to add new features (chat mode, caching, etc.)
The codebase demonstrates:
- Strong TypeScript usage
- React best practices
- Efficient file I/O (streaming, atomic writes)
- Robust error handling
- Clear code organization
Happy reading! 🎩
This guide was generated for comprehensive codebase understanding. For specific implementation questions, refer to the inline code comments and type definitions.