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
18 changes: 18 additions & 0 deletions cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,21 @@ type WriteCommittedOptions struct {
// TurnID correlates checkpoints from the same agent turn.
TurnID string

// Kind tags the session purpose (e.g., "agent_review", "agent_investigate").
Kind string

// ReviewSkills is the snapshot of configured review skills at session start.
ReviewSkills []string

// ReviewPrompt is the actual text of the review request.
ReviewPrompt string

// InvestigateRunID is the 12-hex-char ID of the parent investigation run.
InvestigateRunID string

// InvestigateTopic is the human-readable topic for the investigation run.
InvestigateTopic string

// Transcript position at checkpoint start - tracks what was added during this checkpoint
TranscriptIdentifierAtStart string // Last identifier when checkpoint started (UUID for Claude, message ID for Gemini)
CheckpointTranscriptStart int // Transcript line offset at start of this checkpoint's data
Expand Down Expand Up @@ -470,6 +485,9 @@ type CommittedMetadata struct {
// run. Only set when Kind is an investigate kind.
InvestigateRunID string `json:"investigate_run_id,omitempty"`

// InvestigateTopic is the human-readable topic for the investigation run.
InvestigateTopic string `json:"investigate_topic,omitempty"`

// Task checkpoint fields (only populated for task checkpoints)
IsTask bool `json:"is_task,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Expand Down
7 changes: 7 additions & 0 deletions cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,11 @@ func (s *GitStore) writeSessionToSubdirectory(ctx context.Context, opts WriteCom
Agent: opts.Agent,
Model: opts.Model,
TurnID: opts.TurnID,
Kind: opts.Kind,
ReviewSkills: opts.ReviewSkills,
ReviewPrompt: opts.ReviewPrompt,
InvestigateRunID: opts.InvestigateRunID,
InvestigateTopic: opts.InvestigateTopic,
IsTask: opts.IsTask,
ToolUseID: opts.ToolUseID,
TranscriptIdentifierAtStart: opts.TranscriptIdentifierAtStart,
Expand Down Expand Up @@ -474,6 +479,8 @@ func (s *GitStore) writeCheckpointSummary(opts WriteCommittedOptions, basePath s
Sessions: sessions,
TokenUsage: tokenUsage,
CombinedAttribution: combinedAttribution,
HasReview: opts.Kind == "agent_review",
HasInvestigation: opts.Kind == "agent_investigate",
}

metadataJSON, err := jsonutil.MarshalIndentWithNewline(summary, "", " ")
Expand Down
5 changes: 5 additions & 0 deletions cli/checkpoint/v2_committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,11 @@ func (s *V2GitStore) writeMainSessionToSubdirectory(opts WriteCommittedOptions,
Agent: opts.Agent,
Model: opts.Model,
TurnID: opts.TurnID,
Kind: opts.Kind,
ReviewSkills: opts.ReviewSkills,
ReviewPrompt: opts.ReviewPrompt,
InvestigateRunID: opts.InvestigateRunID,
InvestigateTopic: opts.InvestigateTopic,
IsTask: opts.IsTask,
ToolUseID: opts.ToolUseID,
TranscriptIdentifierAtStart: opts.TranscriptIdentifierAtStart,
Expand Down
24 changes: 24 additions & 0 deletions cli/integration_test/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,24 @@ func (r *HookRunner) SimulateUserPromptSubmitWithPrompt(sessionID, prompt string
return r.runHookWithInput("user-prompt-submit", input)
}

func (r *HookRunner) SimulateUserPromptSubmitWithReviewEnvVars(sessionID, prompt string, extraEnv []string) error {
r.T.Helper()
input := map[string]string{
"session_id": sessionID,
"transcript_path": "",
"prompt": prompt,
}
inputJSON, err := json.Marshal(input)
if err != nil {
return fmt.Errorf("failed to marshal hook input: %w", err)
}
out := r.runAgentHookWithOutput("claude-code", "user-prompt-submit", inputJSON, extraEnv...)
if out.Err != nil {
return fmt.Errorf("hook user-prompt-submit failed: %w\nInput: %s\nOutput: %s%s", out.Err, inputJSON, out.Stdout, out.Stderr)
}
return nil
}

// SimulateUserPromptSubmitWithTranscriptPath simulates the UserPromptSubmit hook
// with an explicit transcript path. This is needed for mid-session commit detection
// which reads the live transcript to detect ongoing sessions.
Expand Down Expand Up @@ -320,6 +338,12 @@ func (env *TestEnv) SimulateUserPromptSubmitWithPrompt(sessionID, prompt string)
return runner.SimulateUserPromptSubmitWithPrompt(sessionID, prompt)
}

func (env *TestEnv) SimulateUserPromptSubmitWithReviewEnvVars(sessionID, prompt string, extraEnv []string) error {
env.T.Helper()
runner := NewHookRunner(env.RepoDir, env.ClaudeProjectDir, env.T)
return runner.SimulateUserPromptSubmitWithReviewEnvVars(sessionID, prompt, extraEnv)
}

// SimulateUserPromptSubmitWithTranscriptPath is a convenience method on TestEnv.
// This is needed for mid-session commit detection which reads the live transcript.
func (env *TestEnv) SimulateUserPromptSubmitWithTranscriptPath(sessionID, transcriptPath string) error {
Expand Down
1 change: 1 addition & 0 deletions cli/integration_test/resume_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,7 @@ func (env *TestEnv) GitCheckoutBranch(branchName string) {

err = worktree.Checkout(&git.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName(branchName),
Force: true,
})
if err != nil {
env.T.Fatalf("failed to checkout branch %s: %v", branchName, err)
Expand Down
22 changes: 22 additions & 0 deletions cli/integration_test/testenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,28 @@ func (env *TestEnv) initTraceInternal(strategyOptions map[string]any) {
}
}

func (env *TestEnv) WriteSettings(settings map[string]any) {
env.T.Helper()
traceDir := filepath.Join(env.RepoDir, paths.TraceDir)
if err := os.MkdirAll(traceDir, 0o755); err != nil {
env.T.Fatalf("failed to create .trace directory: %v", err)
}
data, err := jsonutil.MarshalIndentWithNewline(settings, "", " ")
if err != nil {
env.T.Fatalf("failed to marshal settings: %v", err)
}
if err := os.WriteFile(filepath.Join(traceDir, paths.SettingsFileName), data, 0o644); err != nil {
env.T.Fatalf("failed to write %s: %v", paths.SettingsFileName, err)
}
}

func composeReviewPromptForTest(skills []string) string {
if len(skills) == 0 {
return "Review the current branch changes and report actionable findings."
}
return strings.Join(skills, "\n") + "\n\nReview the current branch changes and report actionable findings."
}

// WriteFile creates a file with the given content in the test repo.
// It creates parent directories as needed.
func (env *TestEnv) WriteFile(path, content string) {
Expand Down
14 changes: 14 additions & 0 deletions cli/session/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ const (
// distinct Kind values AND added to Kind.IsReview so the checkpoint's
// HasReview umbrella flag keeps covering them.
KindAgentReview Kind = "agent_review"

// KindAgentInvestigate tags a session created by `trace investigate`.
KindAgentInvestigate Kind = "agent_investigate"
)

// IsReview reports whether this Kind counts as "a review happened" for the
Expand All @@ -72,6 +75,11 @@ func (k Kind) IsReview() bool {
return k == KindAgentReview
}

// IsInvestigation reports whether this Kind counts as an investigation session.
func (k Kind) IsInvestigation() bool {
return k == KindAgentInvestigate
}

// State represents the state of an active session.
// This is stored in .git/trace-sessions/<session-id>.json
type State struct {
Expand Down Expand Up @@ -125,6 +133,12 @@ type State struct {
// prompt (attach path). Always populated when Kind is a review kind.
ReviewPrompt string `json:"review_prompt,omitempty"`

// InvestigateRunID is the 12-hex-char ID of the parent investigation run.
InvestigateRunID string `json:"investigate_run_id,omitempty"`

// InvestigateTopic is the human-readable topic for the investigation run.
InvestigateTopic string `json:"investigate_topic,omitempty"`

// TurnID is a unique identifier for the current agent turn.
// Lifecycle:
// - Generated fresh in InitializeSession at each turn start
Expand Down
5 changes: 5 additions & 0 deletions cli/strategy/manual_commit_condensation.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,11 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re
Agent: state.AgentType,
Model: state.ModelName,
TurnID: state.TurnID,
Kind: string(state.Kind),
ReviewSkills: state.ReviewSkills,
ReviewPrompt: state.ReviewPrompt,
InvestigateRunID: state.InvestigateRunID,
InvestigateTopic: state.InvestigateTopic,
TranscriptIdentifierAtStart: state.TranscriptIdentifierAtStart,
CheckpointTranscriptStart: state.CheckpointTranscriptStart,
TokenUsage: sessionData.TokenUsage,
Expand Down
28 changes: 28 additions & 0 deletions cli/strategy/manual_commit_hooks_3.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
"github.com/GrayCodeAI/trace/cli/interactive"
"github.com/GrayCodeAI/trace/cli/logging"
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/provenance"
"github.com/GrayCodeAI/trace/cli/review"
"github.com/GrayCodeAI/trace/cli/session"
"github.com/GrayCodeAI/trace/cli/settings"
"github.com/GrayCodeAI/trace/cli/trailers"
Expand Down Expand Up @@ -636,6 +638,28 @@ func addCheckpointTrailerWithComment(message string, checkpointID id.CheckpointI
return userContent + "\n\n" + trailer + "\n" + comment + "\n\n" + gitComments
}

func applyProvenanceEnvToState(state *SessionState) {
if os.Getenv(provenance.ReviewSession) != "" {
state.Kind = session.KindAgentReview
if skills, err := review.DecodeSkills(os.Getenv(provenance.ReviewSkills)); err == nil {
state.ReviewSkills = skills
}
if prompt := strings.TrimSpace(os.Getenv(provenance.ReviewPrompt)); prompt != "" {
state.ReviewPrompt = prompt
}
}

if os.Getenv(provenance.InvestigateSession) != "" {
state.Kind = session.KindAgentInvestigate
if runID := strings.TrimSpace(os.Getenv(provenance.InvestigateRunID)); provenance.IsValidRunID(runID) {
state.InvestigateRunID = runID
}
if topic := strings.TrimSpace(os.Getenv(provenance.InvestigateTopic)); topic != "" {
state.InvestigateTopic = topic
}
}
}

// InitializeSession creates session state for a new session or updates an existing one.
// This implements the optional SessionInitializer interface.
// Called during UserPromptSubmit to allow git hooks to detect active sessions.
Expand Down Expand Up @@ -698,6 +722,8 @@ func (s *ManualCommitStrategy) InitializeSession(ctx context.Context, sessionID
state.ModelName = model
}

applyProvenanceEnvToState(state)

// Update LastPrompt on every turn so condensation always has the current prompt
if userPrompt != "" {
state.LastPrompt = truncatePromptForStorage(userPrompt)
Expand Down Expand Up @@ -765,6 +791,8 @@ func (s *ManualCommitStrategy) InitializeSession(ctx context.Context, sessionID
return fmt.Errorf("failed to initialize session: %w", err)
}

applyProvenanceEnvToState(state)

// Apply phase transition: new session starts as ACTIVE.
if transErr := TransitionAndLog(ctx, state, session.EventTurnStart, session.TransitionContext{}, session.NoOpActionHandler{}); transErr != nil {
logging.Warn(logging.WithComponent(ctx, "hooks"), "turn start transition failed",
Expand Down
15 changes: 15 additions & 0 deletions cmd/trace/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package main

import (
"fmt"
"os"

"github.com/GrayCodeAI/trace/cli"
)

func main() {
if err := cli.NewRootCmd().Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}