Skip to content

Commit ab0d17c

Browse files
committed
feat(session): persist review and investigation provenance
1 parent ff3bff1 commit ab0d17c

10 files changed

Lines changed: 139 additions & 0 deletions

File tree

cli/checkpoint/checkpoint.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,21 @@ type WriteCommittedOptions struct {
273273
// TurnID correlates checkpoints from the same agent turn.
274274
TurnID string
275275

276+
// Kind tags the session purpose (e.g., "agent_review", "agent_investigate").
277+
Kind string
278+
279+
// ReviewSkills is the snapshot of configured review skills at session start.
280+
ReviewSkills []string
281+
282+
// ReviewPrompt is the actual text of the review request.
283+
ReviewPrompt string
284+
285+
// InvestigateRunID is the 12-hex-char ID of the parent investigation run.
286+
InvestigateRunID string
287+
288+
// InvestigateTopic is the human-readable topic for the investigation run.
289+
InvestigateTopic string
290+
276291
// Transcript position at checkpoint start - tracks what was added during this checkpoint
277292
TranscriptIdentifierAtStart string // Last identifier when checkpoint started (UUID for Claude, message ID for Gemini)
278293
CheckpointTranscriptStart int // Transcript line offset at start of this checkpoint's data
@@ -470,6 +485,9 @@ type CommittedMetadata struct {
470485
// run. Only set when Kind is an investigate kind.
471486
InvestigateRunID string `json:"investigate_run_id,omitempty"`
472487

488+
// InvestigateTopic is the human-readable topic for the investigation run.
489+
InvestigateTopic string `json:"investigate_topic,omitempty"`
490+
473491
// Task checkpoint fields (only populated for task checkpoints)
474492
IsTask bool `json:"is_task,omitempty"`
475493
ToolUseID string `json:"tool_use_id,omitempty"`

cli/checkpoint/committed.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,11 @@ func (s *GitStore) writeSessionToSubdirectory(ctx context.Context, opts WriteCom
414414
Agent: opts.Agent,
415415
Model: opts.Model,
416416
TurnID: opts.TurnID,
417+
Kind: opts.Kind,
418+
ReviewSkills: opts.ReviewSkills,
419+
ReviewPrompt: opts.ReviewPrompt,
420+
InvestigateRunID: opts.InvestigateRunID,
421+
InvestigateTopic: opts.InvestigateTopic,
417422
IsTask: opts.IsTask,
418423
ToolUseID: opts.ToolUseID,
419424
TranscriptIdentifierAtStart: opts.TranscriptIdentifierAtStart,
@@ -474,6 +479,8 @@ func (s *GitStore) writeCheckpointSummary(opts WriteCommittedOptions, basePath s
474479
Sessions: sessions,
475480
TokenUsage: tokenUsage,
476481
CombinedAttribution: combinedAttribution,
482+
HasReview: opts.Kind == "agent_review",
483+
HasInvestigation: opts.Kind == "agent_investigate",
477484
}
478485

479486
metadataJSON, err := jsonutil.MarshalIndentWithNewline(summary, "", " ")

cli/checkpoint/v2_committed.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,11 @@ func (s *V2GitStore) writeMainSessionToSubdirectory(opts WriteCommittedOptions,
572572
Agent: opts.Agent,
573573
Model: opts.Model,
574574
TurnID: opts.TurnID,
575+
Kind: opts.Kind,
576+
ReviewSkills: opts.ReviewSkills,
577+
ReviewPrompt: opts.ReviewPrompt,
578+
InvestigateRunID: opts.InvestigateRunID,
579+
InvestigateTopic: opts.InvestigateTopic,
575580
IsTask: opts.IsTask,
576581
ToolUseID: opts.ToolUseID,
577582
TranscriptIdentifierAtStart: opts.TranscriptIdentifierAtStart,

cli/integration_test/hooks.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,24 @@ func (r *HookRunner) SimulateUserPromptSubmitWithPrompt(sessionID, prompt string
7373
return r.runHookWithInput("user-prompt-submit", input)
7474
}
7575

76+
func (r *HookRunner) SimulateUserPromptSubmitWithReviewEnvVars(sessionID, prompt string, extraEnv []string) error {
77+
r.T.Helper()
78+
input := map[string]string{
79+
"session_id": sessionID,
80+
"transcript_path": "",
81+
"prompt": prompt,
82+
}
83+
inputJSON, err := json.Marshal(input)
84+
if err != nil {
85+
return fmt.Errorf("failed to marshal hook input: %w", err)
86+
}
87+
out := r.runAgentHookWithOutput("claude-code", "user-prompt-submit", inputJSON, extraEnv...)
88+
if out.Err != nil {
89+
return fmt.Errorf("hook user-prompt-submit failed: %w\nInput: %s\nOutput: %s%s", out.Err, inputJSON, out.Stdout, out.Stderr)
90+
}
91+
return nil
92+
}
93+
7694
// SimulateUserPromptSubmitWithTranscriptPath simulates the UserPromptSubmit hook
7795
// with an explicit transcript path. This is needed for mid-session commit detection
7896
// which reads the live transcript to detect ongoing sessions.
@@ -320,6 +338,12 @@ func (env *TestEnv) SimulateUserPromptSubmitWithPrompt(sessionID, prompt string)
320338
return runner.SimulateUserPromptSubmitWithPrompt(sessionID, prompt)
321339
}
322340

341+
func (env *TestEnv) SimulateUserPromptSubmitWithReviewEnvVars(sessionID, prompt string, extraEnv []string) error {
342+
env.T.Helper()
343+
runner := NewHookRunner(env.RepoDir, env.ClaudeProjectDir, env.T)
344+
return runner.SimulateUserPromptSubmitWithReviewEnvVars(sessionID, prompt, extraEnv)
345+
}
346+
323347
// SimulateUserPromptSubmitWithTranscriptPath is a convenience method on TestEnv.
324348
// This is needed for mid-session commit detection which reads the live transcript.
325349
func (env *TestEnv) SimulateUserPromptSubmitWithTranscriptPath(sessionID, transcriptPath string) error {

cli/integration_test/resume_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,7 @@ func (env *TestEnv) GitCheckoutBranch(branchName string) {
570570

571571
err = worktree.Checkout(&git.CheckoutOptions{
572572
Branch: plumbing.NewBranchReferenceName(branchName),
573+
Force: true,
573574
})
574575
if err != nil {
575576
env.T.Fatalf("failed to checkout branch %s: %v", branchName, err)

cli/integration_test/testenv.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,28 @@ func (env *TestEnv) initTraceInternal(strategyOptions map[string]any) {
380380
}
381381
}
382382

383+
func (env *TestEnv) WriteSettings(settings map[string]any) {
384+
env.T.Helper()
385+
traceDir := filepath.Join(env.RepoDir, paths.TraceDir)
386+
if err := os.MkdirAll(traceDir, 0o755); err != nil {
387+
env.T.Fatalf("failed to create .trace directory: %v", err)
388+
}
389+
data, err := jsonutil.MarshalIndentWithNewline(settings, "", " ")
390+
if err != nil {
391+
env.T.Fatalf("failed to marshal settings: %v", err)
392+
}
393+
if err := os.WriteFile(filepath.Join(traceDir, paths.SettingsFileName), data, 0o644); err != nil {
394+
env.T.Fatalf("failed to write %s: %v", paths.SettingsFileName, err)
395+
}
396+
}
397+
398+
func composeReviewPromptForTest(skills []string) string {
399+
if len(skills) == 0 {
400+
return "Review the current branch changes and report actionable findings."
401+
}
402+
return strings.Join(skills, "\n") + "\n\nReview the current branch changes and report actionable findings."
403+
}
404+
383405
// WriteFile creates a file with the given content in the test repo.
384406
// It creates parent directories as needed.
385407
func (env *TestEnv) WriteFile(path, content string) {

cli/session/state.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ const (
6262
// distinct Kind values AND added to Kind.IsReview so the checkpoint's
6363
// HasReview umbrella flag keeps covering them.
6464
KindAgentReview Kind = "agent_review"
65+
66+
// KindAgentInvestigate tags a session created by `trace investigate`.
67+
KindAgentInvestigate Kind = "agent_investigate"
6568
)
6669

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

78+
// IsInvestigation reports whether this Kind counts as an investigation session.
79+
func (k Kind) IsInvestigation() bool {
80+
return k == KindAgentInvestigate
81+
}
82+
7583
// State represents the state of an active session.
7684
// This is stored in .git/trace-sessions/<session-id>.json
7785
type State struct {
@@ -125,6 +133,12 @@ type State struct {
125133
// prompt (attach path). Always populated when Kind is a review kind.
126134
ReviewPrompt string `json:"review_prompt,omitempty"`
127135

136+
// InvestigateRunID is the 12-hex-char ID of the parent investigation run.
137+
InvestigateRunID string `json:"investigate_run_id,omitempty"`
138+
139+
// InvestigateTopic is the human-readable topic for the investigation run.
140+
InvestigateTopic string `json:"investigate_topic,omitempty"`
141+
128142
// TurnID is a unique identifier for the current agent turn.
129143
// Lifecycle:
130144
// - Generated fresh in InitializeSession at each turn start

cli/strategy/manual_commit_condensation.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,11 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re
227227
Agent: state.AgentType,
228228
Model: state.ModelName,
229229
TurnID: state.TurnID,
230+
Kind: string(state.Kind),
231+
ReviewSkills: state.ReviewSkills,
232+
ReviewPrompt: state.ReviewPrompt,
233+
InvestigateRunID: state.InvestigateRunID,
234+
InvestigateTopic: state.InvestigateTopic,
230235
TranscriptIdentifierAtStart: state.TranscriptIdentifierAtStart,
231236
CheckpointTranscriptStart: state.CheckpointTranscriptStart,
232237
TokenUsage: sessionData.TokenUsage,

cli/strategy/manual_commit_hooks_3.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import (
1515
"github.com/GrayCodeAI/trace/cli/interactive"
1616
"github.com/GrayCodeAI/trace/cli/logging"
1717
"github.com/GrayCodeAI/trace/cli/paths"
18+
"github.com/GrayCodeAI/trace/cli/provenance"
19+
"github.com/GrayCodeAI/trace/cli/review"
1820
"github.com/GrayCodeAI/trace/cli/session"
1921
"github.com/GrayCodeAI/trace/cli/settings"
2022
"github.com/GrayCodeAI/trace/cli/trailers"
@@ -636,6 +638,28 @@ func addCheckpointTrailerWithComment(message string, checkpointID id.CheckpointI
636638
return userContent + "\n\n" + trailer + "\n" + comment + "\n\n" + gitComments
637639
}
638640

641+
func applyProvenanceEnvToState(state *SessionState) {
642+
if os.Getenv(provenance.ReviewSession) != "" {
643+
state.Kind = session.KindAgentReview
644+
if skills, err := review.DecodeSkills(os.Getenv(provenance.ReviewSkills)); err == nil {
645+
state.ReviewSkills = skills
646+
}
647+
if prompt := strings.TrimSpace(os.Getenv(provenance.ReviewPrompt)); prompt != "" {
648+
state.ReviewPrompt = prompt
649+
}
650+
}
651+
652+
if os.Getenv(provenance.InvestigateSession) != "" {
653+
state.Kind = session.KindAgentInvestigate
654+
if runID := strings.TrimSpace(os.Getenv(provenance.InvestigateRunID)); provenance.IsValidRunID(runID) {
655+
state.InvestigateRunID = runID
656+
}
657+
if topic := strings.TrimSpace(os.Getenv(provenance.InvestigateTopic)); topic != "" {
658+
state.InvestigateTopic = topic
659+
}
660+
}
661+
}
662+
639663
// InitializeSession creates session state for a new session or updates an existing one.
640664
// This implements the optional SessionInitializer interface.
641665
// Called during UserPromptSubmit to allow git hooks to detect active sessions.
@@ -698,6 +722,8 @@ func (s *ManualCommitStrategy) InitializeSession(ctx context.Context, sessionID
698722
state.ModelName = model
699723
}
700724

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

794+
applyProvenanceEnvToState(state)
795+
768796
// Apply phase transition: new session starts as ACTIVE.
769797
if transErr := TransitionAndLog(ctx, state, session.EventTurnStart, session.TransitionContext{}, session.NoOpActionHandler{}); transErr != nil {
770798
logging.Warn(logging.WithComponent(ctx, "hooks"), "turn start transition failed",

cmd/trace/main.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/GrayCodeAI/trace/cli"
8+
)
9+
10+
func main() {
11+
if err := cli.NewRootCmd().Execute(); err != nil {
12+
fmt.Fprintln(os.Stderr, err)
13+
os.Exit(1)
14+
}
15+
}

0 commit comments

Comments
 (0)