Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
7677868
feat(telemetry): implement Phase 1 foundation for anonymous telemetry
flora131 Jan 22, 2026
79d4c56
feat(telemetry): implement Phase 2 CLI command tracking
flora131 Jan 22, 2026
1808274
feat(telemetry): implement Phase 3 slash command CLI tracking
flora131 Jan 22, 2026
828c097
feat(telemetry): implement Phase 4 agent session tracking core
flora131 Jan 22, 2026
99f3999
feat(telemetry): add agent session hooks for all platforms
flora131 Jan 22, 2026
f4d60c5
docs(telemetry): update spec with Phase 4 implementation details
flora131 Jan 22, 2026
6076ced
feat(telemetry): implement Phase 5 user consent flow
flora131 Jan 22, 2026
48753a0
test(telemetry): add CI environment mocking to telemetry tests
flora131 Jan 22, 2026
81d1f89
Merge branch 'main' into flora131/feature/add-anon-telem
flora131 Jan 22, 2026
a1f769e
feat(telemetry): require explicit consent before sending telemetry
flora131 Jan 22, 2026
b5cb38e
refactor(telemetry): remove session duration tracking
flora131 Jan 23, 2026
89e7b01
fix(telemetry): add early exit when jq is unavailable
flora131 Jan 23, 2026
a1ba1e6
refactor(init): move telemetry consent after directory confirmation
flora131 Jan 23, 2026
99d9fd8
fix(telemetry): write compact JSON for proper JSONL format
flora131 Jan 23, 2026
65384cc
feat(telemetry): implement Copilot agent detection and background upload
flora131 Jan 25, 2026
7f349ae
chore: ignore atomic binary build output
flora131 Jan 25, 2026
7c82aa2
test: add Copilot agent detection E2E tests and refactor telemetry tests
flora131 Jan 25, 2026
0dcb9a8
refactor(telemetry): remove unused function and normalize version format
flora131 Jan 25, 2026
0991705
docs(research): update progress for Copilot agent detection refactoring
flora131 Jan 25, 2026
ae69050
fix(telemetry): prefix detected Copilot agent names with slash
flora131 Jan 25, 2026
859f5ce
refactor(telemetry): move jq dependency checks to individual functions
flora131 Jan 25, 2026
86faf7d
resolve merge conflicts
flora131 Jan 25, 2026
af7ae26
feat(ralph): add TypeScript ralph-loop.ts script to replace shell ver…
flora131 Jan 25, 2026
320b3de
feat(ralph): add TypeScript start-ralph-session.ts to replace shell v…
flora131 Jan 25, 2026
c9803de
feat(ralph): add TypeScript cancel-ralph.ts to replace shell version
flora131 Jan 25, 2026
25e108e
refactor(ralph): update stop-hook.ts to use YAML frontmatter state fi…
flora131 Jan 25, 2026
186c184
chore(hooks): update hooks.json to use TypeScript sessionStart script
flora131 Jan 25, 2026
2af8a32
chore(ralph): delete obsolete shell scripts after TypeScript conversion
flora131 Jan 25, 2026
e78fe8f
test(ralph): add comprehensive YAML frontmatter unit tests
flora131 Jan 25, 2026
78107e0
test(ralph): add CLI argument parsing unit tests for ralph-loop.ts
flora131 Jan 25, 2026
024547b
test(ralph): add integration tests for full Ralph loop lifecycle
flora131 Jan 25, 2026
680d033
docs(ralph): update documentation for TypeScript conversion
flora131 Jan 25, 2026
5fca1ab
refactor(telemetry): convert shell scripts to TypeScript and inline d…
flora131 Jan 25, 2026
14f1b26
fix(ralph): improve type safety in test files
flora131 Jan 25, 2026
d402489
refactor(telemetry): remove legacy event file support and update depe…
flora131 Jan 25, 2026
bcb0488
fix(telemetry): address PR feedback for timestamp consistency and con…
lavaman131 Jan 25, 2026
aae0568
chore(deps): update lockfile with dependency version bumps
lavaman131 Jan 25, 2026
e68acfb
refactor(hooks): split stop-hook into modular components
flora131 Jan 25, 2026
891d020
fix(tests): update hook file paths after stop-hook refactor
flora131 Jan 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
338 changes: 338 additions & 0 deletions .claude/hooks/telemetry-stop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,338 @@
#!/usr/bin/env bun

