diff --git a/.github/workflows/doc-minion.yml b/.github/workflows/doc-minion.yml index 5f95472..bb5238a 100644 --- a/.github/workflows/doc-minion.yml +++ b/.github/workflows/doc-minion.yml @@ -28,7 +28,7 @@ jobs: - name: Install minions run: | - go install github.com/partio-io/minions/cmd/minions@v0.0.4 + go install github.com/partio-io/minions/cmd/minions@v0.0.5 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run doc-update program diff --git a/.github/workflows/minion.yml b/.github/workflows/minion.yml index 3a38513..1eb52dd 100644 --- a/.github/workflows/minion.yml +++ b/.github/workflows/minion.yml @@ -55,7 +55,7 @@ jobs: - name: Install minions run: | - go install github.com/partio-io/minions/cmd/minions@v0.0.4 + go install github.com/partio-io/minions/cmd/minions@v0.0.5 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run program diff --git a/.github/workflows/plan.yml b/.github/workflows/plan.yml index cecda79..39059e7 100644 --- a/.github/workflows/plan.yml +++ b/.github/workflows/plan.yml @@ -28,7 +28,7 @@ jobs: - name: Install minions run: | - go install github.com/partio-io/minions/cmd/minions@v0.0.4 + go install github.com/partio-io/minions/cmd/minions@v0.0.5 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Determine program diff --git a/.github/workflows/propose.yml b/.github/workflows/propose.yml index 61beb5a..a5b8d3c 100644 --- a/.github/workflows/propose.yml +++ b/.github/workflows/propose.yml @@ -26,7 +26,7 @@ jobs: - name: Install minions run: | - go install github.com/partio-io/minions/cmd/minions@v0.0.4 + go install github.com/partio-io/minions/cmd/minions@v0.0.5 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run propose program diff --git a/.partio/sessions/current.json b/.partio/sessions/current.json new file mode 100644 index 0000000..f5d9a06 --- /dev/null +++ b/.partio/sessions/current.json @@ -0,0 +1,12 @@ +{ + "id": "", + "agent": "", + "state": "ended", + "started_at": "0001-01-01T00:00:00Z", + "ended_at": "0001-01-01T00:00:00Z", + "branch": "", + "source_dir": "", + "condensed": true, + "captured_session_id": "a6d5c8a7-82cf-49b4-a82c-68eaebcb48de", + "captured_at": "2026-04-08T15:13:32.860983+02:00" +} \ No newline at end of file diff --git a/.partio/state/processed-commits.json b/.partio/state/processed-commits.json new file mode 100644 index 0000000..c6eebbe --- /dev/null +++ b/.partio/state/processed-commits.json @@ -0,0 +1 @@ +{"entries":[{"sha":"36a85a0e1f92ca901f077d32f257e1d1d0cc5146","processed_at":"2026-04-08T08:29:37.483964+02:00"},{"sha":"cafa3bd3e6976a5310f780ac911808b43ad9afed","processed_at":"2026-04-08T15:02:27.488141+02:00"},{"sha":"886aac3131bbf7e21019a88ddf6a23a39ccf9ac1","processed_at":"2026-04-08T15:11:06.860169+02:00"},{"sha":"e3230b0047aa6ffbd0cac732c8e8f8ff466954aa","processed_at":"2026-04-08T15:12:10.216817+02:00"},{"sha":"5f829e10ea78b5684c2d4420650871e474b37f9b","processed_at":"2026-04-08T15:12:46.73285+02:00"},{"sha":"5eb914f97138b8ad462ca11b91a3d7093d689045","processed_at":"2026-04-08T15:13:32.861081+02:00"}]} \ No newline at end of file diff --git a/README.md b/README.md index 709d0e8..fd3a6e7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Capture the *why* behind your code changes. -**partio** hooks into Git workflows to capture AI agent sessions (Claude Code), preserving the reasoning behind code changes alongside the *what* that Git already tracks. +**partio** hooks into Git workflows to capture AI agent sessions (currently Claude Code and Codex), preserving the reasoning behind code changes alongside the *what* that Git already tracks. The "partial" version of [entire.io](https://entire.io). @@ -33,7 +33,10 @@ git commit -m "add new feature" partio rewind --list # Inspect a checkpoint -git show partio/checkpoints/v1://0/full.jsonl +# For checkpoint ID abcdef123456: +# = ab +# = cdef123456 +git show partio/checkpoints/v1://0/full.jsonl # Rewind to a checkpoint partio rewind --to @@ -59,7 +62,7 @@ partio status ## How It Works 1. `partio enable` installs git hooks (`pre-commit`, `post-commit`, `pre-push`) -2. When you commit, hooks detect if Claude Code is running +2. When you commit, hooks detect if the configured AI agent is running 3. If active, it captures the JSONL transcript, calculates attribution, and creates a checkpoint 4. Checkpoints are stored on an orphan branch (`partio/checkpoints/v1`) using git plumbing 5. Commits are annotated with `Partio-Checkpoint` and `Partio-Attribution` trailers @@ -67,23 +70,31 @@ partio status ## Git Worktrees -partio fully supports git worktrees. Hooks are installed to the shared git directory (`git rev-parse --git-common-dir`) so they work across all worktrees. Session discovery walks up from the repo root to find the Claude Code session directory, which may be keyed to a parent workspace directory. +partio fully supports git worktrees. Hooks are installed to the shared git directory (`git rev-parse --git-common-dir`) so they work across all worktrees. Claude Code session discovery walks up from the repo root to find the session directory, which may be keyed to a parent workspace directory. ## Checkpoint Data Checkpoints are stored on the `partio/checkpoints/v1` orphan branch with this structure: ``` -// +// metadata.json # Checkpoint metadata (commit, branch, agent %, timestamps) 0/ metadata.json # Session metadata (agent, tokens, duration) context.md # First 200 chars of the initial prompt prompt.txt # Full initial human message full.jsonl # Complete Claude Code transcript + diff.patch # Commit diff for the checkpointed commit + plan.md # Captured Claude plan, when available content_hash.txt # Commit hash reference ``` +Checkpoint IDs are 12 hex characters. The storage path is sharded by the first two characters of the ID: + +```text +abcdef123456 -> ab/cdef123456 +``` + You can inspect checkpoint data directly with git: ```bash @@ -91,16 +102,16 @@ You can inspect checkpoint data directly with git: git ls-tree -r --name-only partio/checkpoints/v1 # View checkpoint metadata -git show partio/checkpoints/v1://metadata.json +git show partio/checkpoints/v1://metadata.json # View the full Claude session -git show partio/checkpoints/v1://0/full.jsonl +git show partio/checkpoints/v1://0/full.jsonl ``` ## Configuration Config files (highest priority wins): -- Environment variables (`PARTIO_ENABLED`, `PARTIO_STRATEGY`, `PARTIO_LOG_LEVEL`) +- Environment variables (`PARTIO_ENABLED`, `PARTIO_STRATEGY`, `PARTIO_LOG_LEVEL`, `PARTIO_AGENT`) - `.partio/settings.local.json` (git-ignored) - `.partio/settings.json` - `~/.config/partio/settings.json` @@ -115,6 +126,10 @@ Config files (highest priority wins): } ``` +Supported `agent` values: +- `claude-code` (default) +- `codex` + ## License MIT diff --git a/internal/agent/claude/register.go b/internal/agent/claude/register.go new file mode 100644 index 0000000..db4b700 --- /dev/null +++ b/internal/agent/claude/register.go @@ -0,0 +1,7 @@ +package claude + +import "github.com/partio-io/cli/internal/agent" + +func init() { + agent.Register("claude-code", func() agent.Detector { return New() }) +} diff --git a/internal/agent/codex/codex.go b/internal/agent/codex/codex.go new file mode 100644 index 0000000..ffa9611 --- /dev/null +++ b/internal/agent/codex/codex.go @@ -0,0 +1,14 @@ +package codex + +// Detector implements the agent.Detector interface for OpenAI Codex CLI. +type Detector struct{} + +// New creates a new Codex CLI detector. +func New() *Detector { + return &Detector{} +} + +// Name returns the agent name. +func (d *Detector) Name() string { + return "codex" +} diff --git a/internal/agent/codex/find_session_dir.go b/internal/agent/codex/find_session_dir.go new file mode 100644 index 0000000..a41fb83 --- /dev/null +++ b/internal/agent/codex/find_session_dir.go @@ -0,0 +1,85 @@ +package codex + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/partio-io/cli/internal/agent" +) + +// FindSessionDir returns the Codex CLI session directory. +func (d *Detector) FindSessionDir(repoRoot string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("getting home directory: %w", err) + } + + sessionDir := filepath.Join(home, ".codex", "sessions") + if _, err := os.Stat(sessionDir); err != nil { + return "", fmt.Errorf("no Codex session directory found: %w", err) + } + + return sessionDir, nil +} + +// FindLatestSession returns the most recent Codex session file for the given repo. +// It matches sessions by comparing the session_meta cwd against the repo root. +func (d *Detector) FindLatestSession(repoRoot string) (string, *agent.SessionData, error) { + sessionDir, err := d.FindSessionDir(repoRoot) + if err != nil { + return "", nil, err + } + + // Resolve symlinks for comparison + absRepoRoot, err := filepath.EvalSymlinks(repoRoot) + if err != nil { + absRepoRoot = repoRoot + } + absRepoRoot, _ = filepath.Abs(absRepoRoot) + + // Collect all .jsonl files + var files []string + err = filepath.Walk(sessionDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // skip errors + } + if !info.IsDir() && strings.HasSuffix(path, ".jsonl") { + files = append(files, path) + } + return nil + }) + if err != nil { + return "", nil, fmt.Errorf("walking session directory: %w", err) + } + + // Sort descending by name (filenames contain timestamps) + sort.Sort(sort.Reverse(sort.StringSlice(files))) + + // Find the most recent session that matches this repo + for _, path := range files { + cwd := PeekCWD(path) + if cwd == "" { + continue + } + + absCWD, err := filepath.EvalSymlinks(cwd) + if err != nil { + absCWD = cwd + } + absCWD, _ = filepath.Abs(absCWD) + + // Check if the session cwd matches or is a parent of the repo root + if absCWD == absRepoRoot || strings.HasPrefix(absRepoRoot, absCWD+"/") { + data, parseErr := ParseJSONL(path) + if parseErr != nil { + continue + } + return path, data, nil + } + } + + return "", nil, fmt.Errorf("no Codex session found for repo %s", repoRoot) +} diff --git a/internal/agent/codex/parse_jsonl.go b/internal/agent/codex/parse_jsonl.go new file mode 100644 index 0000000..02d0682 --- /dev/null +++ b/internal/agent/codex/parse_jsonl.go @@ -0,0 +1,181 @@ +package codex + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/partio-io/cli/internal/agent" +) + +// jsonlLine is the top-level structure of a Codex JSONL line. +type jsonlLine struct { + Timestamp string `json:"timestamp"` + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` +} + +type sessionMeta struct { + ID string `json:"id"` + CWD string `json:"cwd"` + CLIVersion string `json:"cli_version"` +} + +type eventMsg struct { + Type string `json:"type"` + Message string `json:"message,omitempty"` +} + +type responseItem struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` + Content []struct { + Text string `json:"text"` + } `json:"content,omitempty"` +} + +// ParseJSONL parses a Codex JSONL session file into SessionData. +func ParseJSONL(path string) (*agent.SessionData, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("opening codex session: %w", err) + } + defer f.Close() + + data := &agent.SessionData{ + Agent: "codex", + } + + var firstTS, lastTS time.Time + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) + + for scanner.Scan() { + var line jsonlLine + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + continue + } + + // Track timestamps for duration + if ts, err := time.Parse(time.RFC3339Nano, line.Timestamp); err == nil { + if firstTS.IsZero() { + firstTS = ts + } + lastTS = ts + } + + switch line.Type { + case "session_meta": + var meta sessionMeta + if err := json.Unmarshal(line.Payload, &meta); err == nil { + data.SessionID = meta.ID + } + + case "event_msg": + var evt eventMsg + if err := json.Unmarshal(line.Payload, &evt); err != nil { + continue + } + + switch evt.Type { + case "user_message": + if data.Prompt == "" { + data.Prompt = evt.Message + } + data.Transcript = append(data.Transcript, agent.Message{ + Role: "user", + Content: evt.Message, + Timestamp: lastTS, + }) + + case "agent_message": + data.Transcript = append(data.Transcript, agent.Message{ + Role: "assistant", + Content: evt.Message, + Timestamp: lastTS, + }) + + case "token_count": + // Token counts come as a separate event; extract if needed + var tc struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } + if err := json.Unmarshal(line.Payload, &tc); err == nil { + data.TotalTokens += tc.InputTokens + tc.OutputTokens + } + } + + case "response_item": + var item responseItem + if err := json.Unmarshal(line.Payload, &item); err != nil { + continue + } + + if item.Type == "message" { + var text string + for _, c := range item.Content { + text += c.Text + } + if text != "" { + data.Transcript = append(data.Transcript, agent.Message{ + Role: "assistant", + Content: text, + Timestamp: lastTS, + }) + } + } + } + } + + if !firstTS.IsZero() && !lastTS.IsZero() { + data.Duration = lastTS.Sub(firstTS) + } + + return data, scanner.Err() +} + +// PeekSessionID reads just the session ID from a Codex JSONL file. +func PeekSessionID(path string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + defer f.Close() + + scanner := bufio.NewScanner(f) + if scanner.Scan() { + var line jsonlLine + if err := json.Unmarshal(scanner.Bytes(), &line); err == nil && line.Type == "session_meta" { + var meta sessionMeta + if err := json.Unmarshal(line.Payload, &meta); err == nil { + return meta.ID + } + } + } + return "" +} + +// PeekCWD reads just the cwd from a Codex JSONL file's session_meta. +func PeekCWD(path string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + defer f.Close() + + scanner := bufio.NewScanner(f) + if scanner.Scan() { + var line jsonlLine + if err := json.Unmarshal(scanner.Bytes(), &line); err == nil && line.Type == "session_meta" { + var meta sessionMeta + if err := json.Unmarshal(line.Payload, &meta); err == nil { + return meta.CWD + } + } + } + return "" +} diff --git a/internal/agent/codex/process.go b/internal/agent/codex/process.go new file mode 100644 index 0000000..17abd7d --- /dev/null +++ b/internal/agent/codex/process.go @@ -0,0 +1,19 @@ +package codex + +import ( + "os/exec" + "strings" +) + +// IsRunning checks if a Codex CLI process is currently running. +func (d *Detector) IsRunning() (bool, error) { + out, err := exec.Command("pgrep", "-f", "codex").Output() + if err != nil { + // pgrep returns exit code 1 if no processes found + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return false, nil + } + return false, err + } + return strings.TrimSpace(string(out)) != "", nil +} diff --git a/internal/agent/codex/process_test.go b/internal/agent/codex/process_test.go new file mode 100644 index 0000000..b465de0 --- /dev/null +++ b/internal/agent/codex/process_test.go @@ -0,0 +1,32 @@ +package codex + +import "testing" + +func TestDetector_Name(t *testing.T) { + d := New() + if d.Name() != "codex" { + t.Errorf("expected name=codex, got %s", d.Name()) + } +} + +func TestDetector_IsRunning(t *testing.T) { + d := New() + // We can't control whether codex is actually running, + // but we can verify the function returns without unexpected errors. + _, err := d.IsRunning() + if err != nil { + t.Errorf("IsRunning() returned unexpected error: %v", err) + } +} + +func TestDetector_FindSessionDir(t *testing.T) { + t.Run("returns error when no session directory exists", func(t *testing.T) { + // Use a fake HOME so ~/.codex/sessions/ doesn't exist + t.Setenv("HOME", t.TempDir()) + d := New() + _, err := d.FindSessionDir(t.TempDir()) + if err == nil { + t.Error("FindSessionDir() expected error, got nil") + } + }) +} diff --git a/internal/agent/codex/register.go b/internal/agent/codex/register.go new file mode 100644 index 0000000..f09f95a --- /dev/null +++ b/internal/agent/codex/register.go @@ -0,0 +1,7 @@ +package codex + +import "github.com/partio-io/cli/internal/agent" + +func init() { + agent.Register("codex", func() agent.Detector { return New() }) +} diff --git a/internal/agent/detector.go b/internal/agent/detector.go index e61a3ab..57b2233 100644 --- a/internal/agent/detector.go +++ b/internal/agent/detector.go @@ -11,3 +11,11 @@ type Detector interface { // FindSessionDir returns the path to the agent's session data for the given repo. FindSessionDir(repoRoot string) (string, error) } + +// SessionParser is implemented by detectors that can extract session data. +// The hooks use this to capture session transcripts from any agent. +type SessionParser interface { + // FindLatestSession returns the path to the most recent session file + // and the parsed session data for the given repo. + FindLatestSession(repoRoot string) (path string, data *SessionData, err error) +} diff --git a/internal/agent/registry.go b/internal/agent/registry.go new file mode 100644 index 0000000..0ad652f --- /dev/null +++ b/internal/agent/registry.go @@ -0,0 +1,41 @@ +package agent + +import ( + "fmt" +) + +// NewDetectorFunc is a factory function that creates a Detector. +type NewDetectorFunc func() Detector + +// registry maps agent names to their factory functions. +var registry = map[string]NewDetectorFunc{} + +// Register adds a detector factory to the registry. +func Register(name string, fn NewDetectorFunc) { + registry[name] = fn +} + +// NewDetector returns a Detector for the given agent name. +// Returns an error if no detector is registered for that name. +func NewDetector(name string) (Detector, error) { + fn, ok := registry[name] + if !ok { + return nil, fmt.Errorf("unknown agent: %s", name) + } + return fn(), nil +} + +// DetectActive checks all registered detectors and returns those that are +// currently running. This allows capturing sessions from any active agent +// without requiring PARTIO_AGENT to be set. +func DetectActive() []Detector { + var active []Detector + for _, fn := range registry { + d := fn() + running, err := d.IsRunning() + if err == nil && running { + active = append(active, d) + } + } + return active +} diff --git a/internal/agent/registry_test.go b/internal/agent/registry_test.go new file mode 100644 index 0000000..dae04c8 --- /dev/null +++ b/internal/agent/registry_test.go @@ -0,0 +1,51 @@ +package agent + +import "testing" + +func TestNewDetector(t *testing.T) { + // Register a test detector. + Register("test-agent", func() Detector { + return &stubDetector{name: "test-agent"} + }) + + tests := []struct { + name string + agent string + want string + wantErr bool + }{ + { + name: "registered agent", + agent: "test-agent", + want: "test-agent", + }, + { + name: "unknown agent", + agent: "unknown", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d, err := NewDetector(tt.agent) + if (err != nil) != tt.wantErr { + t.Errorf("NewDetector(%q) error = %v, wantErr %v", tt.agent, err, tt.wantErr) + return + } + if err == nil && d.Name() != tt.want { + t.Errorf("NewDetector(%q).Name() = %q, want %q", tt.agent, d.Name(), tt.want) + } + }) + } +} + +type stubDetector struct { + name string +} + +func (s *stubDetector) Name() string { return s.name } +func (s *stubDetector) IsRunning() (bool, error) { return false, nil } +func (s *stubDetector) FindSessionDir(repoRoot string) (string, error) { + return "", nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e7165e3..fc33f49 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -15,8 +15,8 @@ func TestDefaults(t *testing.T) { if d.Strategy != "manual-commit" { t.Errorf("expected strategy=manual-commit, got %s", d.Strategy) } - if d.Agent != "claude-code" { - t.Errorf("expected agent=claude-code, got %s", d.Agent) + if d.Agent != "" { + t.Errorf("expected agent='' (auto-detect), got %s", d.Agent) } if d.LogLevel != "info" { t.Errorf("expected log_level=info, got %s", d.LogLevel) @@ -56,8 +56,8 @@ func TestMergeFromFile(t *testing.T) { t.Errorf("expected log_level=debug, got %s", cfg.LogLevel) } // Unset fields should retain defaults - if cfg.Agent != "claude-code" { - t.Errorf("expected agent=claude-code (default), got %s", cfg.Agent) + if cfg.Agent != "" { + t.Errorf("expected agent='' (auto-detect default), got %s", cfg.Agent) } } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index f06dde6..ffd3762 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -5,7 +5,7 @@ func Defaults() Config { return Config{ Enabled: true, Strategy: "manual-commit", - Agent: "claude-code", + Agent: "", LogLevel: "info", StrategyOptions: StrategyOptions{ PushSessions: true, diff --git a/internal/config/env.go b/internal/config/env.go index 090b142..56b588a 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -15,4 +15,7 @@ func applyEnv(cfg *Config) { if v := os.Getenv("PARTIO_LOG_LEVEL"); v != "" { cfg.LogLevel = v } + if v := os.Getenv("PARTIO_AGENT"); v != "" { + cfg.Agent = v + } } diff --git a/internal/git/diff_name_only.go b/internal/git/diff_name_only.go new file mode 100644 index 0000000..2c1602f --- /dev/null +++ b/internal/git/diff_name_only.go @@ -0,0 +1,15 @@ +package git + +import "strings" + +// DiffNameOnly returns the list of file paths changed in a specific commit. +func DiffNameOnly(commitHash string) ([]string, error) { + out, err := execGit("diff", "--name-only", commitHash+"~1", commitHash) + if err != nil { + return nil, err + } + if out == "" { + return nil, nil + } + return strings.Split(out, "\n"), nil +} diff --git a/internal/hooks/postcommit.go b/internal/hooks/postcommit.go index a98a36f..ad6af22 100644 --- a/internal/hooks/postcommit.go +++ b/internal/hooks/postcommit.go @@ -1,6 +1,7 @@ package hooks import ( + "context" "encoding/json" "fmt" "log/slog" @@ -8,7 +9,9 @@ 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/agent/codex" "github.com/partio-io/cli/internal/attribution" "github.com/partio-io/cli/internal/checkpoint" "github.com/partio-io/cli/internal/config" @@ -27,7 +30,7 @@ func runPostCommit(repoRoot string, cfg config.Config) error { stateFile := filepath.Join(repoRoot, config.PartioDir, "state", "pre-commit.json") data, err := os.ReadFile(stateFile) if err != nil { - slog.Debug("no pre-commit state found, skipping checkpoint") + slog.Warn("post-commit: no checkpoint created", "reason", "no pre-commit state found", "state_file", stateFile) return nil } // Remove immediately to prevent re-entry (amend triggers post-commit again) @@ -39,7 +42,7 @@ func runPostCommit(repoRoot string, cfg config.Config) error { } if !state.AgentActive { - slog.Debug("no agent was active, skipping checkpoint") + slog.Warn("post-commit: no checkpoint created", "reason", "no agent was active during pre-commit") return nil } @@ -54,7 +57,7 @@ func runPostCommit(repoRoot string, cfg config.Config) error { partioDir := filepath.Join(repoRoot, config.PartioDir) cache := loadCommitCache(partioDir) if cache.contains(commitHash) { - slog.Debug("post-commit: commit already processed, skipping", "commit", commitHash) + slog.Warn("post-commit: no checkpoint created", "reason", "commit already processed", "commit", commitHash) return nil } @@ -65,18 +68,41 @@ 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 SessionParser interface (any agent). + var sessionPath string + var sessionData *agent.SessionData + agentName := cfg.Agent + if state.AgentName != "" { + agentName = state.AgentName + } + detector, detErr := agent.NewDetector(agentName) + if detErr != nil { + slog.Warn("unknown agent, falling back to claude-code", "agent", agentName, "error", detErr) + detector = claude.New() + } + if sp, ok := detector.(agent.SessionParser); ok { + sessionPath, sessionData, err = sp.FindLatestSession(repoRoot) + if err != nil { + slog.Warn("post-commit: could not read agent session", "agent", agentName, "commit", commitHash, "error", err) + } + } + + // Log staged file paths and session content paths for diagnosing path mismatches. + if slog.Default().Enabled(context.Background(), slog.LevelDebug) { + commitFiles, _ := git.DiffNameOnly(commitHash) + slog.Debug("post-commit: file overlap check", + "commit", commitHash, + "staged_files", commitFiles, + "session_path", sessionPath, + "session_found", sessionData != nil, + ) } // Skip if this session is already fully condensed and ended — re-processing // it produces a redundant checkpoint with no new content. if sessionData != nil && sessionData.SessionID != "" { if shouldSkipSession(filepath.Join(repoRoot, config.PartioDir), sessionData.SessionID, sessionPath) { - slog.Debug("post-commit: skipping already-condensed ended session", "session_id", sessionData.SessionID) + slog.Warn("post-commit: no checkpoint created", "reason", "session already condensed", "commit", commitHash, "session_id", sessionData.SessionID) return nil } } @@ -91,7 +117,7 @@ func runPostCommit(repoRoot string, cfg config.Config) error { } if err := git.AmendTrailers(trailers); err != nil { - slog.Warn("could not add trailers to commit", "error", err) + slog.Warn("post-commit: could not add trailers to commit", "commit", commitHash, "error", err) } // Get the post-amend commit hash (this is the hash that gets pushed) @@ -106,7 +132,7 @@ func runPostCommit(repoRoot string, cfg config.Config) error { CommitHash: commitHash, Branch: state.Branch, CreatedAt: time.Now(), - Agent: cfg.Agent, + Agent: agentName, AgentPct: attr.AgentPercent, ContentHash: commitHash, } diff --git a/internal/hooks/precommit.go b/internal/hooks/precommit.go index 06c2ec2..28dd9ca 100644 --- a/internal/hooks/precommit.go +++ b/internal/hooks/precommit.go @@ -6,7 +6,9 @@ import ( "os" "path/filepath" + "github.com/partio-io/cli/internal/agent" "github.com/partio-io/cli/internal/agent/claude" + _ "github.com/partio-io/cli/internal/agent/codex" "github.com/partio-io/cli/internal/config" "github.com/partio-io/cli/internal/git" "github.com/partio-io/cli/internal/session" @@ -15,6 +17,7 @@ import ( // preCommitState records the state captured during pre-commit for use by post-commit. type preCommitState struct { AgentActive bool `json:"agent_active"` + AgentName string `json:"agent_name,omitempty"` SessionPath string `json:"session_path,omitempty"` PreCommitHash string `json:"pre_commit_hash,omitempty"` Branch string `json:"branch"` @@ -27,45 +30,72 @@ func (r *Runner) PreCommit() error { } func runPreCommit(repoRoot string, cfg config.Config) error { - detector := claude.New() + // Auto-detect running agents — check all registered detectors + var detector agent.Detector + var running bool + + active := agent.DetectActive() + if len(active) > 0 { + detector = active[0] + running = true + slog.Debug("auto-detected agent", "agent", detector.Name()) + } - // Detect if agent is running - running, err := detector.IsRunning() - if err != nil { - slog.Warn("could not detect agent process", "error", err) - running = false + // Fall back to configured agent if none auto-detected + if !running && cfg.Agent != "" { + var err error + detector, err = agent.NewDetector(cfg.Agent) + if err != nil { + slog.Warn("unknown agent", "agent", cfg.Agent, "error", err) + detector = claude.New() + } + } + + if detector == nil { + detector = claude.New() } + // Check for condensed sessions (Claude-specific optimisation). if running { - // Quick check: find the latest JSONL path without a full parse and see if - // we have already captured this session in a fully-condensed ended state. - // This avoids the expensive JSONL parse for stale sessions. - latestPath, pathErr := detector.FindLatestJSONLPath(repoRoot) - if pathErr == nil { - sid := claude.PeekSessionID(latestPath) - if shouldSkipSession(filepath.Join(repoRoot, config.PartioDir), sid, latestPath) { - slog.Debug("skipping already-condensed ended session", "session_id", sid) - running = false + if cd, ok := detector.(*claude.Detector); ok { + latestPath, pathErr := cd.FindLatestJSONLPath(repoRoot) + if pathErr == nil { + sid := claude.PeekSessionID(latestPath) + if shouldSkipSession(filepath.Join(repoRoot, config.PartioDir), sid, latestPath) { + slog.Debug("skipping already-condensed ended session", "session_id", sid) + running = false + } } } } + // Find session data using the SessionParser interface (works for any agent). var sessionPath string if running { - path, _, err := detector.FindLatestSession(repoRoot) - if err != nil { - slog.Debug("agent running but no session found", "error", err) - } else { - sessionPath = path - slog.Debug("agent session detected", "path", path) + if sp, ok := detector.(agent.SessionParser); ok { + path, _, findErr := sp.FindLatestSession(repoRoot) + if findErr != nil { + slog.Debug("agent running but no session found", "agent", detector.Name(), "error", findErr) + } else { + sessionPath = path + slog.Debug("agent session detected", "agent", detector.Name(), "path", path) + } } } branch, _ := git.CurrentBranch() commitHash, _ := git.CurrentCommit() + // If the agent supports session parsing, require a session path. + // Otherwise (no SessionParser), running is sufficient. + agentActive := running + if _, ok := detector.(agent.SessionParser); ok { + agentActive = running && sessionPath != "" + } + state := preCommitState{ - AgentActive: running && sessionPath != "", + AgentActive: agentActive, + AgentName: detector.Name(), SessionPath: sessionPath, PreCommitHash: commitHash, Branch: branch, diff --git a/internal/hooks/precommit_test.go b/internal/hooks/precommit_test.go index 1726756..446c731 100644 --- a/internal/hooks/precommit_test.go +++ b/internal/hooks/precommit_test.go @@ -90,8 +90,9 @@ func TestShouldSkipSession(t *testing.T) { tt.setup(t, partioDir, jsonlPath) if tt.modifyJSONL { - // Ensure modification time is strictly after CapturedAt by waiting a moment - time.Sleep(5 * time.Millisecond) + // Ensure modification time is strictly after CapturedAt by waiting a moment. + // Filesystem timestamp granularity can be coarse (e.g. 1s on some systems). + time.Sleep(50 * time.Millisecond) if err := os.WriteFile(jsonlPath, []byte(`{"sessionId":"sess-123"}`+"\n"+"new line\n"), 0o644); err != nil { t.Fatalf("modifying JSONL: %v", err) }