Skip to content

Commit b48280b

Browse files
authored
Merge pull request #360 from entireio/soph/agent-refactor
Agent refactor
2 parents 401c1c8 + 2abcac1 commit b48280b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+4254
-2379
lines changed

.golangci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ linters:
103103
- stdlib
104104
- grpc.DialOption
105105
- github.com/entireio/cli/cmd/entire/cli/agent.Agent
106+
- github.com/entireio/cli/cmd/entire/cli/strategy.Strategy
106107
- github.com/go-git/go-git/v6/plumbing/storer.ReferenceIter
107108
- github.com/go-git/go-git/v6/plumbing.EncodedObject
108109
- github.com/go-git/go-git/v6/storage.Storer

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,8 +278,8 @@ The CLI uses a strategy pattern for managing session data and checkpoints. Each
278278

279279
#### Core Interface
280280
All strategies implement:
281-
- `SaveChanges()` - Save session checkpoint (code + metadata)
282-
- `SaveTaskCheckpoint()` - Save subagent task checkpoint
281+
- `SaveStep()` - Save session step checkpoint (code + metadata)
282+
- `SaveTaskStep()` - Save subagent task step checkpoint
283283
- `GetRewindPoints()` / `Rewind()` - List and restore to checkpoints
284284
- `GetSessionLog()` / `GetSessionInfo()` - Retrieve session data
285285
- `ListSessions()` / `GetSession()` - Session discovery
@@ -319,7 +319,7 @@ All strategies implement:
319319

320320
#### Key Files
321321

322-
- `strategy.go` - Interface definition and context structs (`SaveContext`, `RewindPoint`, etc.)
322+
- `strategy.go` - Interface definition and context structs (`StepContext`, `TaskStepContext`, `RewindPoint`, etc.)
323323
- `registry.go` - Strategy registration/discovery (factory pattern with `Get()`, `List()`, `Default()`)
324324
- `common.go` - Shared helpers for metadata extraction, tree building, rewind validation, `ListCheckpoints()`
325325
- `session.go` - Session/checkpoint data structures

GEMINI.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,8 @@ The CLI uses a strategy pattern for managing session data and checkpoints. Each
149149

150150
#### Core Interface
151151
All strategies implement:
152-
- `SaveChanges()` - Save session checkpoint (code + metadata)
153-
- `SaveTaskCheckpoint()` - Save subagent task checkpoint
152+
- `SaveStep()` - Save session step checkpoint (code + metadata)
153+
- `SaveTaskStep()` - Save subagent task step checkpoint
154154
- `GetRewindPoints()` / `Rewind()` - List and restore to checkpoints
155155
- `GetSessionLog()` / `GetSessionInfo()` - Retrieve session data
156156
- `ListSessions()` / `GetSession()` - Session discovery
@@ -188,7 +188,7 @@ Legacy names `shadow` and `dual` are only recognized when reading settings or ch
188188

189189
#### Key Files
190190

191-
- `strategy.go` - Interface definition and context structs (`SaveContext`, `RewindPoint`, etc.)
191+
- `strategy.go` - Interface definition and context structs (`StepContext`, `TaskStepContext`, `RewindPoint`, etc.)
192192
- `registry.go` - Strategy registration/discovery (factory pattern with `Get()`, `List()`, `Default()`)
193193
- `common.go` - Shared helpers for metadata extraction, tree building, rewind validation, `ListCheckpoints()`
194194
- `session.go` - Session/checkpoint data structures

PLAN.md

Lines changed: 677 additions & 0 deletions
Large diffs are not rendered by default.

cmd/entire/cli/agent/agent.go

