diff --git a/internal/agent/claude/find_all_sessions.go b/internal/agent/claude/find_all_sessions.go new file mode 100644 index 0000000..b01710b --- /dev/null +++ b/internal/agent/claude/find_all_sessions.go @@ -0,0 +1,96 @@ +package claude + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/partio-io/cli/internal/agent" +) + +// SubAgentSession holds a subagent session path and its parsed data. +type SubAgentSession struct { + Path string + Data *agent.SessionData +} + +// FindAllSessions returns all sessions in the session directory, classifying +// the one with the earliest start time as the primary session and the rest as +// subagent sessions. Subagent sessions are spawned by the primary agent during +// the session and have a later start time. +func (d *Detector) FindAllSessions(repoRoot string) (string, *agent.SessionData, []SubAgentSession, error) { + sessionDir, err := d.FindSessionDir(repoRoot) + if err != nil { + return "", nil, nil, err + } + + entries, err := os.ReadDir(sessionDir) + if err != nil { + return "", nil, nil, fmt.Errorf("reading session directory: %w", err) + } + + var jsonlPaths []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".jsonl") { + jsonlPaths = append(jsonlPaths, filepath.Join(sessionDir, e.Name())) + } + } + + if len(jsonlPaths) == 0 { + return "", nil, nil, fmt.Errorf("no JSONL session files found in %s", sessionDir) + } + + type sessionCandidate struct { + path string + data *agent.SessionData + startTime time.Time + } + + var candidates []sessionCandidate + for _, p := range jsonlPaths { + data, err := ParseJSONL(p) + if err != nil { + continue + } + var startTime time.Time + for _, msg := range data.Transcript { + if !msg.Timestamp.IsZero() { + startTime = msg.Timestamp + break + } + } + // Fall back to file modification time if no message timestamps available. + if startTime.IsZero() { + if info, err := os.Stat(p); err == nil { + startTime = info.ModTime() + } + } + candidates = append(candidates, sessionCandidate{path: p, data: data, startTime: startTime}) + } + + if len(candidates) == 0 { + return "", nil, nil, fmt.Errorf("no parseable JSONL session files found in %s", sessionDir) + } + + // Sort ascending by start time: the earliest session is the primary. + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].startTime.IsZero() { + return false + } + if candidates[j].startTime.IsZero() { + return true + } + return candidates[i].startTime.Before(candidates[j].startTime) + }) + + primary := candidates[0] + var subAgents []SubAgentSession + for _, c := range candidates[1:] { + subAgents = append(subAgents, SubAgentSession{Path: c.path, Data: c.data}) + } + + return primary.path, primary.data, subAgents, nil +} diff --git a/internal/agent/claude/find_all_sessions_test.go b/internal/agent/claude/find_all_sessions_test.go new file mode 100644 index 0000000..7f994a9 --- /dev/null +++ b/internal/agent/claude/find_all_sessions_test.go @@ -0,0 +1,139 @@ +package claude + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindAllSessions_SingleSession(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + repoRoot := "/tmp/myrepo" + sessionDir := filepath.Join(home, ".claude", "projects", sanitizePath(repoRoot)) + if err := os.MkdirAll(sessionDir, 0o755); err != nil { + t.Fatalf("creating session dir: %v", err) + } + + content := `{"type":"human","role":"human","message":"hello","timestamp":1700000000,"sessionId":"primary-001"} +{"type":"assistant","role":"assistant","message":"hi","timestamp":1700000010} +` + path := filepath.Join(sessionDir, "session1.jsonl") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("writing session: %v", err) + } + + d := New() + primaryPath, primaryData, subAgents, err := d.FindAllSessions(repoRoot) + if err != nil { + t.Fatalf("FindAllSessions error: %v", err) + } + + if primaryPath != path { + t.Errorf("expected primary path %s, got %s", path, primaryPath) + } + if primaryData == nil { + t.Fatal("expected primary data, got nil") + } + if primaryData.SessionID != "primary-001" { + t.Errorf("expected session ID primary-001, got %s", primaryData.SessionID) + } + if len(subAgents) != 0 { + t.Errorf("expected 0 subagent sessions, got %d", len(subAgents)) + } +} + +func TestFindAllSessions_WithSubAgent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + repoRoot := "/tmp/myrepo" + sessionDir := filepath.Join(home, ".claude", "projects", sanitizePath(repoRoot)) + if err := os.MkdirAll(sessionDir, 0o755); err != nil { + t.Fatalf("creating session dir: %v", err) + } + + // Primary session: earlier timestamps + primaryContent := `{"type":"human","role":"human","message":"run task","timestamp":1700000000,"sessionId":"primary-001"} +{"type":"assistant","role":"assistant","message":"spawning subagent","timestamp":1700000010} +` + // Subagent session: later timestamps + subAgentContent := `{"type":"human","role":"human","message":"subtask","timestamp":1700001000,"sessionId":"subagent-002"} +{"type":"assistant","role":"assistant","message":"done","timestamp":1700001010} +` + + primaryPath := filepath.Join(sessionDir, "primary.jsonl") + subPath := filepath.Join(sessionDir, "subagent.jsonl") + if err := os.WriteFile(primaryPath, []byte(primaryContent), 0o644); err != nil { + t.Fatalf("writing primary session: %v", err) + } + if err := os.WriteFile(subPath, []byte(subAgentContent), 0o644); err != nil { + t.Fatalf("writing subagent session: %v", err) + } + + d := New() + gotPrimaryPath, primaryData, subAgents, err := d.FindAllSessions(repoRoot) + if err != nil { + t.Fatalf("FindAllSessions error: %v", err) + } + + if gotPrimaryPath != primaryPath { + t.Errorf("expected primary path %s, got %s", primaryPath, gotPrimaryPath) + } + if primaryData == nil || primaryData.SessionID != "primary-001" { + t.Errorf("expected primary session ID primary-001, got %v", primaryData) + } + + if len(subAgents) != 1 { + t.Fatalf("expected 1 subagent session, got %d", len(subAgents)) + } + if subAgents[0].Data == nil || subAgents[0].Data.SessionID != "subagent-002" { + t.Errorf("expected subagent session ID subagent-002, got %v", subAgents[0].Data) + } + if subAgents[0].Path != subPath { + t.Errorf("expected subagent path %s, got %s", subPath, subAgents[0].Path) + } +} + +func TestFindAllSessions_NoDuplicateAttribution(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + repoRoot := "/tmp/myrepo" + sessionDir := filepath.Join(home, ".claude", "projects", sanitizePath(repoRoot)) + if err := os.MkdirAll(sessionDir, 0o755); err != nil { + t.Fatalf("creating session dir: %v", err) + } + + // Same session ID in both files — dedup logic in postcommit prevents double attribution. + // Here we just verify FindAllSessions returns distinct paths correctly. + primaryContent := `{"type":"human","role":"human","message":"task","timestamp":1700000000,"sessionId":"sess-abc"} +` + subContent := `{"type":"human","role":"human","message":"subtask","timestamp":1700002000,"sessionId":"sess-xyz"} +` + + if err := os.WriteFile(filepath.Join(sessionDir, "a.jsonl"), []byte(primaryContent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessionDir, "b.jsonl"), []byte(subContent), 0o644); err != nil { + t.Fatal(err) + } + + d := New() + _, primaryData, subAgents, err := d.FindAllSessions(repoRoot) + if err != nil { + t.Fatalf("FindAllSessions error: %v", err) + } + + // Primary and subagent should have different session IDs. + if primaryData == nil { + t.Fatal("expected primary data") + } + if len(subAgents) != 1 { + t.Fatalf("expected 1 subagent, got %d", len(subAgents)) + } + if primaryData.SessionID == subAgents[0].Data.SessionID { + t.Error("primary and subagent should have distinct session IDs") + } +} diff --git a/internal/checkpoint/checkpoint.go b/internal/checkpoint/checkpoint.go index ba024b7..e2ce6e7 100644 --- a/internal/checkpoint/checkpoint.go +++ b/internal/checkpoint/checkpoint.go @@ -6,30 +6,38 @@ import ( "time" ) +// SubAgentAttribution identifies a subagent session that contributed to a checkpoint. +type SubAgentAttribution struct { + SessionID string `json:"session_id"` + Agent string `json:"agent"` +} + // Checkpoint represents a captured point-in-time snapshot. type Checkpoint struct { - ID string `json:"id"` - SessionID string `json:"session_id"` - CommitHash string `json:"commit_hash"` - Branch string `json:"branch"` - CreatedAt time.Time `json:"created_at"` - Agent string `json:"agent"` - AgentPct int `json:"agent_percent"` - ContentHash string `json:"content_hash"` - PlanSlug string `json:"plan_slug,omitempty"` + ID string `json:"id"` + SessionID string `json:"session_id"` + CommitHash string `json:"commit_hash"` + Branch string `json:"branch"` + CreatedAt time.Time `json:"created_at"` + Agent string `json:"agent"` + AgentPct int `json:"agent_percent"` + ContentHash string `json:"content_hash"` + PlanSlug string `json:"plan_slug,omitempty"` + SubAgentSessions []SubAgentAttribution `json:"sub_agent_sessions,omitempty"` } // Metadata is the JSON schema for checkpoint metadata stored on the orphan branch. type Metadata struct { - ID string `json:"id"` - SessionID string `json:"session_id"` - CommitHash string `json:"commit_hash"` - Branch string `json:"branch"` - CreatedAt string `json:"created_at"` - Agent string `json:"agent"` - AgentPercent int `json:"agent_percent"` - ContentHash string `json:"content_hash"` - PlanSlug string `json:"plan_slug,omitempty"` + ID string `json:"id"` + SessionID string `json:"session_id"` + CommitHash string `json:"commit_hash"` + Branch string `json:"branch"` + CreatedAt string `json:"created_at"` + Agent string `json:"agent"` + AgentPercent int `json:"agent_percent"` + ContentHash string `json:"content_hash"` + PlanSlug string `json:"plan_slug,omitempty"` + SubAgentSessions []SubAgentAttribution `json:"sub_agent_sessions,omitempty"` } // NewID generates a 12-character hex checkpoint ID. diff --git a/internal/checkpoint/to_metadata.go b/internal/checkpoint/to_metadata.go index 428f692..2447e24 100644 --- a/internal/checkpoint/to_metadata.go +++ b/internal/checkpoint/to_metadata.go @@ -5,14 +5,15 @@ import "time" // ToMetadata converts a Checkpoint to its storage Metadata format. func (c *Checkpoint) ToMetadata() Metadata { return Metadata{ - ID: c.ID, - SessionID: c.SessionID, - CommitHash: c.CommitHash, - Branch: c.Branch, - CreatedAt: c.CreatedAt.Format(time.RFC3339), - Agent: c.Agent, - AgentPercent: c.AgentPct, - ContentHash: c.ContentHash, - PlanSlug: c.PlanSlug, + ID: c.ID, + SessionID: c.SessionID, + CommitHash: c.CommitHash, + Branch: c.Branch, + CreatedAt: c.CreatedAt.Format(time.RFC3339), + Agent: c.Agent, + AgentPercent: c.AgentPct, + ContentHash: c.ContentHash, + PlanSlug: c.PlanSlug, + SubAgentSessions: c.SubAgentSessions, } } diff --git a/internal/hooks/postcommit.go b/internal/hooks/postcommit.go index 0cd65b1..58d2cb1 100644 --- a/internal/hooks/postcommit.go +++ b/internal/hooks/postcommit.go @@ -8,6 +8,7 @@ import ( "path/filepath" "time" + "github.com/partio-io/cli/internal/agent" "github.com/partio-io/cli/internal/agent/claude" "github.com/partio-io/cli/internal/attribution" "github.com/partio-io/cli/internal/checkpoint" @@ -55,11 +56,39 @@ func runPostCommit(repoRoot string, cfg config.Config) error { attr = &attribution.Result{AgentPercent: 100} } - // Parse agent session data - detector := claude.New() - sessionPath, sessionData, err := detector.FindLatestSession(repoRoot) - if err != nil { - slog.Warn("could not read agent session", "error", err) + // Parse agent session data using paths saved at pre-commit time. + var sessionPath string + var sessionData *agent.SessionData + if state.SessionPath != "" { + sessionPath = state.SessionPath + if d, err := claude.ParseJSONL(sessionPath); err == nil { + sessionData = d + } else { + slog.Warn("could not read agent session", "error", err) + } + } + + // Build subagent attribution from paths saved at pre-commit time. + // Files modified by subagents are already captured in the commit diff; + // we tag their sessions distinctly to avoid duplicate attribution. + var subAgentSessions []checkpoint.SubAgentAttribution + seen := make(map[string]bool) + if sessionData != nil && sessionData.SessionID != "" { + seen[sessionData.SessionID] = true + } + for _, subPath := range state.SubAgentPaths { + subData, err := claude.ParseJSONL(subPath) + if err != nil || subData == nil || subData.SessionID == "" { + continue + } + if seen[subData.SessionID] { + continue + } + seen[subData.SessionID] = true + subAgentSessions = append(subAgentSessions, checkpoint.SubAgentAttribution{ + SessionID: subData.SessionID, + Agent: subData.Agent, + }) } // Generate checkpoint ID and amend commit with trailers BEFORE writing @@ -83,13 +112,14 @@ func runPostCommit(repoRoot string, cfg config.Config) error { // Create checkpoint with the post-amend hash cp := &checkpoint.Checkpoint{ - ID: cpID, - CommitHash: commitHash, - Branch: state.Branch, - CreatedAt: time.Now(), - Agent: cfg.Agent, - AgentPct: attr.AgentPercent, - ContentHash: commitHash, + ID: cpID, + CommitHash: commitHash, + Branch: state.Branch, + CreatedAt: time.Now(), + Agent: cfg.Agent, + AgentPct: attr.AgentPercent, + ContentHash: commitHash, + SubAgentSessions: subAgentSessions, } if sessionData != nil { diff --git a/internal/hooks/precommit.go b/internal/hooks/precommit.go index 6752572..50d0be4 100644 --- a/internal/hooks/precommit.go +++ b/internal/hooks/precommit.go @@ -13,10 +13,11 @@ import ( // preCommitState records the state captured during pre-commit for use by post-commit. type preCommitState struct { - AgentActive bool `json:"agent_active"` - SessionPath string `json:"session_path,omitempty"` - PreCommitHash string `json:"pre_commit_hash,omitempty"` - Branch string `json:"branch"` + AgentActive bool `json:"agent_active"` + SessionPath string `json:"session_path,omitempty"` + SubAgentPaths []string `json:"sub_agent_paths,omitempty"` + PreCommitHash string `json:"pre_commit_hash,omitempty"` + Branch string `json:"branch"` } // PreCommit runs pre-commit hook logic. @@ -36,13 +37,17 @@ func runPreCommit(repoRoot string, cfg config.Config) error { } var sessionPath string + var subAgentPaths []string if running { - path, _, err := detector.FindLatestSession(repoRoot) + primaryPath, _, subAgents, err := detector.FindAllSessions(repoRoot) if err != nil { slog.Debug("agent running but no session found", "error", err) } else { - sessionPath = path - slog.Debug("agent session detected", "path", path) + sessionPath = primaryPath + slog.Debug("agent session detected", "path", primaryPath, "subagents", len(subAgents)) + for _, sa := range subAgents { + subAgentPaths = append(subAgentPaths, sa.Path) + } } } @@ -52,6 +57,7 @@ func runPreCommit(repoRoot string, cfg config.Config) error { state := preCommitState{ AgentActive: running && sessionPath != "", SessionPath: sessionPath, + SubAgentPaths: subAgentPaths, PreCommitHash: commitHash, Branch: branch, }