/**
* Claude Code Stop Hook - Telemetry Tracking
*
* This hook is called when a Claude Code session ends.
* It extracts Atomic slash commands from the session transcript
* and logs an agent_session telemetry event.
*
* Reference: Spec Section 5.3.3
*/

import { $ } from "bun";
import { existsSync, mkdirSync } from "fs";
import { dirname, join } from "path";
import { randomUUID } from "crypto";

// Atomic commands to track
// Source of truth: src/utils/telemetry/constants.ts
// Keep synchronized when adding/removing commands
const ATOMIC_COMMANDS = [
"/research-codebase",
"/create-spec",
"/create-feature-list",
"/implement-feature",
"/commit",
"/create-gh-pr",
"/explain-code",
"/ralph-loop",
"/ralph:ralph-loop",
"/cancel-ralph",
"/ralph:cancel-ralph",
"/ralph-help",
"/ralph:help",
];

// Get the telemetry data directory
// Source of truth: src/utils/config-path.ts getBinaryDataDir()
// Keep synchronized when changing data directory paths
function getTelemetryDataDir(): string {
const osType = process.platform;
if (osType === "win32") {
// Windows
const appData = process.env.LOCALAPPDATA || join(process.env.USERPROFILE || "", "AppData/Local");
return join(appData, "atomic");
} else {
// Unix (macOS/Linux)
const xdgData = process.env.XDG_DATA_HOME || join(process.env.HOME || "", ".local/share");
return join(xdgData, "atomic");
}
}

// Get the telemetry events file path
// Arguments: agentType = "claude", "opencode", "copilot"
function getEventsFilePath(agentType: string): string {
return join(getTelemetryDataDir(), `telemetry-events-${agentType}.jsonl`);
}

// Get the telemetry.json state file path
function getTelemetryStatePath(): string {
return join(getTelemetryDataDir(), "telemetry.json");
}

// Check if telemetry is enabled
// Source of truth: src/utils/telemetry/telemetry.ts isTelemetryEnabled()
// Keep synchronized when changing opt-out logic
// Returns true if enabled, false if disabled
async function isTelemetryEnabled(): Promise<boolean> {
// Check environment variables first (quick exit)
if (process.env.ATOMIC_TELEMETRY === "0") {
return false;
}

if (process.env.DO_NOT_TRACK === "1") {
return false;
}

// Check telemetry.json state file
const stateFile = getTelemetryStatePath();

if (!existsSync(stateFile)) {
// No state file = telemetry not configured, assume disabled
return false;
}

try {
// Check enabled and consentGiven fields in state file
const stateContent = (await Bun.file(stateFile).json()) as any;
const enabled = stateContent?.enabled ?? false;
const consentGiven = stateContent?.consentGiven ?? false;

return enabled === true && consentGiven === true;
} catch {
return false;
}
}

// Get anonymous ID from telemetry state
async function getAnonymousId(): Promise<string | null> {
const stateFile = getTelemetryStatePath();

if (existsSync(stateFile)) {
try {
const stateContent = (await Bun.file(stateFile).json()) as any;
return stateContent?.anonymousId || null;
} catch {
return null;
}
}
return null;
}

// Get Atomic version from state file (if available) or use "unknown"
async function getAtomicVersion(): Promise<string> {
// Try to get version by running atomic --version
// Strip "atomic v" prefix to match TypeScript VERSION format
// Fall back to "unknown" if not available
try {
const result = await $`atomic --version`.text();
return result.trim().replace(/^atomic v/, "") || "unknown";
} catch {
return "unknown";
}
}

