Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ cli/
├── cmd/ # CLI commands (install, uninstall, status, test, hook, config, sync)
├── internal/
│ ├── client/ # HTTP client, config loading
│ ├── correlation/ # W3C trace_id/span_id generation and per-session persistence
│ ├── envelope/ # Raw event envelope types
│ ├── git/ # Git context extraction
│ ├── sync/ # Transcript sync and parsing (Claude Code parser, state management)
Expand Down
144 changes: 144 additions & 0 deletions cmd/correlation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package cmd

import (
"github.com/promptconduit/cli/internal/client"
"github.com/promptconduit/cli/internal/correlation"
"github.com/promptconduit/cli/internal/envelope"
)

// buildCorrelation populates trace/span IDs for an outgoing envelope and
// records the new span for future parent lookups. Failures are silent —
// correlation is best-effort and must never block the hook.
//
// hookEvent is the tool-native event name (Claude Code uses PreToolUse,
// PostToolUse, etc.). nativeEvent is the parsed payload; sessionID has
// already been pulled out by the caller.
func buildCorrelation(tool, hookEvent, sessionID string, nativeEvent map[string]interface{}) *envelope.Correlation {
store := correlation.NewStore(client.ConfigDir())
store.MaybeGC()

spanID := correlation.NewSpanID()

// No session ID: emit an orphan trace for this event only.
if sessionID == "" {
return &envelope.Correlation{
TraceID: correlation.NewTraceID(),
SpanID: spanID,
}
}

rec, err := store.LoadOrCreateTrace(sessionID)
if err != nil || rec == nil {
return &envelope.Correlation{
TraceID: correlation.NewTraceID(),
SpanID: spanID,
}
}

parentSpanID := lookupParentSpan(store, tool, hookEvent, sessionID, nativeEvent)
recordSpan(store, tool, hookEvent, sessionID, spanID, nativeEvent)

return &envelope.Correlation{
TraceID: rec.TraceID,
SpanID: spanID,
ParentSpanID: parentSpanID,
}
}

// lookupParentSpan resolves parent_span_id for known event chains.
// Returns "" if no parent applies or the lookup misses.
//
// Note: Claude Code's hook payloads do NOT currently carry tool_use_id /
// task_id / elicitation_id directly — those fields only appear in the
// transcript JSONL referenced by transcript_path. The chain-keying below
// remains a no-op for those events in real traffic; server-side adapters
// can enrich linkage by parsing transcript_path. SubagentStart/Stop do
// carry agent_id and will resolve. session_id-keyed chains (PreCompact,
// SessionEnd, Stop) work everywhere.
func lookupParentSpan(store *correlation.Store, tool, hookEvent, sessionID string, e map[string]interface{}) string {
switch tool {
case "claude-code":
return lookupParentClaudeCode(store, hookEvent, sessionID, e)
}
return ""
}

func lookupParentClaudeCode(store *correlation.Store, hookEvent, sessionID string, e map[string]interface{}) string {
switch hookEvent {
case "PostToolUse", "PostToolUseFailure":
if id := stringField(e, "tool_use_id"); id != "" {
return store.LookupParent(sessionID, correlation.SpanKindToolUse, id)
}
case "SubagentStop":
if id := firstStringField(e, "subagent_id", "agent_id"); id != "" {
return store.LookupParent(sessionID, correlation.SpanKindSubagent, id)
}
case "TaskCompleted":
if id := stringField(e, "task_id"); id != "" {
return store.LookupParent(sessionID, correlation.SpanKindTask, id)
}
case "ElicitationResult":
if id := stringField(e, "elicitation_id"); id != "" {
return store.LookupParent(sessionID, correlation.SpanKindElicitation, id)
}
case "PostCompact":
return store.LookupParent(sessionID, correlation.SpanKindContextCompact, sessionID)
case "Stop", "StopFailure":
// Agent response: parent is the originating user prompt.
return store.LookupLastPromptSubmit(sessionID)
case "SessionEnd":
return store.LookupRootSpan(sessionID)
}
return ""
}

