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/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..3a0ae40 --- /dev/null +++ b/internal/agent/codex/find_session_dir.go @@ -0,0 +1,23 @@ +package codex + +import ( + "fmt" + "os" + "path/filepath" +) + +// FindSessionDir returns the Codex CLI session directory for the given repo. +// Codex stores sessions at ~/.codex/sessions/ within the project 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 +} 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..5b66dc6 --- /dev/null +++ b/internal/agent/codex/process_test.go @@ -0,0 +1,54 @@ +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) { + tests := []struct { + name string + }{ + {name: "returns without error"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := New() + // We can't control whether codex is actually running in CI, + // 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) { + tests := []struct { + name string + repoRoot string + wantErr bool + }{ + { + name: "returns error when no session directory exists", + repoRoot: t.TempDir(), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := New() + _, err := d.FindSessionDir(tt.repoRoot) + if (err != nil) != tt.wantErr { + t.Errorf("FindSessionDir() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} 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/registry.go b/internal/agent/registry.go new file mode 100644 index 0000000..bf79b0c --- /dev/null +++ b/internal/agent/registry.go @@ -0,0 +1,26 @@ +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 +} 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/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..b86d402 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,37 @@ 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 (Claude-specific — other agents skip this) + var sessionPath string + var sessionData *agent.SessionData + detector, detErr := agent.NewDetector(cfg.Agent) + if detErr != nil { + slog.Warn("unknown agent, falling back to claude-code", "agent", cfg.Agent, "error", detErr) + detector = claude.New() + } + if cd, ok := detector.(*claude.Detector); ok { + sessionPath, sessionData, err = cd.FindLatestSession(repoRoot) + if err != nil { + slog.Warn("post-commit: could not read agent session", "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 +113,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) diff --git a/internal/hooks/precommit.go b/internal/hooks/precommit.go index 06c2ec2..2793083 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" @@ -27,45 +29,57 @@ func (r *Runner) PreCommit() error { } func runPreCommit(repoRoot string, cfg config.Config) error { - detector := claude.New() + detector, err := agent.NewDetector(cfg.Agent) + if err != nil { + slog.Warn("unknown agent, falling back to claude-code", "agent", cfg.Agent, "error", err) + detector = claude.New() + } // Detect if agent is running - running, err := detector.IsRunning() - if err != nil { - slog.Warn("could not detect agent process", "error", err) + running, runErr := detector.IsRunning() + if runErr != nil { + slog.Warn("could not detect agent process", "error", runErr) running = false } + // Claude-specific optimisation: check for condensed sessions. 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 + } } } } 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 cd, ok := detector.(*claude.Detector); ok { + path, _, findErr := cd.FindLatestSession(repoRoot) + if findErr != nil { + slog.Debug("agent running but no session found", "error", findErr) + } else { + sessionPath = path + slog.Debug("agent session detected", "path", path) + } } } branch, _ := git.CurrentBranch() commitHash, _ := git.CurrentCommit() + // For Claude, we require a session path; for other agents, running is sufficient. + agentActive := running + if _, ok := detector.(*claude.Detector); ok { + agentActive = running && sessionPath != "" + } + state := preCommitState{ - AgentActive: running && sessionPath != "", + AgentActive: agentActive, 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) }