Lines changed: 91 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@ import (
1010
// Agent defines the interface for interacting with a coding agent.
1111
// Each agent implementation (Claude Code, Cursor, Aider, etc.) converts its
1212
// native format to the normalized types defined in this package.
13+
//
14+
// The interface is organized into four groups:
15+
//
16+
// - Identity (5 methods): Name, Type, Description, DetectPresence, ProtectedDirs
17+
// - Event Mapping (2 methods): HookNames, ParseHookEvent
18+
// - Transcript Storage (3 methods): ReadTranscript, ChunkTranscript, ReassembleTranscript
19+
// - Legacy (8 methods): Will be moved to optional interfaces or removed in a future phase
1320
type Agent interface {
21+
// --- Identity ---
22+
1423
// Name returns the agent registry key (e.g., "claude-code", "gemini")
1524
Name() AgentName
1625

@@ -24,46 +33,64 @@ type Agent interface {
2433
// DetectPresence checks if this agent is configured in the repository
2534
DetectPresence() (bool, error)
2635

27-
// GetHookConfigPath returns path to hook config file (empty if none)
36+
// ProtectedDirs returns repo-root-relative directories that should never be
37+
// modified or deleted during rewind or other destructive operations.
38+
// Examples: [".claude"] for Claude, [".gemini"] for Gemini.
39+
ProtectedDirs() []string
40+
41+
// --- Event Mapping ---
42+
43+
// HookNames returns the hook verbs this agent supports.
44+
// These become subcommands under `entire hooks <agent>`.
45+
// e.g., ["stop", "user-prompt-submit", "session-start", "session-end"]
46+
HookNames() []string
47+
48+
// ParseHookEvent translates an agent-native hook into a normalized lifecycle Event.
49+
// Returns nil if the hook has no lifecycle significance (e.g., pass-through hooks).
50+
// This is the core contribution surface for new agent implementations.
51+
ParseHookEvent(hookName string, stdin io.Reader) (*Event, error)
52+
53+
// --- Transcript Storage ---
54+
55+
// ReadTranscript reads the raw transcript bytes for a session.
56+
ReadTranscript(sessionRef string) ([]byte, error)
57+
58+
// ChunkTranscript splits a transcript into chunks if it exceeds maxSize.
59+
// Returns a slice of chunks. If the transcript fits in one chunk, returns single-element slice.
60+
// The chunking is format-aware: JSONL splits at line boundaries, JSON splits message arrays.
61+
ChunkTranscript(content []byte, maxSize int) ([][]byte, error)
62+
63+
// ReassembleTranscript combines chunks back into a single transcript.
64+
// Handles format-specific reassembly (JSONL concatenation, JSON message merging).
65+
ReassembleTranscript(chunks [][]byte) ([]byte, error)
66+
67+
// --- Legacy methods (will move to optional interfaces in Phase 4) ---
68+
69+
// GetHookConfigPath returns path to hook config file (empty if none).
2870
GetHookConfigPath() string
2971

30-
// SupportsHooks returns true if agent supports lifecycle hooks
72+
// SupportsHooks returns true if agent supports lifecycle hooks.
3173
SupportsHooks() bool
3274

33-
// ParseHookInput parses hook callback input from stdin
75+
// ParseHookInput parses hook callback input from stdin.
3476
ParseHookInput(hookType HookType, reader io.Reader) (*HookInput, error)
3577

36-
// GetSessionID extracts session ID from hook input
78+
// GetSessionID extracts session ID from hook input.
3779
GetSessionID(input *HookInput) string
3880

39-
// ProtectedDirs returns repo-root-relative directories that should never be
40-
// modified or deleted during rewind or other destructive operations.
41-
// Examples: [".claude"] for Claude, [".gemini"] for Gemini.
42-
ProtectedDirs() []string
43-
4481
// GetSessionDir returns where agent stores session data for this repo.
45-
// Examples:
46-
// Claude: ~/.claude/projects/<sanitized-repo-path>/
47-
// Aider: current working directory (returns repoPath)
48-
// Cursor: ~/Library/Application Support/Cursor/User/globalStorage/
4982
GetSessionDir(repoPath string) (string, error)
5083

51-
// ResolveSessionFile returns the path to the session transcript file for a given
52-
// agent session ID. Agents use different naming conventions:
53-
// Claude: <sessionDir>/<id>.jsonl
54-
// Gemini: <sessionDir>/session-<date>-<shortid>.json (searches for existing file)
55-
// If no existing file is found, returns a sensible default path.
84+
// ResolveSessionFile returns the path to the session transcript file.
5685
ResolveSessionFile(sessionDir, agentSessionID string) string
5786

5887
// ReadSession reads session data from agent's storage.
59-
// Handles different formats: JSONL (Claude), SQLite (Cursor), Markdown (Aider)
6088
ReadSession(input *HookInput) (*AgentSession, error)
6189

6290
// WriteSession writes session data for resumption.
63-
// Agent handles format conversion (JSONL, SQLite, etc.)
6491
WriteSession(session *AgentSession) error
6592

66-
// FormatResumeCommand returns command to resume a session
93+
// FormatResumeCommand returns command to resume a session.
6794
FormatResumeCommand(sessionID string) string
6895
}
6996

@@ -90,18 +117,12 @@ type HookSupport interface {
90117
}
91118

92119
// HookHandler is implemented by agents that define their own hook vocabulary.
93-
// Each agent defines its own hook names (verbs) which become subcommands
94-
// under `entire hooks <agent>`. The actual handling is done by handlers
95-
// registered in the CLI package to avoid circular dependencies.
96-
//
97-
// This allows different agents to have completely different hook vocabularies
98-
// (e.g., Claude Code has "stop", Cursor might have "completion").
120+
// HookNames() is now part of the core Agent interface.
121+
// This interface is kept for backward compatibility during migration.
99122
type HookHandler interface {
100123
Agent
101124

102125
// GetHookNames returns the hook verbs this agent supports.
103-
// These are the subcommand names that will appear under `entire hooks <agent>`.
104-
// e.g., ["stop", "user-prompt-submit", "pre-task", "post-task", "post-todo"]
105126
GetHookNames() []string
106127
}
107128

@@ -118,16 +139,17 @@ type FileWatcher interface {
118139
OnFileChange(path string) (*SessionChange, error)
119140
}
120141

121-
// TranscriptAnalyzer is implemented by agents that support transcript analysis.
122-
// This allows agent-agnostic detection of work done between checkpoints.
142+
// TranscriptAnalyzer provides format-specific transcript parsing.
143+
// Agents that implement this get richer checkpoints (transcript-derived file lists,
144+
// prompts, summaries). Agents that don't still participate in the checkpoint lifecycle
145+
// via git-status-based file detection and raw transcript storage.
123146
type TranscriptAnalyzer interface {
124147
Agent
125148

126149
// GetTranscriptPosition returns the current position (length) of a transcript.
127150
// For JSONL formats (Claude Code), this is the line count.
128151
// For JSON formats (Gemini CLI), this is the message count.
129152
// Returns 0 if the file doesn't exist or is empty.
130-
// Use this to efficiently check if the transcript has grown since last checkpoint.
131153
GetTranscriptPosition(path string) (int, error)
132154

133155
// ExtractModifiedFilesFromOffset extracts files modified since a given offset.
@@ -138,20 +160,46 @@ type TranscriptAnalyzer interface {
138160
// - currentPosition: the current position (line count or message count)
139161
// - error: any error encountered during reading
140162
ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error)
163+
164+
// ExtractPrompts extracts user prompts from the transcript starting at the given offset.
165+
ExtractPrompts(sessionRef string, fromOffset int) ([]string, error)
166+
167+
// ExtractSummary extracts a summary of the session from the transcript.
168+
ExtractSummary(sessionRef string) (string, error)
141169
}
142170

143-
// TranscriptChunker is implemented by agents that support transcript chunking.
144-
// This allows agents to split large transcripts into chunks for storage (GitHub has
145-
// a 100MB blob limit) and reassemble them when reading.
146-
type TranscriptChunker interface {
171+
// TranscriptPreparer is called before ReadTranscript to handle agent-specific
172+
// flush/sync requirements (e.g., Claude Code's async transcript writing).
173+
// The framework calls PrepareTranscript before ReadTranscript if implemented.
174+
type TranscriptPreparer interface {
147175
Agent
148176

149-
// ChunkTranscript splits a transcript into chunks if it exceeds maxSize.
150-
// Returns a slice of chunks. If the transcript fits in one chunk, returns single-element slice.
151-
// The chunking is format-aware: JSONL splits at line boundaries, JSON splits message arrays.
152-
ChunkTranscript(content []byte, maxSize int) ([][]byte, error)
177+
// PrepareTranscript ensures the transcript is ready to read.
178+
// For Claude Code, this waits for the async transcript flush to complete.
179+
PrepareTranscript(sessionRef string) error
180+
}
153181

154-
// ReassembleTranscript combines chunks back into a single transcript.
155-
// Handles format-specific reassembly (JSONL concatenation, JSON message merging).
156-
ReassembleTranscript(chunks [][]byte) ([]byte, error)
182+
// TokenCalculator provides token usage calculation for a session.
183+
// The framework calls this during step save and checkpoint if implemented.
184+
type TokenCalculator interface {
185+
Agent
186+
187+
// CalculateTokenUsage computes token usage from the transcript starting at the given offset.
188+
CalculateTokenUsage(sessionRef string, fromOffset int) (*TokenUsage, error)
189+
}
190+
191+
// SubagentAwareExtractor provides methods for extracting files and tokens including subagents.
192+
// Agents that support spawning subagents (like Claude Code's Task tool) should implement this
193+
// to ensure subagent contributions are included in checkpoints.
194+
type SubagentAwareExtractor interface {
195+
Agent
196+
197+
// ExtractAllModifiedFiles extracts files modified by both the main agent and any spawned subagents.
198+
// The subagentsDir parameter specifies where subagent transcripts are stored.
199+
// Returns a deduplicated list of all modified file paths.
200+
ExtractAllModifiedFiles(sessionRef string, fromOffset int, subagentsDir string) ([]string, error)
201+
202+
// CalculateTotalTokenUsage computes token usage including all spawned subagents.
203+
// The subagentsDir parameter specifies where subagent transcripts are stored.
204+
CalculateTotalTokenUsage(sessionRef string, fromOffset int, subagentsDir string) (*TokenUsage, error)
157205
}

cmd/entire/cli/agent/agent_test.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,24 @@ func (m *mockAgent) SupportsHooks() bool { return false }
2424
func (m *mockAgent) ParseHookInput(_ HookType, _ io.Reader) (*HookInput, error) {
2525
return nil, nil
2626
}
27-
func (m *mockAgent) GetSessionID(_ *HookInput) string { return "" }
28-
func (m *mockAgent) TransformSessionID(agentID string) string { return agentID }
29-
func (m *mockAgent) ProtectedDirs() []string { return nil }
30-
func (m *mockAgent) GetSessionDir(_ string) (string, error) { return "", nil }
27+
func (m *mockAgent) GetSessionID(_ *HookInput) string { return "" }
28+
func (m *mockAgent) ProtectedDirs() []string { return nil }
29+
func (m *mockAgent) HookNames() []string { return nil }
30+
31+
//nolint:nilnil // Mock implementation
32+
func (m *mockAgent) ParseHookEvent(_ string, _ io.Reader) (*Event, error) { return nil, nil }
33+
func (m *mockAgent) ReadTranscript(_ string) ([]byte, error) { return nil, nil }
34+
func (m *mockAgent) ChunkTranscript(content []byte, _ int) ([][]byte, error) {
35+
return [][]byte{content}, nil
36+
}
37+
func (m *mockAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) {
38+
var result []byte
39+
for _, c := range chunks {
40+
result = append(result, c...)
41+
}
42+
return result, nil
43+
}
44+
func (m *mockAgent) GetSessionDir(_ string) (string, error) { return "", nil }
3145
func (m *mockAgent) ResolveSessionFile(sessionDir, agentSessionID string) string {
3246
return sessionDir + "/" + agentSessionID + ".jsonl"
3347
}

cmd/entire/cli/agent/chunking.go

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,21 @@ const (
1717
)
1818

1919
// ChunkTranscript splits a transcript into chunks using the appropriate agent.
20-
// If agentType is empty or the agent doesn't implement TranscriptChunker,
21-
// falls back to JSONL (line-based) chunking.
20+
// If agentType is empty or the agent is not found, falls back to JSONL (line-based) chunking.
2221
func ChunkTranscript(content []byte, agentType AgentType) ([][]byte, error) {
2322
if len(content) <= MaxChunkSize {
2423
return [][]byte{content}, nil
2524
}
2625

27-
// Try to get the agent by type
26+
// Try to get the agent by type and use its format-aware chunking
2827
if agentType != "" {
2928
ag, err := GetByAgentType(agentType)
3029
if err == nil {
31-
if chunker, ok := ag.(TranscriptChunker); ok {
32-
chunks, chunkErr := chunker.ChunkTranscript(content, MaxChunkSize)
33-
if chunkErr != nil {
34-
return nil, fmt.Errorf("agent chunking failed: %w", chunkErr)
35-
}
36-
return chunks, nil
30+
chunks, chunkErr := ag.ChunkTranscript(content, MaxChunkSize)
31+
if chunkErr != nil {
32+
return nil, fmt.Errorf("agent chunking failed: %w", chunkErr)
3733
}
34+
return chunks, nil
3835
}
3936
}
4037

@@ -43,8 +40,7 @@ func ChunkTranscript(content []byte, agentType AgentType) ([][]byte, error) {
4340
}
4441

4542
// ReassembleTranscript combines chunks back into a single transcript.
46-
// If agentType is empty or the agent doesn't implement TranscriptChunker,
47-
// falls back to JSONL (line-based) reassembly.
43+
// If agentType is empty or the agent is not found, falls back to JSONL (line-based) reassembly.
4844
func ReassembleTranscript(chunks [][]byte, agentType AgentType) ([]byte, error) {
4945
if len(chunks) == 0 {
5046
return nil, nil
@@ -53,17 +49,15 @@ func ReassembleTranscript(chunks [][]byte, agentType AgentType) ([]byte, error)
5349
return chunks[0], nil
5450
}
5551

56-
// Try to get the agent by type
52+
// Try to get the agent by type and use its format-aware reassembly
5753
if agentType != "" {
5854
ag, err := GetByAgentType(agentType)
5955
if err == nil {
60-
if chunker, ok := ag.(TranscriptChunker); ok {
61-
result, reassembleErr := chunker.ReassembleTranscript(chunks)
62-
if reassembleErr != nil {
63-
return nil, fmt.Errorf("agent reassembly failed: %w", reassembleErr)
64-
}
65-
return result, nil
56+
result, reassembleErr := ag.ReassembleTranscript(chunks)
57+
if reassembleErr != nil {
58+
return nil, fmt.Errorf("agent reassembly failed: %w", reassembleErr)
6659
}
60+
return result, nil
6761
}
6862
}
6963

cmd/entire/cli/agent/claudecode/claude.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -362,9 +362,12 @@ func (c *ClaudeCodeAgent) GetTranscriptPosition(path string) (int, error) {
362362
lineCount := 0
363363

364364
for {
365-
_, err := reader.ReadBytes('\n')
365+
line, err := reader.ReadBytes('\n')
366366
if err != nil {
367367
if err == io.EOF {
368+
if len(line) > 0 {
369+
lineCount++ // Count final line without trailing newline
370+
}
368371
break
369372
}
370373
return 0, fmt.Errorf("failed to read transcript: %w", err)
@@ -422,8 +425,6 @@ func (c *ClaudeCodeAgent) ExtractModifiedFilesFromOffset(path string, startOffse
422425
return ExtractModifiedFiles(lines), lineNum, nil
423426
}
424427

425-
// TranscriptChunker interface implementation
426-
427428
// ChunkTranscript splits a JSONL transcript at line boundaries.
428429
// Claude Code uses JSONL format (one JSON object per line), so chunking
429430
// is done at newline boundaries to preserve message integrity.
@@ -437,7 +438,22 @@ func (c *ClaudeCodeAgent) ChunkTranscript(content []byte, maxSize int) ([][]byte
437438

438439
// ReassembleTranscript concatenates JSONL chunks with newlines.
439440
//
440-
//nolint:unparam // error return is required by interface, kept for consistency
441+
441442
func (c *ClaudeCodeAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) {
442443
return agent.ReassembleJSONL(chunks), nil
443444
}
445+
446+
// SubagentAwareExtractor interface implementation
447+
448+
// ExtractAllModifiedFiles extracts files modified by both the main agent and any spawned subagents.
449+
// Claude Code spawns subagents via the Task tool; their transcripts are stored in subagentsDir.
450+
// Returns a deduplicated list of all modified file paths.
451+
func (c *ClaudeCodeAgent) ExtractAllModifiedFiles(sessionRef string, fromOffset int, subagentsDir string) ([]string, error) {
452+
return ExtractAllModifiedFiles(sessionRef, fromOffset, subagentsDir)
453+
}
454+
455+
// CalculateTotalTokenUsage computes token usage including all spawned subagents.
456+
// Claude Code spawns subagents via the Task tool; their transcripts are stored in subagentsDir.
457+
func (c *ClaudeCodeAgent) CalculateTotalTokenUsage(sessionRef string, fromOffset int, subagentsDir string) (*agent.TokenUsage, error) {
458+
return CalculateTotalTokenUsage(sessionRef, fromOffset, subagentsDir)
459+
}

0 commit comments

Comments
 (0)