// Extract Atomic commands from JSONL transcript
// CRITICAL: Only extracts from string content in user messages (user-typed commands)
// Array content in user messages means skill instructions were loaded - we ignore these
// Usage: extractCommands("transcript JSONL content")
// Output: comma-separated list of found commands
function extractCommands(transcript: string): string {
const foundCommands: string[] = [];

// Process each line (JSONL format - one JSON object per line)
const lines = transcript.split("\n");
for (const line of lines) {
// Skip empty lines
if (!line.trim()) continue;

try {
const parsed = JSON.parse(line);

// Extract type from JSON (skip if not user message)
const msgType = parsed?.type;
if (msgType !== "user") continue;

// Check content type - only process string content (user-typed commands)
// Array content = skill instructions loaded, which contain command references we should ignore
const content = parsed?.message?.content;
if (typeof content !== "string") continue;

// Extract text content from user message (string content only)
const text = content;
if (!text) continue;

// Find all commands in this user message
for (const cmd of ATOMIC_COMMANDS) {
// Escape special regex characters
const escapedCmd = cmd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

// Count occurrences (for usage frequency tracking)
const regex = new RegExp(`(^|[\\s]|[^\\w/_-])${escapedCmd}([\\s]|$|[^\\w_-])`, "g");
const matches = text.match(regex);
const count = matches ? matches.length : 0;

// Add command once for each occurrence
for (let i = 0; i < count; i++) {
foundCommands.push(cmd);
}
}
} catch {
// Skip invalid JSON lines
continue;
}
}

// Return commands (comma-separated, preserving duplicates for frequency tracking)
return foundCommands.join(",");
}

// Generate a UUID v4
function generateUuid(): string {
return randomUUID();
}

// Get current timestamp in ISO 8601 format
function getTimestamp(): string {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}

// Get current platform
function getPlatform(): string {
switch (process.platform) {
case "darwin":
return "darwin";
case "linux":
return "linux";
case "win32":
return "win32";
default:
return "unknown";
}
}

// Write an agent session event to the telemetry events file
// Source of truth: src/utils/telemetry/telemetry-file-io.ts appendEvent()
// Keep synchronized when changing event structure or file writing logic
//
// Arguments:
// agentType: "claude", "opencode", or "copilot"
// commands: comma-separated list of commands (e.g., "/commit,/create-gh-pr")
// sessionStartedAt: ISO timestamp when session started (unused, kept for parity)
//
// Returns: true on success, false on failure
async function writeSessionEvent(agentType: string, commandsStr: string, _sessionStartedAt?: string): Promise<boolean> {
// Early return if telemetry disabled
if (!(await isTelemetryEnabled())) {
return true;
}

// Early return if no commands
if (!commandsStr) {
return true;
}

// Get required fields
const anonymousId = await getAnonymousId();

if (!anonymousId) {
// No anonymous ID = telemetry not properly configured
return false;
}

const eventId = generateUuid();
const sessionId = eventId;
const timestamp = getTimestamp();
const platform = getPlatform();
const atomicVersion = await getAtomicVersion();

// Convert commands to JSON array
const commands = commandsStr.split(",").filter((c) => c);
const commandCount = commands.length;

// Build event JSON
const eventJson = {
anonymousId,
eventId,
sessionId,
eventType: "agent_session",
timestamp,
agentType,
commands,
commandCount,
platform,
atomicVersion,
source: "session_hook",
};

// Get events file path and ensure directory exists
const eventsFile = getEventsFilePath(agentType);
const eventsDir = dirname(eventsFile);

if (!existsSync(eventsDir)) {
mkdirSync(eventsDir, { recursive: true });
}

// Append event to JSONL file
await Bun.write(eventsFile, (await Bun.file(eventsFile).text().catch(() => "")) + JSON.stringify(eventJson) + "\n");

return true;
}

