diff --git a/cli/checkpoint/checkpoint.go b/cli/checkpoint/checkpoint.go index 4a34994..5c92680 100644 --- a/cli/checkpoint/checkpoint.go +++ b/cli/checkpoint/checkpoint.go @@ -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 @@ -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"` diff --git a/cli/checkpoint/committed.go b/cli/checkpoint/committed.go index c24997a..da473e1 100644 --- a/cli/checkpoint/committed.go +++ b/cli/checkpoint/committed.go @@ -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, @@ -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, "", " ") diff --git a/cli/checkpoint/v2_committed.go b/cli/checkpoint/v2_committed.go index 470831a..b1bb9b3 100644 --- a/cli/checkpoint/v2_committed.go +++ b/cli/checkpoint/v2_committed.go @@ -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, diff --git a/cli/integration_test/hooks.go b/cli/integration_test/hooks.go index 0a51c25..79aecce 100644 --- a/cli/integration_test/hooks.go +++ b/cli/integration_test/hooks.go @@ -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. @@ -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 { diff --git a/cli/integration_test/resume_test.go b/cli/integration_test/resume_test.go index 9ed8624..2609376 100644 --- a/cli/integration_test/resume_test.go +++ b/cli/integration_test/resume_test.go @@ -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) diff --git a/cli/integration_test/testenv.go b/cli/integration_test/testenv.go index 9659012..8e07647 100644 --- a/cli/integration_test/testenv.go +++ b/cli/integration_test/testenv.go @@ -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) { diff --git a/cli/session/state.go b/cli/session/state.go index a1a5551..66bf6dc 100644 --- a/cli/session/state.go +++ b/cli/session/state.go @@ -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 @@ -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/.json type State struct { @@ -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 diff --git a/cli/strategy/manual_commit_condensation.go b/cli/strategy/manual_commit_condensation.go index 14e9b10..30dc252 100644 --- a/cli/strategy/manual_commit_condensation.go +++ b/cli/strategy/manual_commit_condensation.go @@ -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, diff --git a/cli/strategy/manual_commit_hooks_3.go b/cli/strategy/manual_commit_hooks_3.go index 6f3f445..cc76702 100644 --- a/cli/strategy/manual_commit_hooks_3.go +++ b/cli/strategy/manual_commit_hooks_3.go @@ -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" @@ -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. @@ -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) @@ -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", diff --git a/cmd/trace/main.go b/cmd/trace/main.go new file mode 100644 index 0000000..13d889d --- /dev/null +++ b/cmd/trace/main.go @@ -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) + } +}