Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/doc-minion.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/minion.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/plan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/propose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions internal/agent/claude/register.go
Original file line number Diff line number Diff line change
@@ -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() })
}
14 changes: 14 additions & 0 deletions internal/agent/codex/codex.go
Original file line number Diff line number Diff line change
@@ -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"
}
23 changes: 23 additions & 0 deletions internal/agent/codex/find_session_dir.go
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 19 additions & 0 deletions internal/agent/codex/process.go
Original file line number Diff line number Diff line change
@@ -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
}
54 changes: 54 additions & 0 deletions internal/agent/codex/process_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
7 changes: 7 additions & 0 deletions internal/agent/codex/register.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package codex

import "github.com/partio-io/cli/internal/agent"

func init() {
agent.Register("codex", func() agent.Detector { return New() })
}
26 changes: 26 additions & 0 deletions internal/agent/registry.go
Original file line number Diff line number Diff line change
@@ -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
}
51 changes: 51 additions & 0 deletions internal/agent/registry_test.go
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions internal/config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
15 changes: 15 additions & 0 deletions internal/git/diff_name_only.go
Original file line number Diff line number Diff line change
@@ -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
}
42 changes: 32 additions & 10 deletions internal/hooks/postcommit.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package hooks

import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"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"
Expand All @@ -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)
Expand All @@ -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
}

Expand All @@ -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
}

Expand All @@ -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
}
}
Expand All @@ -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)
Expand Down
Loading