// Spawn background upload process
// Usage: spawnUploadProcess()
async function spawnUploadProcess(): Promise<void> {
try {
// Check if atomic command exists
await $`command -v atomic`.quiet();
// Spawn in background
$`nohup atomic --upload-telemetry > /dev/null 2>&1 &`.quiet().nothrow();
} catch {
// atomic not available, skip
}
}

// Main execution
async function main(): Promise<void> {
// Read hook input from stdin
// Claude Code passes JSON with session information including transcript_path
const input = await Bun.stdin.text();

// Parse input fields
let transcriptPath: string | undefined;
let sessionStartedAt: string | undefined;

try {
const parsed = JSON.parse(input);
transcriptPath = parsed?.transcript_path || undefined;
sessionStartedAt = parsed?.session_started_at || undefined;
} catch {
process.exit(0);
}

// Early exit if no transcript available
if (!transcriptPath || !existsSync(transcriptPath)) {
process.exit(0);
}

// Read transcript content
let transcript: string;
try {
transcript = await Bun.file(transcriptPath).text();
} catch {
transcript = "";
}

// Early exit if transcript is empty
if (!transcript) {
process.exit(0);
}

// Extract commands from transcript
const commands = extractCommands(transcript);

// Write session event (helper handles telemetry enabled check)
if (commands) {
await writeSessionEvent("claude", commands, sessionStartedAt);

// Spawn upload process
// Atomic file operations prevent duplicate uploads even if multiple processes spawn
await spawnUploadProcess();
}

// Exit successfully (don't block session end)
process.exit(0);
}

main();
13 changes: 13 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,18 @@
},
"enabledPlugins": {
"ralph@atomic-plugins": true
},
"hooks": {
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "bun run ${CLAUDE_PROJECT_DIR}/.claude/hooks/telemetry-stop.ts",
"timeout": 30
}
]
}
]
}
}
4 changes: 2 additions & 2 deletions .github/agents/cancel-ralph.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ Cancel an active Ralph Wiggum loop.
## Execute Cancellation

```!
if [[ "$(uname)" == MINGW* || "$(uname)" == MSYS* || "$(uname)" == CYGWIN* ]]; then powershell -ExecutionPolicy Bypass -File ./.github/scripts/cancel-ralph.ps1; else ./.github/scripts/cancel-ralph.sh; fi
bun run ./.github/scripts/cancel-ralph.ts
```

This will:
- Archive state to `.github/logs/`
- Remove state files (`.github/ralph-loop.local.json`, `.github/ralph-continue.flag`)
- Remove state files (`.github/ralph-loop.local.md`, `.github/ralph-continue.flag`)
- Kill any spawned `copilot-cli` processes
10 changes: 3 additions & 7 deletions .github/agents/ralph-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ $ARGUMENTS
Execute the setup script to initialize the Ralph loop:

```!
if [[ "$(uname)" == MINGW* || "$(uname)" == MSYS* || "$(uname)" == CYGWIN* ]]; then powershell -ExecutionPolicy Bypass -File ./.github/scripts/setup-ralph-loop.ps1 $ARGUMENTS; else ./.github/scripts/setup-ralph-loop.sh $ARGUMENTS; fi
bun run ./.github/scripts/ralph-loop.ts $ARGUMENTS
```

### Parameters
Expand Down Expand Up @@ -47,16 +47,12 @@ CRITICAL: Only output the promise when the statement is completely and unequivoc
## Manual Cancellation

```bash
# macOS/Linux
./.github/scripts/cancel-ralph.sh

# Windows
powershell -ExecutionPolicy Bypass -File ./.github/scripts/cancel-ralph.ps1
bun run ./.github/scripts/cancel-ralph.ts
```

## Monitoring

```bash
cat .github/ralph-loop.local.json | jq . # Check full state
head -20 .github/ralph-loop.local.md # Check state (YAML frontmatter)
cat .github/logs/ralph-sessions.jsonl | jq -s . # View session history
```
Loading
Loading