// recordSpan persists span IDs that may become future parents.
func recordSpan(store *correlation.Store, tool, hookEvent, sessionID, spanID string, e map[string]interface{}) {
switch tool {
case "claude-code":
recordSpanClaudeCode(store, hookEvent, sessionID, spanID, e)
}
}

func recordSpanClaudeCode(store *correlation.Store, hookEvent, sessionID, spanID string, e map[string]interface{}) {
switch hookEvent {
case "SessionStart":
_ = store.RecordRootSpan(sessionID, spanID)
case "UserPromptSubmit":
_ = store.RecordLastPromptSubmit(sessionID, spanID)
case "PreToolUse":
if id := stringField(e, "tool_use_id"); id != "" {
_ = store.RecordSpan(sessionID, correlation.SpanKindToolUse, id, spanID)
}
case "SubagentStart":
if id := firstStringField(e, "subagent_id", "agent_id"); id != "" {
_ = store.RecordSpan(sessionID, correlation.SpanKindSubagent, id, spanID)
}
case "TaskCreated":
if id := stringField(e, "task_id"); id != "" {
_ = store.RecordSpan(sessionID, correlation.SpanKindTask, id, spanID)
}
case "Elicitation":
if id := stringField(e, "elicitation_id"); id != "" {
_ = store.RecordSpan(sessionID, correlation.SpanKindElicitation, id, spanID)
}
case "PreCompact":
_ = store.RecordSpan(sessionID, correlation.SpanKindContextCompact, sessionID, spanID)
}
}

func stringField(e map[string]interface{}, key string) string {
if v, ok := e[key].(string); ok {
return v
}
return ""
}

func firstStringField(e map[string]interface{}, keys ...string) string {
for _, k := range keys {
if v := stringField(e, k); v != "" {
return v
}
}
return ""
}
77 changes: 77 additions & 0 deletions cmd/debug.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package cmd

import (
"fmt"
"os"

"github.com/promptconduit/cli/internal/client"
"github.com/promptconduit/cli/internal/correlation"
"github.com/spf13/cobra"
)

var debugCmd = &cobra.Command{
Use: "debug",
Short: "Debugging utilities",
}

var debugTraceCmd = &cobra.Command{
Use: "trace <session_id>",
Short: "Print the correlation trace tree for a session",
Long: `Print the locally-stored trace ID and recorded parent spans for a session.

Useful for support and self-debugging when correlation IDs look wrong.
Reads from ~/.config/promptconduit/traces/<session_id>.{json,spans.json}.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
sessionID := args[0]
store := correlation.NewStore(client.ConfigDir())

rec, err := store.LoadTrace(sessionID)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("no trace record for session %s", sessionID)
}
return fmt.Errorf("read trace: %w", err)
}

cmd.Printf("Session: %s\n", rec.SessionID)
cmd.Printf("Trace ID: %s\n", rec.TraceID)
cmd.Printf("Created: %s\n", rec.CreatedAt.Format("2006-01-02T15:04:05Z07:00"))
cmd.Printf("Last seen: %s\n", rec.LastSeenAt.Format("2006-01-02T15:04:05Z07:00"))

spans, err := store.LoadSpans(sessionID)
if err != nil {
return fmt.Errorf("read spans: %w", err)
}

cmd.Println()
cmd.Println("Recorded parent spans:")
if spans.RootSpan != "" {
cmd.Printf(" root_span: %s\n", spans.RootSpan)
}
if spans.LastPromptSubmit != "" {
cmd.Printf(" last_prompt_submit: %s\n", spans.LastPromptSubmit)
}
printSpanMap(cmd, "tool_uses", spans.ToolUses)
printSpanMap(cmd, "subagents", spans.Subagents)
printSpanMap(cmd, "tasks", spans.Tasks)
printSpanMap(cmd, "elicitations", spans.Elicitations)
printSpanMap(cmd, "context_compacts", spans.ContextCompacts)

return nil
},
}

func printSpanMap(cmd *cobra.Command, label string, m map[string]string) {
if len(m) == 0 {
return
}
cmd.Printf(" %s:\n", label)
for k, v := range m {
cmd.Printf(" %s -> %s\n", k, v)
}
}

func init() {
debugCmd.AddCommand(debugTraceCmd)
}
44 changes: 40 additions & 4 deletions cmd/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"time"

"github.com/google/uuid"
Expand Down Expand Up @@ -91,23 +92,30 @@ func processHookEvent() error {
// Detect tool (simple heuristics)
tool := detectTool(nativeEvent)
hookEvent := getHookEventName(nativeEvent)
sessionID := getSessionID(nativeEvent)

fileLog("Detected tool: %s, hook event: %s", tool, hookEvent)

// Build correlation IDs (W3C-compatible trace_id/span_id).
// Stable across hook fires within a session; best-effort, never blocks.
corr := buildCorrelation(tool, hookEvent, sessionID, nativeEvent)
if corr != nil {
fileLog("correlation: trace=%s span=%s parent=%s", corr.TraceID, corr.SpanID, corr.ParentSpanID)
}

// Extract git context from working directory
var gitCtx *envelope.GitContext
cwd := getWorkingDirectory(nativeEvent)

// Write to local events file for macOS app
writeLocalEvent(hookEvent, cwd, getSessionID(nativeEvent))
writeLocalEvent(hookEvent, cwd, sessionID)

// Trigger auto-sync on SessionEnd or Stop events
// SessionEnd: Fires when user explicitly ends session (rare - users often just close terminal)
// Stop: Fires after each Claude response - gives us incremental sync opportunities
// The sync logic deduplicates via hash checking, so frequent triggers are safe
// NOTE: Called directly (not in goroutine) since it spawns a subprocess and returns quickly
if hookEvent == "SessionEnd" || hookEvent == "Stop" {
sessionID := getSessionID(nativeEvent)
if sessionID != "" {
triggerAutoSync(sessionID)
}
Expand All @@ -120,6 +128,8 @@ func processHookEvent() error {
}
}

enr := buildEnrichment(gitCtx, corr)

apiClient := client.NewClient(cfg, Version)

// For UserPromptSubmit events, check if the user's message includes attachments
Expand Down Expand Up @@ -157,7 +167,7 @@ func processHookEvent() error {
}

// Create envelope with attachment metadata
env := envelope.NewWithAttachments(Version, tool, hookEvent, rawInput, gitCtx, envAttachments)
env := envelope.NewWithAttachments(Version, tool, hookEvent, rawInput, enr, envAttachments)

// Send via multipart with binary attachments
if err := apiClient.SendEnvelopeWithAttachmentsAsync(env, attachmentData); err != nil {
Expand All @@ -172,7 +182,7 @@ func processHookEvent() error {
}

// Create envelope with raw payload (no attachments case, or non-UserPromptSubmit events)
env := envelope.New(Version, tool, hookEvent, rawInput, gitCtx)
env := envelope.New(Version, tool, hookEvent, rawInput, enr)

fileLog("Created envelope: tool=%s, event=%s", tool, hookEvent)

Expand All @@ -186,6 +196,32 @@ func processHookEvent() error {
return nil
}

// buildEnrichment assembles the enrichment block: CLI-computed context that
// augments the raw native payload (git, source provider, correlation IDs,
// host/os/arch). Returns nil only when there's nothing to send.
func buildEnrichment(gitCtx *envelope.GitContext, corr *envelope.Correlation) *envelope.Enrichment {
enr := &envelope.Enrichment{
Git: gitCtx,
Correlation: corr,
Host: hostname(),
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}
if gitCtx != nil {
enr.Source = git.DetectSource(gitCtx.RemoteURL)
}
return enr
}

// hostname returns the machine hostname or "" if unavailable.
func hostname() string {
h, err := os.Hostname()
if err != nil {
return ""
}
return h
}

// detectTool identifies which AI tool generated the event
func detectTool(event map[string]interface{}) string {
// Check environment variable override first
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func init() {
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(insightsCmd)
rootCmd.AddCommand(skillsCmd)
rootCmd.AddCommand(debugCmd)
}

var versionCmd = &cobra.Command{
Expand Down
Loading
Loading