From 204f7bb35bf2a512607bcec9a99c4160ec187998 Mon Sep 17 00:00:00 2001 From: WangxuMarshall Date: Sat, 7 Mar 2026 13:48:57 +0800 Subject: [PATCH 01/15] feat: add Trae Agent integration Add support for Trae Agent (ByteDance's LLM-based software engineering agent): - Implement Agent interface with full hook support - Add HookTypes for before_agent, after_agent, before_model, after_model, etc. - Support JSON trajectory transcript format - Implement ParseHookEvent for lifecycle event translation - Add hook installation/uninstallation for .trae/settings.json This integration is marked as preview status. --- cmd/entire/cli/agent/registry.go | 2 + cmd/entire/cli/agent/traeagent/hooks.go | 568 ++++++++++++++++++ cmd/entire/cli/agent/traeagent/traeagent.go | 347 +++++++++++ .../cli/agent/traeagent/traeagent_test.go | 64 ++ cmd/entire/cli/agent/traeagent/transcript.go | 345 +++++++++++ cmd/entire/cli/agent/types.go | 21 +- cmd/entire/cli/hooks_cmd.go | 1 + 7 files changed, 1342 insertions(+), 6 deletions(-) create mode 100644 cmd/entire/cli/agent/traeagent/hooks.go create mode 100644 cmd/entire/cli/agent/traeagent/traeagent.go create mode 100644 cmd/entire/cli/agent/traeagent/traeagent_test.go create mode 100644 cmd/entire/cli/agent/traeagent/transcript.go diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 5d518470f..500b74594 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -106,6 +106,7 @@ const ( AgentNameFactoryAIDroid types.AgentName = "factoryai-droid" AgentNameGemini types.AgentName = "gemini" AgentNameOpenCode types.AgentName = "opencode" + AgentNameTraeAgent types.AgentName = "trae-agent" ) // Agent type constants (type identifiers stored in metadata/trailers) @@ -116,6 +117,7 @@ const ( AgentTypeFactoryAIDroid types.AgentType = "Factory AI Droid" AgentTypeGemini types.AgentType = "Gemini CLI" AgentTypeOpenCode types.AgentType = "OpenCode" + AgentTypeTraeAgent types.AgentType = "Trae Agent" AgentTypeUnknown types.AgentType = "Agent" // Fallback for backwards compatibility ) diff --git a/cmd/entire/cli/agent/traeagent/hooks.go b/cmd/entire/cli/agent/traeagent/hooks.go new file mode 100644 index 000000000..1b7075d04 --- /dev/null +++ b/cmd/entire/cli/agent/traeagent/hooks.go @@ -0,0 +1,568 @@ +// Package traeagent implements the Agent interface for Trae Agent. +package traeagent + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Ensure TraeAgent implements HookSupport +var _ agent.HookSupport = (*TraeAgent)(nil) + +// Trae Agent hook names - these become subcommands under `entire hooks trae-agent` +const ( + HookNameSessionStart = "session-start" + HookNameSessionEnd = "session-end" + HookNameBeforeAgent = "before-agent" + HookNameAfterAgent = "after-agent" + HookNameBeforeModel = "before-model" + HookNameAfterModel = "after-model" + HookNameBeforeToolSelection = "before-tool-selection" + HookNamePreTool = "pre-tool" + HookNameAfterTool = "after-tool" + HookNamePreCompress = "pre-compress" + HookNameNotification = "notification" +) + +// TraeSettingsFileName is the settings file used by Trae Agent. +const TraeSettingsFileName = "settings.json" + +// GetHookNames returns the hook verbs Trae Agent supports. +// These become subcommands: entire hooks trae-agent +func (t *TraeAgent) HookNames() []string { + return []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameBeforeAgent, + HookNameAfterAgent, + HookNameBeforeModel, + HookNameAfterModel, + HookNameBeforeToolSelection, + HookNamePreTool, + HookNameAfterTool, + HookNamePreCompress, + HookNameNotification, + } +} + +// entireHookPrefixes are command prefixes that identify Entire hooks (both old and new formats) +var entireHookPrefixes = []string{ + "entire ", + "go run ${TRAE_PROJECT_DIR}/cmd/entire/main.go ", +} + +// InstallHooks installs Trae Agent hooks in .trae/settings.json. +// If force is true, removes existing Entire hooks before installing. +// Returns the number of hooks installed. +func (t *TraeAgent) InstallHooks(ctx context.Context, localDev bool, force bool) (int, error) { + // Use repo root instead of CWD to find .trae directory + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + // Fallback to CWD if not in a git repo (e.g., during tests) + repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when WorktreeRoot() fails + if err != nil { + return 0, fmt.Errorf("failed to get current directory: %w", err) + } + } + + settingsPath := filepath.Join(repoRoot, ".trae", TraeSettingsFileName) + + // Read existing settings if they exist + var rawSettings map[string]json.RawMessage + + // rawHooks preserves unknown hook types + var rawHooks map[string]json.RawMessage + + existingData, readErr := os.ReadFile(settingsPath) //nolint:gosec // path is constructed safely + if readErr == nil { + if err := json.Unmarshal(existingData, &rawSettings); err != nil { + return 0, fmt.Errorf("failed to parse existing settings.json: %w", err) + } + if hooksRaw, ok := rawSettings["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return 0, fmt.Errorf("failed to parse hooks in settings.json: %w", err) + } + } + } else { + rawSettings = make(map[string]json.RawMessage) + } + + if rawHooks == nil { + rawHooks = make(map[string]json.RawMessage) + } + + // Parse only the hook types we need to modify + var sessionStart, sessionEnd, beforeAgent, afterAgent, beforeModel, afterModel []TraeHook + var beforeToolSelection, preTool, afterTool, preCompress, notification []TraeHook + + parseHookType(rawHooks, "SessionStart", &sessionStart) + parseHookType(rawHooks, "SessionEnd", &sessionEnd) + parseHookType(rawHooks, "BeforeAgent", &beforeAgent) + parseHookType(rawHooks, "AfterAgent", &afterAgent) + parseHookType(rawHooks, "BeforeModel", &beforeModel) + parseHookType(rawHooks, "AfterModel", &afterModel) + parseHookType(rawHooks, "BeforeToolSelection", &beforeToolSelection) + parseHookType(rawHooks, "PreTool", &preTool) + parseHookType(rawHooks, "AfterTool", &afterTool) + parseHookType(rawHooks, "PreCompress", &preCompress) + parseHookType(rawHooks, "Notification", ¬ification) + + // If force is true, remove all existing Entire hooks first + if force { + sessionStart = removeEntireHooks(sessionStart) + sessionEnd = removeEntireHooks(sessionEnd) + beforeAgent = removeEntireHooks(beforeAgent) + afterAgent = removeEntireHooks(afterAgent) + beforeModel = removeEntireHooks(beforeModel) + afterModel = removeEntireHooks(afterModel) + beforeToolSelection = removeEntireHooks(beforeToolSelection) + preTool = removeEntireHooks(preTool) + afterTool = removeEntireHooks(afterTool) + preCompress = removeEntireHooks(preCompress) + notification = removeEntireHooks(notification) + } + + // Define hook commands + var sessionStartCmd, sessionEndCmd, beforeAgentCmd, afterAgentCmd string + var beforeModelCmd, afterModelCmd, beforeToolSelectionCmd, preToolCmd string + var afterToolCmd, preCompressCmd, notificationCmd string + + if localDev { + baseCmd := "go run ${TRAE_PROJECT_DIR}/cmd/entire/main.go hooks trae-agent" + sessionStartCmd = fmt.Sprintf("%s %s", baseCmd, HookNameSessionStart) + sessionEndCmd = fmt.Sprintf("%s %s", baseCmd, HookNameSessionEnd) + beforeAgentCmd = fmt.Sprintf("%s %s", baseCmd, HookNameBeforeAgent) + afterAgentCmd = fmt.Sprintf("%s %s", baseCmd, HookNameAfterAgent) + beforeModelCmd = fmt.Sprintf("%s %s", baseCmd, HookNameBeforeModel) + afterModelCmd = fmt.Sprintf("%s %s", baseCmd, HookNameAfterModel) + beforeToolSelectionCmd = fmt.Sprintf("%s %s", baseCmd, HookNameBeforeToolSelection) + preToolCmd = fmt.Sprintf("%s %s", baseCmd, HookNamePreTool) + afterToolCmd = fmt.Sprintf("%s %s", baseCmd, HookNameAfterTool) + preCompressCmd = fmt.Sprintf("%s %s", baseCmd, HookNamePreCompress) + notificationCmd = fmt.Sprintf("%s %s", baseCmd, HookNameNotification) + } else { + baseCmd := "entire hooks trae-agent" + sessionStartCmd = fmt.Sprintf("%s %s", baseCmd, HookNameSessionStart) + sessionEndCmd = fmt.Sprintf("%s %s", baseCmd, HookNameSessionEnd) + beforeAgentCmd = fmt.Sprintf("%s %s", baseCmd, HookNameBeforeAgent) + afterAgentCmd = fmt.Sprintf("%s %s", baseCmd, HookNameAfterAgent) + beforeModelCmd = fmt.Sprintf("%s %s", baseCmd, HookNameBeforeModel) + afterModelCmd = fmt.Sprintf("%s %s", baseCmd, HookNameAfterModel) + beforeToolSelectionCmd = fmt.Sprintf("%s %s", baseCmd, HookNameBeforeToolSelection) + preToolCmd = fmt.Sprintf("%s %s", baseCmd, HookNamePreTool) + afterToolCmd = fmt.Sprintf("%s %s", baseCmd, HookNameAfterTool) + preCompressCmd = fmt.Sprintf("%s %s", baseCmd, HookNamePreCompress) + notificationCmd = fmt.Sprintf("%s %s", baseCmd, HookNameNotification) + } + + count := 0 + + // Add hooks if they don't exist + if !hookCommandExists(sessionStart, sessionStartCmd) { + sessionStart = append(sessionStart, TraeHook{Name: "entire-session-start", Type: "command", Command: sessionStartCmd}) + count++ + } + if !hookCommandExists(sessionEnd, sessionEndCmd) { + sessionEnd = append(sessionEnd, TraeHook{Name: "entire-session-end", Type: "command", Command: sessionEndCmd}) + count++ + } + if !hookCommandExists(beforeAgent, beforeAgentCmd) { + beforeAgent = append(beforeAgent, TraeHook{Name: "entire-before-agent", Type: "command", Command: beforeAgentCmd}) + count++ + } + if !hookCommandExists(afterAgent, afterAgentCmd) { + afterAgent = append(afterAgent, TraeHook{Name: "entire-after-agent", Type: "command", Command: afterAgentCmd}) + count++ + } + if !hookCommandExists(beforeModel, beforeModelCmd) { + beforeModel = append(beforeModel, TraeHook{Name: "entire-before-model", Type: "command", Command: beforeModelCmd}) + count++ + } + if !hookCommandExists(afterModel, afterModelCmd) { + afterModel = append(afterModel, TraeHook{Name: "entire-after-model", Type: "command", Command: afterModelCmd}) + count++ + } + if !hookCommandExists(beforeToolSelection, beforeToolSelectionCmd) { + beforeToolSelection = append(beforeToolSelection, TraeHook{Name: "entire-before-tool-selection", Type: "command", Command: beforeToolSelectionCmd}) + count++ + } + if !hookCommandExists(preTool, preToolCmd) { + preTool = append(preTool, TraeHook{Name: "entire-pre-tool", Type: "command", Command: preToolCmd}) + count++ + } + if !hookCommandExists(afterTool, afterToolCmd) { + afterTool = append(afterTool, TraeHook{Name: "entire-after-tool", Type: "command", Command: afterToolCmd}) + count++ + } + if !hookCommandExists(preCompress, preCompressCmd) { + preCompress = append(preCompress, TraeHook{Name: "entire-pre-compress", Type: "command", Command: preCompressCmd}) + count++ + } + if !hookCommandExists(notification, notificationCmd) { + notification = append(notification, TraeHook{Name: "entire-notification", Type: "command", Command: notificationCmd}) + count++ + } + + if count == 0 { + return 0, nil // All hooks already installed + } + + // Marshal modified hook types back to rawHooks + marshalHookType(rawHooks, "SessionStart", sessionStart) + marshalHookType(rawHooks, "SessionEnd", sessionEnd) + marshalHookType(rawHooks, "BeforeAgent", beforeAgent) + marshalHookType(rawHooks, "AfterAgent", afterAgent) + marshalHookType(rawHooks, "BeforeModel", beforeModel) + marshalHookType(rawHooks, "AfterModel", afterModel) + marshalHookType(rawHooks, "BeforeToolSelection", beforeToolSelection) + marshalHookType(rawHooks, "PreTool", preTool) + marshalHookType(rawHooks, "AfterTool", afterTool) + marshalHookType(rawHooks, "PreCompress", preCompress) + marshalHookType(rawHooks, "Notification", notification) + + // Marshal hooks and update raw settings + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return 0, fmt.Errorf("failed to marshal hooks: %w", err) + } + rawSettings["hooks"] = hooksJSON + + // Set hooksConfig.enabled = true (required for Trae Agent to execute hooks) + hooksConfig := TraeHooksConfig{Enabled: true} + hooksConfigJSON, err := json.Marshal(hooksConfig) + if err != nil { + return 0, fmt.Errorf("failed to marshal hooksConfig: %w", err) + } + rawSettings["hooksConfig"] = hooksConfigJSON + + // Write back to file + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { + return 0, fmt.Errorf("failed to create .trae directory: %w", err) + } + + output, err := jsonutil.MarshalIndentWithNewline(rawSettings, "", " ") + if err != nil { + return 0, fmt.Errorf("failed to marshal settings: %w", err) + } + + if err := os.WriteFile(settingsPath, output, 0o600); err != nil { + return 0, fmt.Errorf("failed to write settings.json: %w", err) + } + + return count, nil +} + +// parseHookType parses a specific hook type from rawHooks into the target slice. +// Silently ignores parse errors (leaves target unchanged). +func parseHookType(rawHooks map[string]json.RawMessage, hookType string, target interface{}) { + if data, ok := rawHooks[hookType]; ok { + //nolint:errcheck,gosec // Intentionally ignoring parse errors + json.Unmarshal(data, target) + } +} + +// marshalHookType marshals a hook type back to rawHooks. +// If the slice is empty, removes the key from rawHooks. +func marshalHookType(rawHooks map[string]json.RawMessage, hookType string, hooks interface{}) { + // Check if hooks is empty + var isEmpty bool + switch h := hooks.(type) { + case []TraeHook: + isEmpty = len(h) == 0 + default: + isEmpty = true + } + + if isEmpty { + delete(rawHooks, hookType) + return + } + + data, err := json.Marshal(hooks) + if err != nil { + return // Silently ignore marshal errors + } + rawHooks[hookType] = data +} + +// UninstallHooks removes Entire hooks from Trae Agent settings. +func (t *TraeAgent) UninstallHooks(ctx context.Context) error { + // Use repo root to find .trae directory when run from a subdirectory + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot = "." // Fallback to CWD if not in a git repo + } + settingsPath := filepath.Join(repoRoot, ".trae", TraeSettingsFileName) + data, err := os.ReadFile(settingsPath) //nolint:gosec // path is constructed safely + if err != nil { + return nil //nolint:nilerr // No settings file means nothing to uninstall + } + + var rawSettings map[string]json.RawMessage + if err := json.Unmarshal(data, &rawSettings); err != nil { + return fmt.Errorf("failed to parse settings.json: %w", err) + } + + // rawHooks preserves unknown hook types + var rawHooks map[string]json.RawMessage + if hooksRaw, ok := rawSettings["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return fmt.Errorf("failed to parse hooks: %w", err) + } + } + if rawHooks == nil { + rawHooks = make(map[string]json.RawMessage) + } + + // Parse all hook types + var sessionStart, sessionEnd, beforeAgent, afterAgent, beforeModel, afterModel []TraeHook + var beforeToolSelection, preTool, afterTool, preCompress, notification []TraeHook + + parseHookType(rawHooks, "SessionStart", &sessionStart) + parseHookType(rawHooks, "SessionEnd", &sessionEnd) + parseHookType(rawHooks, "BeforeAgent", &beforeAgent) + parseHookType(rawHooks, "AfterAgent", &afterAgent) + parseHookType(rawHooks, "BeforeModel", &beforeModel) + parseHookType(rawHooks, "AfterModel", &afterModel) + parseHookType(rawHooks, "BeforeToolSelection", &beforeToolSelection) + parseHookType(rawHooks, "PreTool", &preTool) + parseHookType(rawHooks, "AfterTool", &afterTool) + parseHookType(rawHooks, "PreCompress", &preCompress) + parseHookType(rawHooks, "Notification", ¬ification) + + // Remove Entire hooks from all hook types + sessionStart = removeEntireHooks(sessionStart) + sessionEnd = removeEntireHooks(sessionEnd) + beforeAgent = removeEntireHooks(beforeAgent) + afterAgent = removeEntireHooks(afterAgent) + beforeModel = removeEntireHooks(beforeModel) + afterModel = removeEntireHooks(afterModel) + beforeToolSelection = removeEntireHooks(beforeToolSelection) + preTool = removeEntireHooks(preTool) + afterTool = removeEntireHooks(afterTool) + preCompress = removeEntireHooks(preCompress) + notification = removeEntireHooks(notification) + + // Marshal modified hook types back to rawHooks + marshalHookType(rawHooks, "SessionStart", sessionStart) + marshalHookType(rawHooks, "SessionEnd", sessionEnd) + marshalHookType(rawHooks, "BeforeAgent", beforeAgent) + marshalHookType(rawHooks, "AfterAgent", afterAgent) + marshalHookType(rawHooks, "BeforeModel", beforeModel) + marshalHookType(rawHooks, "AfterModel", afterModel) + marshalHookType(rawHooks, "BeforeToolSelection", beforeToolSelection) + marshalHookType(rawHooks, "PreTool", preTool) + marshalHookType(rawHooks, "AfterTool", afterTool) + marshalHookType(rawHooks, "PreCompress", preCompress) + marshalHookType(rawHooks, "Notification", notification) + + // Marshal hooks back (preserving unknown hook types) + if len(rawHooks) > 0 { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("failed to marshal hooks: %w", err) + } + rawSettings["hooks"] = hooksJSON + } else { + delete(rawSettings, "hooks") + } + + // Write back + output, err := jsonutil.MarshalIndentWithNewline(rawSettings, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal settings: %w", err) + } + if err := os.WriteFile(settingsPath, output, 0o600); err != nil { + return fmt.Errorf("failed to write settings.json: %w", err) + } + return nil +} + +// AreHooksInstalled checks if Entire hooks are installed. +func (t *TraeAgent) AreHooksInstalled(ctx context.Context) bool { + // Use repo root to find .trae directory when run from a subdirectory + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot = "." // Fallback to CWD if not in a git repo + } + settingsPath := filepath.Join(repoRoot, ".trae", TraeSettingsFileName) + data, err := os.ReadFile(settingsPath) //nolint:gosec // path is constructed safely + if err != nil { + return false + } + + var settings TraeSettings + if err := json.Unmarshal(data, &settings); err != nil { + return false + } + + // Check for at least one of our hooks + return hookCommandExists(settings.Hooks.SessionStart, "entire hooks trae-agent session-start") || + hookCommandExists(settings.Hooks.SessionStart, "go run ${TRAE_PROJECT_DIR}/cmd/entire/main.go hooks trae-agent session-start") +} + +// ParseHookEvent translates a Trae Agent hook into a normalized lifecycle Event. +// Returns nil if the hook has no lifecycle significance. +func (t *TraeAgent) ParseHookEvent(_ context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { + switch hookName { + case HookNameSessionStart: + return t.parseSessionStart(stdin) + case HookNameBeforeAgent: + return t.parseBeforeAgent(stdin) + case HookNameAfterAgent: + return t.parseAfterAgent(stdin) + case HookNameSessionEnd: + return t.parseSessionEnd(stdin) + default: + return nil, nil //nolint:nilnil // Unknown hooks have no lifecycle action + } +} + +func (t *TraeAgent) parseSessionStart(stdin io.Reader) (*agent.Event, error) { + input, err := t.ParseHookInput(agent.HookSessionStart, stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SessionStart, + SessionID: input.SessionID, + SessionRef: input.SessionRef, + Timestamp: time.Now(), + }, nil +} + +func (t *TraeAgent) parseBeforeAgent(stdin io.Reader) (*agent.Event, error) { + input, err := t.ParseHookInput(agent.HookBeforeAgent, stdin) + if err != nil { + return nil, err + } + prompt, _ := input.RawData["prompt"].(string) + return &agent.Event{ + Type: agent.TurnStart, + SessionID: input.SessionID, + SessionRef: input.SessionRef, + Prompt: prompt, + Timestamp: time.Now(), + }, nil +} + +func (t *TraeAgent) parseAfterAgent(stdin io.Reader) (*agent.Event, error) { + input, err := t.ParseHookInput(agent.HookAfterAgent, stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.TurnEnd, + SessionID: input.SessionID, + SessionRef: input.SessionRef, + Timestamp: time.Now(), + }, nil +} + +func (t *TraeAgent) parseSessionEnd(stdin io.Reader) (*agent.Event, error) { + input, err := t.ParseHookInput(agent.HookSessionEnd, stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SessionEnd, + SessionID: input.SessionID, + SessionRef: input.SessionRef, + Timestamp: time.Now(), + }, nil +} + +// GetSupportedHooks returns the hook types Trae Agent supports. +func (t *TraeAgent) GetSupportedHooks() []agent.HookType { + return []agent.HookType{ + agent.HookSessionStart, + agent.HookSessionEnd, + agent.HookBeforeAgent, + agent.HookAfterAgent, + agent.HookBeforeModel, + agent.HookAfterModel, + agent.HookBeforeToolSelection, + agent.HookPreTool, + agent.HookAfterTool, + agent.HookPreCompress, + agent.HookNotification, + } +} + +// Helper functions for hook management + +// TraeHook represents a single hook configuration in Trae Agent + +type TraeHook struct { + Name string `json:"name"` + Type string `json:"type"` + Command string `json:"command"` +} + +// TraeHooks represents all hook configurations in Trae Agent + +type TraeHooks struct { + SessionStart []TraeHook `json:"SessionStart,omitempty"` + SessionEnd []TraeHook `json:"SessionEnd,omitempty"` + BeforeAgent []TraeHook `json:"BeforeAgent,omitempty"` + AfterAgent []TraeHook `json:"AfterAgent,omitempty"` + BeforeModel []TraeHook `json:"BeforeModel,omitempty"` + AfterModel []TraeHook `json:"AfterModel,omitempty"` + BeforeToolSelection []TraeHook `json:"BeforeToolSelection,omitempty"` + PreTool []TraeHook `json:"PreTool,omitempty"` + AfterTool []TraeHook `json:"AfterTool,omitempty"` + PreCompress []TraeHook `json:"PreCompress,omitempty"` + Notification []TraeHook `json:"Notification,omitempty"` +} + +// TraeSettings represents the complete Trae Agent settings structure + +type TraeSettings struct { + HooksConfig TraeHooksConfig `json:"hooksConfig,omitempty"` + Hooks TraeHooks `json:"hooks,omitempty"` +} + +// TraeHooksConfig represents the hooks configuration settings +type TraeHooksConfig struct { + Enabled bool `json:"enabled,omitempty"` +} + +func hookCommandExists(hooks []TraeHook, command string) bool { + for _, hook := range hooks { + if hook.Command == command { + return true + } + } + return false +} + +// isEntireHook checks if a command is an Entire hook (old or new format) +func isEntireHook(command string) bool { + for _, prefix := range entireHookPrefixes { + if strings.HasPrefix(command, prefix) { + return true + } + } + return false +} + +// removeEntireHooks removes all Entire hooks from a list of hooks +func removeEntireHooks(hooks []TraeHook) []TraeHook { + result := make([]TraeHook, 0, len(hooks)) + for _, hook := range hooks { + if !isEntireHook(hook.Command) { + result = append(result, hook) + } + } + return result +} diff --git a/cmd/entire/cli/agent/traeagent/traeagent.go b/cmd/entire/cli/agent/traeagent/traeagent.go new file mode 100644 index 000000000..768c365fd --- /dev/null +++ b/cmd/entire/cli/agent/traeagent/traeagent.go @@ -0,0 +1,347 @@ +// Package traeagent implements the Agent interface for Trae Agent. +package traeagent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +//nolint:gochecknoinits // Agent self-registration is the intended pattern +func init() { + agent.Register(agent.AgentNameTraeAgent, NewTraeAgent) +} + +// TraeAgent implements the Agent interface for Trae Agent. +type TraeAgent struct{} + +// NewTraeAgent creates a new Trae Agent instance. +func NewTraeAgent() agent.Agent { + return &TraeAgent{} +} + +// Name returns the agent registry key. +func (t *TraeAgent) Name() types.AgentName { + return agent.AgentNameTraeAgent +} + +// Type returns the agent type identifier. +func (t *TraeAgent) Type() types.AgentType { + return agent.AgentTypeTraeAgent +} + +// Description returns a human-readable description. +func (t *TraeAgent) Description() string { + return "Trae Agent - ByteDance's LLM-based software engineering agent" +} + +// IsPreview returns whether the agent integration is in preview. +func (t *TraeAgent) IsPreview() bool { + return true +} + +// DetectPresence checks if Trae Agent is configured in the repository. +func (t *TraeAgent) DetectPresence(ctx context.Context) (bool, error) { + // Get repo root to check for .trae directory + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + // Not in a git repo, fall back to CWD-relative check + repoRoot = "." + } + + // Check for .trae directory + traeDir := filepath.Join(repoRoot, ".trae") + if _, err := os.Stat(traeDir); err == nil { + return true, nil + } + // Check for .trae/settings.json or trae_config.yaml + settingsFile := filepath.Join(repoRoot, ".trae", "settings.json") + if _, err := os.Stat(settingsFile); err == nil { + return true, nil + } + configFile := filepath.Join(repoRoot, "trae_config.yaml") + if _, err := os.Stat(configFile); err == nil { + return true, nil + } + return false, nil +} + +// GetHookConfigPath returns the path to Trae's hook config file. +func (t *TraeAgent) GetHookConfigPath() string { + return ".trae/settings.json" +} + +// SupportsHooks returns true as Trae Agent supports lifecycle hooks. +func (t *TraeAgent) SupportsHooks() bool { + return true +} + +// ParseHookInput parses Trae Agent hook input from stdin. +func (t *TraeAgent) ParseHookInput(hookType agent.HookType, reader io.Reader) (*agent.HookInput, error) { + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read input: %w", err) + } + + if len(data) == 0 { + return nil, errors.New("empty input") + } + + input := &agent.HookInput{ + HookType: hookType, + Timestamp: time.Now(), + RawData: make(map[string]interface{}), + } + + // Parse based on hook type + switch hookType { + case agent.HookSessionStart: + var raw sessionStartRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse session start: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TrajectoryPath + + case agent.HookSessionEnd: + var raw sessionEndRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse session end: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TrajectoryPath + + case agent.HookPreToolUse: + var raw preToolUseRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse pre-tool input: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TrajectoryPath + input.ToolName = raw.ToolName + input.ToolUseID = raw.ToolUseID + input.ToolInput = data + + case agent.HookPostToolUse: + var raw postToolUseRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse post-tool input: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TrajectoryPath + input.ToolName = raw.ToolName + input.ToolUseID = raw.ToolUseID + input.ToolInput = data + input.ToolResponse = data + + case agent.HookBeforeModel: + var raw preModelRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse pre-model input: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TrajectoryPath + input.RawData["model_name"] = raw.ModelName + input.RawData["prompt"] = raw.Prompt + + case agent.HookAfterModel: + var raw postModelRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse post-model input: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TrajectoryPath + input.RawData["model_name"] = raw.ModelName + input.RawData["response"] = raw.Response + input.RawData["token_usage"] = raw.TokenUsage + + case agent.HookPreCompress: + var raw preCompressRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse pre-compress input: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TrajectoryPath + input.RawData["context"] = raw.Context + + case agent.HookNotification: + var raw notificationRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse notification input: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TrajectoryPath + input.RawData["notification_type"] = raw.NotificationType + input.RawData["message"] = raw.Message + } + + return input, nil +} + +// GetSessionID extracts the session ID from hook input. +func (t *TraeAgent) GetSessionID(input *agent.HookInput) string { + return input.SessionID +} + +// ResolveSessionFile returns the path to a Trae Agent session file. +func (t *TraeAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + return filepath.Join(sessionDir, agentSessionID+"_trajectory.json") +} + +// ProtectedDirs returns directories that Trae Agent uses for config/state. +func (t *TraeAgent) ProtectedDirs() []string { return []string{".trae"} } + +// GetSessionDir returns the directory where Trae Agent stores session transcripts. +func (t *TraeAgent) GetSessionDir(repoPath string) (string, error) { + // Check for test environment override + if override := os.Getenv("ENTIRE_TEST_TRAE_PROJECT_DIR"); override != "" { + return override, nil + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + return filepath.Join(homeDir, ".trae", "projects", filepath.Base(repoPath)), nil +} + +// ReadSession reads a session from Trae Agent's storage (JSON trajectory file). +func (t *TraeAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { + if input.SessionRef == "" { + return nil, errors.New("session reference (trajectory path) is required") + } + + // Read the raw JSON trajectory file + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read trajectory: %w", err) + } + + // Parse to extract computed fields + modifiedFiles, err := ExtractModifiedFiles(data) + if err != nil { + return nil, fmt.Errorf("failed to extract modified files: %w", err) + } + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: t.Name(), + SessionRef: input.SessionRef, + StartTime: time.Now(), + NativeData: data, + ModifiedFiles: modifiedFiles, + }, nil +} + +// WriteSession writes a session to Trae Agent's storage (JSON trajectory file). +func (t *TraeAgent) WriteSession(_ context.Context, session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + + // Verify this session belongs to Trae Agent + if session.AgentName != "" && session.AgentName != t.Name() { + return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, t.Name()) + } + + if session.SessionRef == "" { + return errors.New("session reference (trajectory path) is required") + } + + if len(session.NativeData) == 0 { + return errors.New("session has no native data to write") + } + + // Write the raw JSON data + if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + return fmt.Errorf("failed to write trajectory: %w", err) + } + + return nil +} + +// FormatResumeCommand returns the command to resume a Trae Agent session. +func (t *TraeAgent) FormatResumeCommand(sessionID string) string { + return "trae-cli interactive --resume " + sessionID +} + +// ReadTranscript reads the raw JSON trajectory bytes for a session. +func (t *TraeAgent) ReadTranscript(sessionRef string) ([]byte, error) { + data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input + if err != nil { + return nil, fmt.Errorf("failed to read trajectory: %w", err) + } + return data, nil +} + +// ExtractModifiedFiles extracts modified files from Trae Agent trajectory data. +func ExtractModifiedFiles(data []byte) ([]string, error) { + return extractModifiedFiles(data) +} + +// Raw data structures for parsing hooks + +type sessionStartRaw struct { + SessionID string `json:"session_id"` + TrajectoryPath string `json:"trajectory_path"` +} + +type sessionEndRaw struct { + SessionID string `json:"session_id"` + TrajectoryPath string `json:"trajectory_path"` +} + +type preToolUseRaw struct { + SessionID string `json:"session_id"` + TrajectoryPath string `json:"trajectory_path"` + ToolName string `json:"tool_name"` + ToolUseID string `json:"tool_use_id"` + ToolInput json.RawMessage `json:"tool_input"` +} + +type postToolUseRaw struct { + SessionID string `json:"session_id"` + TrajectoryPath string `json:"trajectory_path"` + ToolName string `json:"tool_name"` + ToolUseID string `json:"tool_use_id"` + ToolInput json.RawMessage `json:"tool_input"` + ToolResponse json.RawMessage `json:"tool_response"` +} + +type preModelRaw struct { + SessionID string `json:"session_id"` + TrajectoryPath string `json:"trajectory_path"` + ModelName string `json:"model_name"` + Prompt string `json:"prompt"` +} + +type postModelRaw struct { + SessionID string `json:"session_id"` + TrajectoryPath string `json:"trajectory_path"` + ModelName string `json:"model_name"` + Response string `json:"response"` + TokenUsage json.RawMessage `json:"token_usage"` +} + +type preCompressRaw struct { + SessionID string `json:"session_id"` + TrajectoryPath string `json:"trajectory_path"` + Context string `json:"context"` +} + +type notificationRaw struct { + SessionID string `json:"session_id"` + TrajectoryPath string `json:"trajectory_path"` + NotificationType string `json:"notification_type"` + Message string `json:"message"` +} diff --git a/cmd/entire/cli/agent/traeagent/traeagent_test.go b/cmd/entire/cli/agent/traeagent/traeagent_test.go new file mode 100644 index 000000000..a07d7749b --- /dev/null +++ b/cmd/entire/cli/agent/traeagent/traeagent_test.go @@ -0,0 +1,64 @@ +// Package traeagent implements the Agent interface for Trae Agent. +package traeagent + +import ( + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/stretchr/testify/assert" +) + +func TestNewTraeAgent(t *testing.T) { + ag := NewTraeAgent() + assert.NotNil(t, ag) + assert.Equal(t, agent.AgentNameTraeAgent, ag.Name()) + assert.Equal(t, agent.AgentTypeTraeAgent, ag.Type()) + assert.Equal(t, "Trae Agent - ByteDance's LLM-based software engineering agent", ag.Description()) +} + +func TestTraeAgent_SupportsHooks(t *testing.T) { + ag := NewTraeAgent() + assert.True(t, ag.SupportsHooks()) +} + +func TestTraeAgent_ProtectedDirs(t *testing.T) { + ag := NewTraeAgent() + dirs := ag.ProtectedDirs() + assert.Equal(t, []string{".trae"}, dirs) +} + +func TestTraeAgent_GetHookNames(t *testing.T) { + ag := NewTraeAgent() + hookHandler, ok := ag.(agent.HookHandler) + assert.True(t, ok, "TraeAgent should implement HookHandler") + hookNames := hookHandler.GetHookNames() + assert.Contains(t, hookNames, HookNameSessionStart) + assert.Contains(t, hookNames, HookNameSessionEnd) + assert.Contains(t, hookNames, HookNameBeforeAgent) + assert.Contains(t, hookNames, HookNameAfterAgent) + assert.Contains(t, hookNames, HookNameBeforeModel) + assert.Contains(t, hookNames, HookNameAfterModel) + assert.Contains(t, hookNames, HookNameBeforeToolSelection) + assert.Contains(t, hookNames, HookNamePreTool) + assert.Contains(t, hookNames, HookNameAfterTool) + assert.Contains(t, hookNames, HookNamePreCompress) + assert.Contains(t, hookNames, HookNameNotification) +} + +func TestTraeAgent_GetSupportedHooks(t *testing.T) { + ag := NewTraeAgent() + hookSupport, ok := ag.(agent.HookSupport) + assert.True(t, ok, "TraeAgent should implement HookSupport") + supportedHooks := hookSupport.GetSupportedHooks() + assert.Contains(t, supportedHooks, agent.HookSessionStart) + assert.Contains(t, supportedHooks, agent.HookSessionEnd) + assert.Contains(t, supportedHooks, agent.HookBeforeAgent) + assert.Contains(t, supportedHooks, agent.HookAfterAgent) + assert.Contains(t, supportedHooks, agent.HookBeforeModel) + assert.Contains(t, supportedHooks, agent.HookAfterModel) + assert.Contains(t, supportedHooks, agent.HookBeforeToolSelection) + assert.Contains(t, supportedHooks, agent.HookPreTool) + assert.Contains(t, supportedHooks, agent.HookAfterTool) + assert.Contains(t, supportedHooks, agent.HookPreCompress) + assert.Contains(t, supportedHooks, agent.HookNotification) +} diff --git a/cmd/entire/cli/agent/traeagent/transcript.go b/cmd/entire/cli/agent/traeagent/transcript.go new file mode 100644 index 000000000..88dc1da24 --- /dev/null +++ b/cmd/entire/cli/agent/traeagent/transcript.go @@ -0,0 +1,345 @@ +// Package traeagent implements the Agent interface for Trae Agent. +package traeagent + +import ( + "context" + "encoding/json" + "fmt" + "os" +) + +// Scanner buffer size for large transcript files (10MB) +const scannerBufferSize = 10 * 1024 * 1024 + +// TrajectoryEvent represents a single event in Trae Agent's trajectory + +type TrajectoryEvent struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + EventID string `json:"event_id"` + Content json.RawMessage `json:"content"` + ToolName string `json:"tool_name,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + ToolInput json.RawMessage `json:"tool_input,omitempty"` + ToolOutput json.RawMessage `json:"tool_output,omitempty"` + ModelName string `json:"model_name,omitempty"` + Prompt string `json:"prompt,omitempty"` + Response string `json:"response,omitempty"` + TokenUsage json.RawMessage `json:"token_usage,omitempty"` +} + +// Trajectory represents the complete trajectory of a Trae Agent session +type Trajectory struct { + SessionID string `json:"session_id"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time,omitempty"` + Events []TrajectoryEvent `json:"events"` +} + +// ParseTrajectory parses raw JSON content into a Trajectory object +func ParseTrajectory(data []byte) (*Trajectory, error) { + var trajectory Trajectory + if err := json.Unmarshal(data, &trajectory); err != nil { + return nil, fmt.Errorf("failed to parse trajectory: %w", err) + } + return &trajectory, nil +} + +// extractModifiedFiles extracts files modified by tool calls from trajectory +func extractModifiedFiles(data []byte) ([]string, error) { + trajectory, err := ParseTrajectory(data) + if err != nil { + return []string{}, nil // Return empty slice for now if parsing fails + } + + fileSet := make(map[string]bool) + var files []string + + for _, event := range trajectory.Events { + // Check for tool execution events + if event.Type == "tool_execution" || event.Type == "tool_result" { + // Check if it's a file modification tool + isModifyTool := false + for _, name := range FileModificationTools { + if event.ToolName == name { + isModifyTool = true + break + } + } + + if !isModifyTool { + continue + } + + // Try to extract file path from tool input + var toolInput struct { + FilePath string `json:"file_path,omitempty"` + Path string `json:"path,omitempty"` + } + + if err := json.Unmarshal(event.ToolInput, &toolInput); err == nil { + file := toolInput.FilePath + if file == "" { + file = toolInput.Path + } + + if file != "" && !fileSet[file] { + fileSet[file] = true + files = append(files, file) + } + } + } + } + + return files, nil +} + +// FileModificationTools lists the tools that modify files +var FileModificationTools = []string{ + "str_replace_based_edit_tool", + "edit_tool", + "write_file", + "update_file", + "delete_file", +} + +// TranscriptAnalyzer interface implementation + +// GetTranscriptPosition returns the current event count of a Trae Agent trajectory. +// Trae Agent uses JSON format with an events array, so position is the number of events. +func (t *TraeAgent) GetTranscriptPosition(path string) (int, error) { + if path == "" { + return 0, nil + } + + file, err := os.Open(path) //nolint:gosec // Path comes from Trae Agent trajectory location + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, fmt.Errorf("failed to open trajectory file: %w", err) + } + defer file.Close() + + // Read the entire file and parse it to get the event count + data, err := os.ReadFile(path) + if err != nil { + return 0, fmt.Errorf("failed to read trajectory file: %w", err) + } + + var trajectory Trajectory + if err := json.Unmarshal(data, &trajectory); err != nil { + return 0, fmt.Errorf("failed to parse trajectory: %w", err) + } + + return len(trajectory.Events), nil +} + +// ExtractModifiedFilesFromOffset extracts files modified since a given event index. +// For Trae Agent (JSON format), offset is the starting event index. +func (t *TraeAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) { + if path == "" { + return nil, 0, nil + } + + file, openErr := os.Open(path) //nolint:gosec // Path comes from Trae Agent trajectory location + if openErr != nil { + return nil, 0, fmt.Errorf("failed to open trajectory file: %w", openErr) + } + defer file.Close() + + // Read the entire file and parse it + data, err := os.ReadFile(path) + if err != nil { + return nil, 0, fmt.Errorf("failed to read trajectory file: %w", err) + } + + var trajectory Trajectory + if err := json.Unmarshal(data, &trajectory); err != nil { + return nil, 0, fmt.Errorf("failed to parse trajectory: %w", err) + } + + currentPosition = len(trajectory.Events) + if startOffset >= currentPosition { + return nil, currentPosition, nil + } + + // Extract events from startOffset onwards + relevantEvents := trajectory.Events[startOffset:] + + // Create a new trajectory with only relevant events + partialTrajectory := Trajectory{ + SessionID: trajectory.SessionID, + StartTime: trajectory.StartTime, + EndTime: trajectory.EndTime, + Events: relevantEvents, + } + + // Serialize partial trajectory and extract modified files + partialData, err := json.Marshal(partialTrajectory) + if err != nil { + return nil, currentPosition, fmt.Errorf("failed to marshal partial trajectory: %w", err) + } + + modifiedFiles, err := ExtractModifiedFiles(partialData) + if err != nil { + return nil, currentPosition, fmt.Errorf("failed to extract modified files: %w", err) + } + + return modifiedFiles, currentPosition, nil +} + +// TranscriptChunker interface implementation + +// ChunkTranscript splits a JSON trajectory into chunks if it exceeds maxSize. +// For JSON format, we split the events array into chunks while preserving valid JSON. +func (t *TraeAgent) ChunkTranscript(_ context.Context, content []byte, maxSize int) ([][]byte, error) { + // If content is smaller than maxSize, return as single chunk + if len(content) <= maxSize { + return [][]byte{content}, nil + } + + // Parse the trajectory to split events + var trajectory Trajectory + if err := json.Unmarshal(content, &trajectory); err != nil { + return nil, fmt.Errorf("failed to parse trajectory: %w", err) + } + + var chunks [][]byte + currentChunk := Trajectory{ + SessionID: trajectory.SessionID, + StartTime: trajectory.StartTime, + Events: []TrajectoryEvent{}, + } + + for _, event := range trajectory.Events { + // Add event to current chunk + currentChunk.Events = append(currentChunk.Events, event) + + // Check if current chunk exceeds maxSize + chunkData, err := json.Marshal(currentChunk) + if err != nil { + return nil, fmt.Errorf("failed to marshal chunk: %w", err) + } + + if len(chunkData) > maxSize { + // Remove the last event (it caused the overflow) + currentChunk.Events = currentChunk.Events[:len(currentChunk.Events)-1] + + // Marshal and add the chunk + finalChunkData, err := json.Marshal(currentChunk) + if err != nil { + return nil, fmt.Errorf("failed to marshal final chunk: %w", err) + } + chunks = append(chunks, finalChunkData) + + // Start a new chunk with the last event + currentChunk = Trajectory{ + SessionID: trajectory.SessionID, + StartTime: trajectory.StartTime, + Events: []TrajectoryEvent{event}, + } + } + } + + // Add the remaining chunk + if len(currentChunk.Events) > 0 { + chunkData, err := json.Marshal(currentChunk) + if err != nil { + return nil, fmt.Errorf("failed to marshal remaining chunk: %w", err) + } + chunks = append(chunks, chunkData) + } + + return chunks, nil +} + +// ReassembleTranscript combines chunks back into a single trajectory. +// For JSON format, we merge the events arrays from all chunks. +func (t *TraeAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + if len(chunks) == 0 { + return []byte("{}"), nil + } + + // Parse the first chunk to get the base trajectory + var mergedTrajectory Trajectory + if err := json.Unmarshal(chunks[0], &mergedTrajectory); err != nil { + return nil, fmt.Errorf("failed to parse first chunk: %w", err) + } + + // Merge events from remaining chunks + for i := 1; i < len(chunks); i++ { + var chunk Trajectory + if err := json.Unmarshal(chunks[i], &chunk); err != nil { + return nil, fmt.Errorf("failed to parse chunk %d: %w", i, err) + } + mergedTrajectory.Events = append(mergedTrajectory.Events, chunk.Events...) + } + + // Set end time from the last chunk if available + var lastChunk Trajectory + if err := json.Unmarshal(chunks[len(chunks)-1], &lastChunk); err == nil { + mergedTrajectory.EndTime = lastChunk.EndTime + } + + // Serialize the merged trajectory + mergedData, err := json.Marshal(mergedTrajectory) + if err != nil { + return nil, fmt.Errorf("failed to marshal merged trajectory: %w", err) + } + + return mergedData, nil +} + +// ExtractAllUserPrompts extracts all user prompts from a trajectory. +func ExtractAllUserPrompts(data []byte) ([]string, error) { + trajectory, err := ParseTrajectory(data) + if err != nil { + return nil, err + } + + var prompts []string + for _, event := range trajectory.Events { + if event.Type == "user_message" || event.Type == "user" { + var content struct { + Text string `json:"text"` + } + if err := json.Unmarshal(event.Content, &content); err == nil && content.Text != "" { + prompts = append(prompts, content.Text) + } + } + // Also check for prompt field directly in event + if event.Prompt != "" { + prompts = append(prompts, event.Prompt) + } + } + + return prompts, nil +} + +// ExtractLastAssistantMessage extracts the last assistant message from a trajectory. +func ExtractLastAssistantMessage(data []byte) (string, error) { + trajectory, err := ParseTrajectory(data) + if err != nil { + return "", err + } + + // Iterate in reverse to find the last assistant message + for i := len(trajectory.Events) - 1; i >= 0; i-- { + event := trajectory.Events[i] + if event.Type == "assistant_message" || event.Type == "assistant" { + var content struct { + Text string `json:"text"` + } + if err := json.Unmarshal(event.Content, &content); err == nil && content.Text != "" { + return content.Text, nil + } + } + // Also check for response field directly in event + if event.Response != "" { + return event.Response, nil + } + } + + return "", nil +} diff --git a/cmd/entire/cli/agent/types.go b/cmd/entire/cli/agent/types.go index 99f68b6d0..d50d8e3b1 100644 --- a/cmd/entire/cli/agent/types.go +++ b/cmd/entire/cli/agent/types.go @@ -6,12 +6,21 @@ import "time" type HookType string const ( - HookSessionStart HookType = "session_start" - HookSessionEnd HookType = "session_end" - HookUserPromptSubmit HookType = "user_prompt_submit" - HookStop HookType = "stop" - HookPreToolUse HookType = "pre_tool_use" - HookPostToolUse HookType = "post_tool_use" + HookSessionStart HookType = "session_start" + HookSessionEnd HookType = "session_end" + HookUserPromptSubmit HookType = "user_prompt_submit" + HookStop HookType = "stop" + HookPreToolUse HookType = "pre_tool_use" + HookPostToolUse HookType = "post_tool_use" + HookBeforeAgent HookType = "before_agent" + HookAfterAgent HookType = "after_agent" + HookBeforeModel HookType = "before_model" + HookAfterModel HookType = "after_model" + HookBeforeToolSelection HookType = "before_tool_selection" + HookPreTool HookType = "pre_tool" + HookAfterTool HookType = "after_tool" + HookPreCompress HookType = "pre_compress" + HookNotification HookType = "notification" ) // HookInput contains normalized data from hook callbacks diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index 74830ddbd..7ba5f05fe 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -15,6 +15,7 @@ import ( _ "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" _ "github.com/entireio/cli/cmd/entire/cli/agent/opencode" + _ "github.com/entireio/cli/cmd/entire/cli/agent/traeagent" _ "github.com/entireio/cli/cmd/entire/cli/agent/vogon" // support external agents From 84f96fd69fc0673ec52c48204f9e2eda6b1f7fb4 Mon Sep 17 00:00:00 2001 From: WangxuMarshall Date: Sat, 7 Mar 2026 15:09:21 +0800 Subject: [PATCH 02/15] feat(iflow): add core agent implementation Add types, main agent struct, and lifecycle event parsing for iFlow CLI integration. - types.go: Define settings, hooks, transcript structures and constants - iflow.go: Implement Agent interface with session management - lifecycle.go: Parse hook events into normalized lifecycle events --- cmd/entire/cli/agent/iflow/iflow.go | 273 ++++++++++++++++++++++++ cmd/entire/cli/agent/iflow/lifecycle.go | 184 ++++++++++++++++ cmd/entire/cli/agent/iflow/types.go | 183 ++++++++++++++++ 3 files changed, 640 insertions(+) create mode 100644 cmd/entire/cli/agent/iflow/iflow.go create mode 100644 cmd/entire/cli/agent/iflow/lifecycle.go create mode 100644 cmd/entire/cli/agent/iflow/types.go diff --git a/cmd/entire/cli/agent/iflow/iflow.go b/cmd/entire/cli/agent/iflow/iflow.go new file mode 100644 index 000000000..9b5062411 --- /dev/null +++ b/cmd/entire/cli/agent/iflow/iflow.go @@ -0,0 +1,273 @@ +// Package iflow implements the Agent interface for iFlow CLI. +// iFlow CLI is Alibaba's AI coding assistant with a hooks-based event system. +package iflow + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +//nolint:gochecknoinits // Agent self-registration is the intended pattern +func init() { + agent.Register(agent.AgentNameIFlow, NewIFlowCLIAgent) +} + +// IFlowCLIAgent implements the Agent interface for iFlow CLI. +type IFlowCLIAgent struct{} + +// NewIFlowCLIAgent creates a new iFlow CLI agent instance. +func NewIFlowCLIAgent() agent.Agent { + return &IFlowCLIAgent{} +} + +// Name returns the agent registry key. +func (i *IFlowCLIAgent) Name() types.AgentName { + return agent.AgentNameIFlow +} + +// Type returns the agent type identifier. +func (i *IFlowCLIAgent) Type() types.AgentType { + return agent.AgentTypeIFlow +} + +// Description returns a human-readable description. +func (i *IFlowCLIAgent) Description() string { + return "iFlow CLI - Alibaba's AI coding assistant" +} + +// IsPreview returns whether the agent integration is in preview. +func (i *IFlowCLIAgent) IsPreview() bool { + return true +} + +// DetectPresence checks if iFlow CLI is configured in the repository. +func (i *IFlowCLIAgent) DetectPresence(ctx context.Context) (bool, error) { + // Get worktree root to check for .iflow directory + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + // Not in a git repo, fall back to CWD-relative check + repoRoot = "." + } + + // Check for .iflow directory + iflowDir := filepath.Join(repoRoot, ".iflow") + if _, err := os.Stat(iflowDir); err == nil { + return true, nil + } + // Check for .iflow/settings.json + settingsFile := filepath.Join(iflowDir, "settings.json") + if _, err := os.Stat(settingsFile); err == nil { + return true, nil + } + return false, nil +} + +// ProtectedDirs returns directories that iFlow uses for config/state. +func (i *IFlowCLIAgent) ProtectedDirs() []string { + return []string{".iflow"} +} + +// GetSessionID extracts the session ID from hook input. +func (i *IFlowCLIAgent) GetSessionID(input *agent.HookInput) string { + return input.SessionID +} + +// GetSessionDir returns the directory where iFlow stores session transcripts. +func (i *IFlowCLIAgent) GetSessionDir(repoPath string) (string, error) { + // Check for test environment override + if override := os.Getenv("ENTIRE_TEST_IFLOW_PROJECT_DIR"); override != "" { + return override, nil + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + projectDir := SanitizePathForIFlow(repoPath) + return filepath.Join(homeDir, ".iflow", "projects", projectDir), nil +} + +// ResolveSessionFile returns the path to an iFlow session file. +// iFlow names files as .jsonl +func (i *IFlowCLIAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + return filepath.Join(sessionDir, agentSessionID+".jsonl") +} + +// ReadSession reads a session from iFlow's storage (JSONL transcript file). +func (i *IFlowCLIAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { + if input.SessionRef == "" { + return nil, errors.New("session reference (transcript path) is required") + } + + // Read the raw JSONL file + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + + // Parse to extract computed fields + lines, err := ParseTranscriptFromBytes(data) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: i.Name(), + SessionRef: input.SessionRef, + StartTime: time.Now(), + NativeData: data, + ModifiedFiles: ExtractModifiedFiles(lines), + }, nil +} + +// WriteSession writes a session to iFlow's storage (JSONL transcript file). +func (i *IFlowCLIAgent) WriteSession(_ context.Context, session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + + // Verify this session belongs to iFlow + if session.AgentName != "" && session.AgentName != i.Name() { + return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, i.Name()) + } + + if session.SessionRef == "" { + return errors.New("session reference (transcript path) is required") + } + + if len(session.NativeData) == 0 { + return errors.New("session has no native data to write") + } + + // Write the raw JSONL data + if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + return fmt.Errorf("failed to write transcript: %w", err) + } + + return nil +} + +// FormatResumeCommand returns the command to resume an iFlow CLI session. +func (i *IFlowCLIAgent) FormatResumeCommand(sessionID string) string { + return "iflow -r " + sessionID +} + +// ReadTranscript reads the raw JSONL transcript bytes for a session. +func (i *IFlowCLIAgent) ReadTranscript(sessionRef string) ([]byte, error) { + data, err := os.ReadFile(sessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + return data, nil +} + +// ChunkTranscript splits a JSONL transcript at line boundaries. +func (i *IFlowCLIAgent) ChunkTranscript(_ context.Context, content []byte, maxSize int) ([][]byte, error) { + chunks, err := agent.ChunkJSONL(content, maxSize) + if err != nil { + return nil, fmt.Errorf("failed to chunk JSONL transcript: %w", err) + } + return chunks, nil +} + +// ReassembleTranscript concatenates JSONL chunks with newlines. +func (i *IFlowCLIAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + return agent.ReassembleJSONL(chunks), nil +} + +// GetTranscriptPosition returns the current line count of an iFlow transcript. +func (i *IFlowCLIAgent) GetTranscriptPosition(path string) (int, error) { + if path == "" { + return 0, nil + } + + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, fmt.Errorf("failed to open transcript file: %w", err) + } + defer file.Close() + + reader := bufio.NewReader(file) + lineCount := 0 + + for { + line, err := reader.ReadBytes('\n') + if err != nil { + if err == io.EOF { + if len(line) > 0 { + lineCount++ + } + break + } + return 0, fmt.Errorf("failed to read transcript: %w", err) + } + lineCount++ + } + + return lineCount, nil +} + +// ExtractModifiedFilesFromOffset extracts files modified since a given line number. +func (i *IFlowCLIAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) { + if path == "" { + return nil, 0, nil + } + + file, openErr := os.Open(path) + if openErr != nil { + return nil, 0, fmt.Errorf("failed to open transcript file: %w", openErr) + } + defer file.Close() + + reader := bufio.NewReader(file) + var lines []TranscriptLine + lineNum := 0 + + for { + lineData, readErr := reader.ReadBytes('\n') + if readErr != nil && readErr != io.EOF { + return nil, 0, fmt.Errorf("failed to read transcript: %w", readErr) + } + + if len(lineData) > 0 { + lineNum++ + if lineNum > startOffset { + var line TranscriptLine + if parseErr := json.Unmarshal(lineData, &line); parseErr == nil { + lines = append(lines, line) + } + } + } + + if readErr == io.EOF { + break + } + } + + return ExtractModifiedFiles(lines), lineNum, nil +} + +// SanitizePathForIFlow converts a path to iFlow's project directory format. +// iFlow replaces any non-alphanumeric character with a dash. +var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`) + +func SanitizePathForIFlow(path string) string { + return nonAlphanumericRegex.ReplaceAllString(path, "-") +} diff --git a/cmd/entire/cli/agent/iflow/lifecycle.go b/cmd/entire/cli/agent/iflow/lifecycle.go new file mode 100644 index 000000000..3b676abe1 --- /dev/null +++ b/cmd/entire/cli/agent/iflow/lifecycle.go @@ -0,0 +1,184 @@ +package iflow + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// Ensure IFlowCLIAgent implements required interfaces +var ( + _ agent.TranscriptAnalyzer = (*IFlowCLIAgent)(nil) + _ agent.HookResponseWriter = (*IFlowCLIAgent)(nil) +) + +// WriteHookResponse outputs a JSON hook response to stdout. +// iFlow CLI can read this JSON and display messages to the user. +func (i *IFlowCLIAgent) WriteHookResponse(message string) error { + resp := struct { + SystemMessage string `json:"systemMessage,omitempty"` + }{SystemMessage: message} + if err := json.NewEncoder(os.Stdout).Encode(resp); err != nil { + return fmt.Errorf("failed to encode hook response: %w", err) + } + return nil +} + +// ParseHookEvent translates an iFlow CLI hook into a normalized lifecycle Event. +// Returns nil if the hook has no lifecycle significance. +func (i *IFlowCLIAgent) ParseHookEvent(_ context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { + switch hookName { + case HookNameSessionStart: + return i.parseSessionStart(stdin) + case HookNameUserPromptSubmit: + return i.parseTurnStart(stdin) + case HookNamePreToolUse: + return i.parsePreToolUse(stdin) + case HookNamePostToolUse: + return i.parsePostToolUse(stdin) + case HookNameStop: + return i.parseStop(stdin) + case HookNameSessionEnd: + return i.parseSessionEnd(stdin) + case HookNameSubagentStop: + return i.parseSubagentStop(stdin) + case HookNameSetUpEnvironment, HookNameNotification: + // These hooks don't have lifecycle significance for Entire + return nil, nil + default: + return nil, nil + } +} + +// --- Internal hook parsing functions --- + +func (i *IFlowCLIAgent) parseSessionStart(stdin io.Reader) (*agent.Event, error) { + var input SessionStartInput + if err := json.NewDecoder(stdin).Decode(&input); err != nil { + return nil, fmt.Errorf("failed to decode session start input: %w", err) + } + + event := &agent.Event{ + Type: agent.SessionStart, + SessionID: input.SessionID, + SessionRef: input.TranscriptPath, + Timestamp: time.Now(), + Metadata: make(map[string]string), + } + + if input.Model != "" { + event.Model = input.Model + } + + if input.Source != "" { + event.Metadata["session_source"] = input.Source + } + + return event, nil +} + +func (i *IFlowCLIAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error) { + var input UserPromptSubmitInput + if err := json.NewDecoder(stdin).Decode(&input); err != nil { + return nil, fmt.Errorf("failed to decode user prompt submit input: %w", err) + } + + return &agent.Event{ + Type: agent.TurnStart, + SessionID: input.SessionID, + SessionRef: input.TranscriptPath, + Prompt: input.Prompt, + Timestamp: time.Now(), + }, nil +} + +func (i *IFlowCLIAgent) parsePreToolUse(stdin io.Reader) (*agent.Event, error) { + var input ToolHookInput + if err := json.NewDecoder(stdin).Decode(&input); err != nil { + return nil, fmt.Errorf("failed to decode pre-tool-use input: %w", err) + } + + // Check if this is a subagent start (iFlow doesn't have explicit subagent concept, + // but we can detect certain patterns if needed) + // For now, we don't generate lifecycle events for PreToolUse + // unless it's a special tool that indicates subagent behavior + + return nil, nil +} + +func (i *IFlowCLIAgent) parsePostToolUse(stdin io.Reader) (*agent.Event, error) { + var input ToolHookInput + if err := json.NewDecoder(stdin).Decode(&input); err != nil { + return nil, fmt.Errorf("failed to decode post-tool-use input: %w", err) + } + + // Similar to PreToolUse, we don't generate lifecycle events for PostToolUse + // unless special handling is needed + + return nil, nil +} + +func (i *IFlowCLIAgent) parseStop(stdin io.Reader) (*agent.Event, error) { + var input StopInput + if err := json.NewDecoder(stdin).Decode(&input); err != nil { + return nil, fmt.Errorf("failed to decode stop input: %w", err) + } + + event := &agent.Event{ + Type: agent.TurnEnd, + SessionID: input.SessionID, + SessionRef: input.TranscriptPath, + Timestamp: time.Now(), + } + + if input.DurationMs > 0 { + event.DurationMs = input.DurationMs + } + if input.TurnCount > 0 { + event.TurnCount = input.TurnCount + } + + return event, nil +} + +func (i *IFlowCLIAgent) parseSessionEnd(stdin io.Reader) (*agent.Event, error) { + var input BaseHookInput + if err := json.NewDecoder(stdin).Decode(&input); err != nil { + return nil, fmt.Errorf("failed to decode session end input: %w", err) + } + + return &agent.Event{ + Type: agent.SessionEnd, + SessionID: input.SessionID, + SessionRef: input.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +func (i *IFlowCLIAgent) parseSubagentStop(stdin io.Reader) (*agent.Event, error) { + var input SubagentStopInput + if err := json.NewDecoder(stdin).Decode(&input); err != nil { + return nil, fmt.Errorf("failed to decode subagent stop input: %w", err) + } + + event := &agent.Event{ + Type: agent.SubagentEnd, + SessionID: input.SessionID, + SessionRef: input.TranscriptPath, + Timestamp: time.Now(), + } + + if input.SubagentID != "" { + event.SubagentID = input.SubagentID + } + if input.DurationMs > 0 { + event.DurationMs = input.DurationMs + } + + return event, nil +} diff --git a/cmd/entire/cli/agent/iflow/types.go b/cmd/entire/cli/agent/iflow/types.go new file mode 100644 index 000000000..87700921d --- /dev/null +++ b/cmd/entire/cli/agent/iflow/types.go @@ -0,0 +1,183 @@ +package iflow + +import "encoding/json" + +// IFlowSettings represents the .iflow/settings.json structure +type IFlowSettings struct { + Hooks IFlowHooks `json:"hooks,omitempty"` + Permissions IFlowPermissions `json:"permissions,omitempty"` +} + +// IFlowHooks contains the hook configurations +type IFlowHooks struct { + PreToolUse []IFlowHookMatcher `json:"PreToolUse,omitempty"` + PostToolUse []IFlowHookMatcher `json:"PostToolUse,omitempty"` + SetUpEnvironment []IFlowHookEntry `json:"SetUpEnvironment,omitempty"` + Stop []IFlowHookEntry `json:"Stop,omitempty"` + SubagentStop []IFlowHookEntry `json:"SubagentStop,omitempty"` + SessionStart []IFlowHookMatcher `json:"SessionStart,omitempty"` + SessionEnd []IFlowHookEntry `json:"SessionEnd,omitempty"` + UserPromptSubmit []IFlowHookMatcher `json:"UserPromptSubmit,omitempty"` + Notification []IFlowHookMatcher `json:"Notification,omitempty"` +} + +// IFlowPermissions contains permission settings +type IFlowPermissions struct { + Deny []string `json:"deny,omitempty"` +} + +// IFlowHookMatcher matches hooks to specific patterns +type IFlowHookMatcher struct { + Matcher string `json:"matcher,omitempty"` + Hooks []IFlowHookEntry `json:"hooks"` +} + +// IFlowHookEntry represents a single hook command +type IFlowHookEntry struct { + Type string `json:"type"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// --- Hook Input Types --- + +// BaseHookInput contains fields common to all hook inputs +type BaseHookInput struct { + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + TranscriptPath string `json:"transcript_path,omitempty"` +} + +// ToolHookInput is the JSON structure from PreToolUse/PostToolUse hooks +type ToolHookInput struct { + BaseHookInput + ToolName string `json:"tool_name"` + ToolAliases []string `json:"tool_aliases,omitempty"` + ToolInput json.RawMessage `json:"tool_input"` + ToolResponse json.RawMessage `json:"tool_response,omitempty"` +} + +// UserPromptSubmitInput is the JSON structure from UserPromptSubmit hook +type UserPromptSubmitInput struct { + BaseHookInput + Prompt string `json:"prompt"` +} + +// SessionStartInput is the JSON structure from SessionStart hook +type SessionStartInput struct { + BaseHookInput + Source string `json:"source,omitempty"` // startup, resume, clear, compress + Model string `json:"model,omitempty"` +} + +// NotificationInput is the JSON structure from Notification hook +type NotificationInput struct { + BaseHookInput + Message string `json:"message"` +} + +// StopInput is the JSON structure from Stop hook +type StopInput struct { + BaseHookInput + DurationMs int64 `json:"duration_ms,omitempty"` + TurnCount int `json:"turn_count,omitempty"` +} + +// SubagentStopInput is the JSON structure from SubagentStop hook +type SubagentStopInput struct { + BaseHookInput + SubagentID string `json:"subagent_id,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` +} + +// --- Transcript Types --- + +// TranscriptLine represents a single line in the JSONL transcript +type TranscriptLine struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + Message json.RawMessage `json:"message,omitempty"` + ToolUse *ToolUse `json:"tool_use,omitempty"` + ToolResult *ToolResult `json:"tool_result,omitempty"` +} + +// ToolUse represents a tool invocation in the transcript +type ToolUse struct { + ID string `json:"id"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` +} + +// ToolResult represents the result of a tool invocation +type ToolResult struct { + ToolUseID string `json:"tool_use_id"` + Result json.RawMessage `json:"result"` +} + +// FileEditToolInput represents input for file editing tools +type FileEditToolInput struct { + FilePath string `json:"file_path"` + Path string `json:"path"` // Alternative field name +} + +// FileWriteToolInput represents input for file write tools +type FileWriteToolInput struct { + FilePath string `json:"file_path"` + Path string `json:"path"` // Alternative field name +} + +// Tool names used in iFlow CLI transcripts +const ( + ToolWrite = "write_file" + ToolEdit = "replace" + ToolMultiEdit = "multi_edit" + ToolShell = "run_shell_command" + ToolRead = "read_file" + ToolList = "list_directory" + ToolSearch = "search_file_content" + ToolGlob = "glob" +) + +// FileModificationTools lists tools that create or modify files +var FileModificationTools = []string{ + ToolWrite, + ToolEdit, + ToolMultiEdit, +} + +// SessionSource constants for SessionStart hook +type SessionSource string + +const ( + SessionSourceStartup SessionSource = "startup" + SessionSourceResume SessionSource = "resume" + SessionSourceClear SessionSource = "clear" + SessionSourceCompress SessionSource = "compress" +) + +// IFlow-specific environment variable names +const ( + EnvIFlowSessionID = "IFLOW_SESSION_ID" + EnvIFlowTranscriptPath = "IFLOW_TRANSCRIPT_PATH" + EnvIFlowCWD = "IFLOW_CWD" + EnvIFlowHookEventName = "IFLOW_HOOK_EVENT_NAME" + EnvIFlowToolName = "IFLOW_TOOL_NAME" + EnvIFlowToolArgs = "IFLOW_TOOL_ARGS" + EnvIFlowToolAliases = "IFLOW_TOOL_ALIASES" + EnvIFlowSessionSource = "IFLOW_SESSION_SOURCE" + EnvIFlowUserPrompt = "IFLOW_USER_PROMPT" + EnvIFlowNotification = "IFLOW_NOTIFICATION_MESSAGE" +) + +// Settings file name +const IFlowSettingsFileName = "settings.json" + +// metadataDenyRule blocks iFlow from reading Entire session metadata +const metadataDenyRule = "Read(./.entire/metadata/**)" + +// entireHookPrefixes are command prefixes that identify Entire hooks +var entireHookPrefixes = []string{ + "entire ", + "go run ${IFLOW_PROJECT_DIR}/cmd/entire/main.go ", +} From 8a1cf22a5fea87f7f24b9f0930bcb42e7ec5ab13 Mon Sep 17 00:00:00 2001 From: WangxuMarshall Date: Sat, 7 Mar 2026 15:09:35 +0800 Subject: [PATCH 03/15] feat(iflow): add hooks and transcript handling - hooks.go: Implement HookSupport interface for auto-installation of hooks - transcript.go: Parse and extract file modifications from JSONL transcripts --- cmd/entire/cli/agent/iflow/hooks.go | 557 +++++++++++++++++++++++ cmd/entire/cli/agent/iflow/transcript.go | 235 ++++++++++ 2 files changed, 792 insertions(+) create mode 100644 cmd/entire/cli/agent/iflow/hooks.go create mode 100644 cmd/entire/cli/agent/iflow/transcript.go diff --git a/cmd/entire/cli/agent/iflow/hooks.go b/cmd/entire/cli/agent/iflow/hooks.go new file mode 100644 index 000000000..4645b853c --- /dev/null +++ b/cmd/entire/cli/agent/iflow/hooks.go @@ -0,0 +1,557 @@ +package iflow + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Ensure IFlowCLIAgent implements HookSupport +var _ agent.HookSupport = (*IFlowCLIAgent)(nil) + +// iFlow hook names - these become subcommands under `entire hooks iflow` +const ( + HookNamePreToolUse = "pre-tool-use" + HookNamePostToolUse = "post-tool-use" + HookNameSetUpEnvironment = "set-up-environment" + HookNameStop = "stop" + HookNameSubagentStop = "subagent-stop" + HookNameSessionStart = "session-start" + HookNameSessionEnd = "session-end" + HookNameUserPromptSubmit = "user-prompt-submit" + HookNameNotification = "notification" +) + +// HookNames returns the hook verbs iFlow CLI supports. +// These become subcommands: entire hooks iflow +func (i *IFlowCLIAgent) HookNames() []string { + return []string{ + HookNamePreToolUse, + HookNamePostToolUse, + HookNameSetUpEnvironment, + HookNameStop, + HookNameSubagentStop, + HookNameSessionStart, + HookNameSessionEnd, + HookNameUserPromptSubmit, + HookNameNotification, + } +} + +// InstallHooks installs iFlow CLI hooks in .iflow/settings.json. +// If force is true, removes existing Entire hooks before installing. +// Returns the number of hooks installed. +func (i *IFlowCLIAgent) InstallHooks(ctx context.Context, localDev bool, force bool) (int, error) { + // Use repo root instead of CWD to find .iflow directory + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + // Fallback to CWD if not in a git repo + repoRoot, err = os.Getwd() + if err != nil { + return 0, fmt.Errorf("failed to get current directory: %w", err) + } + } + + settingsPath := filepath.Join(repoRoot, ".iflow", IFlowSettingsFileName) + + // Read existing settings if they exist + var rawSettings map[string]json.RawMessage + var rawHooks map[string]json.RawMessage + var rawPermissions map[string]json.RawMessage + + existingData, readErr := os.ReadFile(settingsPath) + if readErr == nil { + if err := json.Unmarshal(existingData, &rawSettings); err != nil { + return 0, fmt.Errorf("failed to parse existing settings.json: %w", err) + } + if hooksRaw, ok := rawSettings["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return 0, fmt.Errorf("failed to parse hooks in settings.json: %w", err) + } + } + if permRaw, ok := rawSettings["permissions"]; ok { + if err := json.Unmarshal(permRaw, &rawPermissions); err != nil { + return 0, fmt.Errorf("failed to parse permissions in settings.json: %w", err) + } + } + } else { + rawSettings = make(map[string]json.RawMessage) + } + + if rawHooks == nil { + rawHooks = make(map[string]json.RawMessage) + } + if rawPermissions == nil { + rawPermissions = make(map[string]json.RawMessage) + } + + // Parse hook types we need to modify + var preToolUse, postToolUse, sessionStart, userPromptSubmit, notification []IFlowHookMatcher + var setUpEnvironment, stop, subagentStop, sessionEnd []IFlowHookEntry + + parseHookMatcherType(rawHooks, "PreToolUse", &preToolUse) + parseHookMatcherType(rawHooks, "PostToolUse", &postToolUse) + parseHookMatcherType(rawHooks, "SessionStart", &sessionStart) + parseHookMatcherType(rawHooks, "UserPromptSubmit", &userPromptSubmit) + parseHookMatcherType(rawHooks, "Notification", ¬ification) + parseHookEntryType(rawHooks, "SetUpEnvironment", &setUpEnvironment) + parseHookEntryType(rawHooks, "Stop", &stop) + parseHookEntryType(rawHooks, "SubagentStop", &subagentStop) + parseHookEntryType(rawHooks, "SessionEnd", &sessionEnd) + + // If force is true, remove all existing Entire hooks first + if force { + preToolUse = removeEntireHooks(preToolUse) + postToolUse = removeEntireHooks(postToolUse) + sessionStart = removeEntireHooks(sessionStart) + userPromptSubmit = removeEntireHooks(userPromptSubmit) + notification = removeEntireHooks(notification) + setUpEnvironment = removeEntireHookEntries(setUpEnvironment) + stop = removeEntireHookEntries(stop) + subagentStop = removeEntireHookEntries(subagentStop) + sessionEnd = removeEntireHookEntries(sessionEnd) + } + + // Define hook commands + var preToolUseCmd, postToolUseCmd, setUpEnvCmd, stopCmd, subagentStopCmd string + var sessionStartCmd, sessionEndCmd, userPromptSubmitCmd, notificationCmd string + + if localDev { + baseCmd := "go run ${IFLOW_PROJECT_DIR}/cmd/entire/main.go hooks iflow" + preToolUseCmd = baseCmd + " pre-tool-use" + postToolUseCmd = baseCmd + " post-tool-use" + setUpEnvCmd = baseCmd + " set-up-environment" + stopCmd = baseCmd + " stop" + subagentStopCmd = baseCmd + " subagent-stop" + sessionStartCmd = baseCmd + " session-start" + sessionEndCmd = baseCmd + " session-end" + userPromptSubmitCmd = baseCmd + " user-prompt-submit" + notificationCmd = baseCmd + " notification" + } else { + preToolUseCmd = "entire hooks iflow pre-tool-use" + postToolUseCmd = "entire hooks iflow post-tool-use" + setUpEnvCmd = "entire hooks iflow set-up-environment" + stopCmd = "entire hooks iflow stop" + subagentStopCmd = "entire hooks iflow subagent-stop" + sessionStartCmd = "entire hooks iflow session-start" + sessionEndCmd = "entire hooks iflow session-end" + userPromptSubmitCmd = "entire hooks iflow user-prompt-submit" + notificationCmd = "entire hooks iflow notification" + } + + count := 0 + + // Add PreToolUse hook with matcher "*" (all tools) + if !hookMatcherExists(preToolUse, "*", preToolUseCmd) { + preToolUse = addHookMatcher(preToolUse, "*", preToolUseCmd) + count++ + } + + // Add PostToolUse hook with matcher "*" (all tools) + if !hookMatcherExists(postToolUse, "*", postToolUseCmd) { + postToolUse = addHookMatcher(postToolUse, "*", postToolUseCmd) + count++ + } + + // Add SessionStart hook with matcher "startup" + if !hookMatcherExists(sessionStart, "startup", sessionStartCmd) { + sessionStart = addHookMatcher(sessionStart, "startup", sessionStartCmd) + count++ + } + + // Add UserPromptSubmit hook + if !hookMatcherHasCommand(userPromptSubmit, userPromptSubmitCmd) { + userPromptSubmit = append(userPromptSubmit, IFlowHookMatcher{ + Hooks: []IFlowHookEntry{{Type: "command", Command: userPromptSubmitCmd}}, + }) + count++ + } + + // Add Notification hook + if !hookMatcherHasCommand(notification, notificationCmd) { + notification = append(notification, IFlowHookMatcher{ + Hooks: []IFlowHookEntry{{Type: "command", Command: notificationCmd}}, + }) + count++ + } + + // Add SetUpEnvironment hook + if !hookEntryExists(setUpEnvCmd, setUpEnvironment) { + setUpEnvironment = append(setUpEnvironment, IFlowHookEntry{ + Type: "command", + Command: setUpEnvCmd, + }) + count++ + } + + // Add Stop hook + if !hookEntryExists(stopCmd, stop) { + stop = append(stop, IFlowHookEntry{ + Type: "command", + Command: stopCmd, + }) + count++ + } + + // Add SubagentStop hook + if !hookEntryExists(subagentStopCmd, subagentStop) { + subagentStop = append(subagentStop, IFlowHookEntry{ + Type: "command", + Command: subagentStopCmd, + }) + count++ + } + + // Add SessionEnd hook + if !hookEntryExists(sessionEndCmd, sessionEnd) { + sessionEnd = append(sessionEnd, IFlowHookEntry{ + Type: "command", + Command: sessionEndCmd, + }) + count++ + } + + // Add permissions.deny rule if not present + permissionsChanged := false + var denyRules []string + if denyRaw, ok := rawPermissions["deny"]; ok { + if err := json.Unmarshal(denyRaw, &denyRules); err != nil { + return 0, fmt.Errorf("failed to parse permissions.deny in settings.json: %w", err) + } + } + if !slices.Contains(denyRules, metadataDenyRule) { + denyRules = append(denyRules, metadataDenyRule) + denyJSON, err := json.Marshal(denyRules) + if err != nil { + return 0, fmt.Errorf("failed to marshal permissions.deny: %w", err) + } + rawPermissions["deny"] = denyJSON + permissionsChanged = true + } + + if count == 0 && !permissionsChanged { + return 0, nil // All hooks and permissions already installed + } + + // Marshal modified hook types back to rawHooks + marshalHookMatcherType(rawHooks, "PreToolUse", preToolUse) + marshalHookMatcherType(rawHooks, "PostToolUse", postToolUse) + marshalHookMatcherType(rawHooks, "SessionStart", sessionStart) + marshalHookMatcherType(rawHooks, "UserPromptSubmit", userPromptSubmit) + marshalHookMatcherType(rawHooks, "Notification", notification) + marshalHookEntryType(rawHooks, "SetUpEnvironment", setUpEnvironment) + marshalHookEntryType(rawHooks, "Stop", stop) + marshalHookEntryType(rawHooks, "SubagentStop", subagentStop) + marshalHookEntryType(rawHooks, "SessionEnd", sessionEnd) + + // Marshal hooks and update raw settings + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return 0, fmt.Errorf("failed to marshal hooks: %w", err) + } + rawSettings["hooks"] = hooksJSON + + // Marshal permissions and update raw settings + permJSON, err := json.Marshal(rawPermissions) + if err != nil { + return 0, fmt.Errorf("failed to marshal permissions: %w", err) + } + rawSettings["permissions"] = permJSON + + // Write back to file + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { + return 0, fmt.Errorf("failed to create .iflow directory: %w", err) + } + + output, err := jsonutil.MarshalIndentWithNewline(rawSettings, "", " ") + if err != nil { + return 0, fmt.Errorf("failed to marshal settings: %w", err) + } + + if err := os.WriteFile(settingsPath, output, 0o600); err != nil { + return 0, fmt.Errorf("failed to write settings.json: %w", err) + } + + return count, nil +} + +// UninstallHooks removes Entire hooks from iFlow CLI settings. +func (i *IFlowCLIAgent) UninstallHooks(ctx context.Context) error { + // Use repo root to find .iflow directory when run from a subdirectory + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot = "." + } + settingsPath := filepath.Join(repoRoot, ".iflow", IFlowSettingsFileName) + data, err := os.ReadFile(settingsPath) + if err != nil { + return nil + } + + var rawSettings map[string]json.RawMessage + if err := json.Unmarshal(data, &rawSettings); err != nil { + return fmt.Errorf("failed to parse settings.json: %w", err) + } + + var rawHooks map[string]json.RawMessage + if hooksRaw, ok := rawSettings["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return fmt.Errorf("failed to parse hooks: %w", err) + } + } + if rawHooks == nil { + rawHooks = make(map[string]json.RawMessage) + } + + // Parse and clean all hook types + var preToolUse, postToolUse, sessionStart, userPromptSubmit, notification []IFlowHookMatcher + var setUpEnvironment, stop, subagentStop, sessionEnd []IFlowHookEntry + + parseHookMatcherType(rawHooks, "PreToolUse", &preToolUse) + parseHookMatcherType(rawHooks, "PostToolUse", &postToolUse) + parseHookMatcherType(rawHooks, "SessionStart", &sessionStart) + parseHookMatcherType(rawHooks, "UserPromptSubmit", &userPromptSubmit) + parseHookMatcherType(rawHooks, "Notification", ¬ification) + parseHookEntryType(rawHooks, "SetUpEnvironment", &setUpEnvironment) + parseHookEntryType(rawHooks, "Stop", &stop) + parseHookEntryType(rawHooks, "SubagentStop", &subagentStop) + parseHookEntryType(rawHooks, "SessionEnd", &sessionEnd) + + // Remove Entire hooks from all hook types + preToolUse = removeEntireHooks(preToolUse) + postToolUse = removeEntireHooks(postToolUse) + sessionStart = removeEntireHooks(sessionStart) + userPromptSubmit = removeEntireHooks(userPromptSubmit) + notification = removeEntireHooks(notification) + setUpEnvironment = removeEntireHookEntries(setUpEnvironment) + stop = removeEntireHookEntries(stop) + subagentStop = removeEntireHookEntries(subagentStop) + sessionEnd = removeEntireHookEntries(sessionEnd) + + // Marshal modified hook types back to rawHooks + marshalHookMatcherType(rawHooks, "PreToolUse", preToolUse) + marshalHookMatcherType(rawHooks, "PostToolUse", postToolUse) + marshalHookMatcherType(rawHooks, "SessionStart", sessionStart) + marshalHookMatcherType(rawHooks, "UserPromptSubmit", userPromptSubmit) + marshalHookMatcherType(rawHooks, "Notification", notification) + marshalHookEntryType(rawHooks, "SetUpEnvironment", setUpEnvironment) + marshalHookEntryType(rawHooks, "Stop", stop) + marshalHookEntryType(rawHooks, "SubagentStop", subagentStop) + marshalHookEntryType(rawHooks, "SessionEnd", sessionEnd) + + // Also remove the metadata deny rule from permissions + var rawPermissions map[string]json.RawMessage + if permRaw, ok := rawSettings["permissions"]; ok { + if err := json.Unmarshal(permRaw, &rawPermissions); err != nil { + rawPermissions = nil + } + } + + if rawPermissions != nil { + if denyRaw, ok := rawPermissions["deny"]; ok { + var denyRules []string + if err := json.Unmarshal(denyRaw, &denyRules); err == nil { + filteredRules := make([]string, 0, len(denyRules)) + for _, rule := range denyRules { + if rule != metadataDenyRule { + filteredRules = append(filteredRules, rule) + } + } + if len(filteredRules) > 0 { + denyJSON, err := json.Marshal(filteredRules) + if err == nil { + rawPermissions["deny"] = denyJSON + } + } else { + delete(rawPermissions, "deny") + } + } + } + + if len(rawPermissions) > 0 { + permJSON, err := json.Marshal(rawPermissions) + if err == nil { + rawSettings["permissions"] = permJSON + } + } else { + delete(rawSettings, "permissions") + } + } + + // Marshal hooks back + if len(rawHooks) > 0 { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("failed to marshal hooks: %w", err) + } + rawSettings["hooks"] = hooksJSON + } else { + delete(rawSettings, "hooks") + } + + // Write back + output, err := jsonutil.MarshalIndentWithNewline(rawSettings, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal settings: %w", err) + } + if err := os.WriteFile(settingsPath, output, 0o600); err != nil { + return fmt.Errorf("failed to write settings.json: %w", err) + } + return nil +} + +// AreHooksInstalled checks if Entire hooks are currently installed. +func (i *IFlowCLIAgent) AreHooksInstalled(ctx context.Context) bool { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot = "." + } + settingsPath := filepath.Join(repoRoot, ".iflow", IFlowSettingsFileName) + data, err := os.ReadFile(settingsPath) + if err != nil { + return false + } + + var settings IFlowSettings + if err := json.Unmarshal(data, &settings); err != nil { + return false + } + + // Check for at least one of our hooks + return hookEntryExists("entire hooks iflow stop", settings.Hooks.Stop) || + hookEntryExists("go run ${IFLOW_PROJECT_DIR}/cmd/entire/main.go hooks iflow stop", settings.Hooks.Stop) +} + +// Helper functions for hook management + +func parseHookMatcherType(rawHooks map[string]json.RawMessage, hookType string, target *[]IFlowHookMatcher) { + if data, ok := rawHooks[hookType]; ok { + json.Unmarshal(data, target) + } +} + +func parseHookEntryType(rawHooks map[string]json.RawMessage, hookType string, target *[]IFlowHookEntry) { + if data, ok := rawHooks[hookType]; ok { + json.Unmarshal(data, target) + } +} + +func marshalHookMatcherType(rawHooks map[string]json.RawMessage, hookType string, matchers []IFlowHookMatcher) { + if len(matchers) == 0 { + delete(rawHooks, hookType) + return + } + data, err := json.Marshal(matchers) + if err != nil { + return + } + rawHooks[hookType] = data +} + +func marshalHookEntryType(rawHooks map[string]json.RawMessage, hookType string, entries []IFlowHookEntry) { + if len(entries) == 0 { + delete(rawHooks, hookType) + return + } + data, err := json.Marshal(entries) + if err != nil { + return + } + rawHooks[hookType] = data +} + +func isEntireHook(command string) bool { + for _, prefix := range entireHookPrefixes { + if strings.HasPrefix(command, prefix) { + return true + } + } + return false +} + +func removeEntireHooks(matchers []IFlowHookMatcher) []IFlowHookMatcher { + result := make([]IFlowHookMatcher, 0, len(matchers)) + for _, matcher := range matchers { + filteredHooks := make([]IFlowHookEntry, 0, len(matcher.Hooks)) + for _, hook := range matcher.Hooks { + if !isEntireHook(hook.Command) { + filteredHooks = append(filteredHooks, hook) + } + } + if len(filteredHooks) > 0 { + matcher.Hooks = filteredHooks + result = append(result, matcher) + } + } + return result +} + +func removeEntireHookEntries(entries []IFlowHookEntry) []IFlowHookEntry { + result := make([]IFlowHookEntry, 0, len(entries)) + for _, entry := range entries { + if !isEntireHook(entry.Command) { + result = append(result, entry) + } + } + return result +} + +func hookMatcherExists(matchers []IFlowHookMatcher, matcherPattern, command string) bool { + for _, matcher := range matchers { + if matcher.Matcher == matcherPattern { + for _, hook := range matcher.Hooks { + if hook.Command == command { + return true + } + } + } + } + return false +} + +func hookEntryExists(command string, entries []IFlowHookEntry) bool { + for _, entry := range entries { + if entry.Command == command { + return true + } + } + return false +} + +func hookMatcherHasCommand(matchers []IFlowHookMatcher, command string) bool { + for _, matcher := range matchers { + for _, hook := range matcher.Hooks { + if hook.Command == command { + return true + } + } + } + return false +} + +func addHookMatcher(matchers []IFlowHookMatcher, matcherPattern, command string) []IFlowHookMatcher { + entry := IFlowHookEntry{ + Type: "command", + Command: command, + } + + for i, matcher := range matchers { + if matcher.Matcher == matcherPattern { + matchers[i].Hooks = append(matchers[i].Hooks, entry) + return matchers + } + } + + return append(matchers, IFlowHookMatcher{ + Matcher: matcherPattern, + Hooks: []IFlowHookEntry{entry}, + }) +} diff --git a/cmd/entire/cli/agent/iflow/transcript.go b/cmd/entire/cli/agent/iflow/transcript.go new file mode 100644 index 000000000..1a2474488 --- /dev/null +++ b/cmd/entire/cli/agent/iflow/transcript.go @@ -0,0 +1,235 @@ +package iflow + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "strings" +) + +// ParseTranscriptFromBytes parses JSONL transcript data into TranscriptLines. +func ParseTranscriptFromBytes(data []byte) ([]TranscriptLine, error) { + var lines []TranscriptLine + scanner := bufio.NewScanner(bytesReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var entry TranscriptLine + if err := json.Unmarshal([]byte(line), &entry); err != nil { + // Skip malformed lines + continue + } + lines = append(lines, entry) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to scan transcript: %w", err) + } + return lines, nil +} + +// ExtractModifiedFiles extracts file paths from tools that modify files. +func ExtractModifiedFiles(lines []TranscriptLine) []string { + fileSet := make(map[string]struct{}) + + for _, line := range lines { + if line.ToolUse == nil { + continue + } + + if !isFileModificationTool(line.ToolUse.Name) { + continue + } + + filePath := extractFilePathFromToolInput(line.ToolUse.Input) + if filePath != "" { + fileSet[filePath] = struct{}{} + } + } + + // Convert set to sorted slice + files := make([]string, 0, len(fileSet)) + for file := range fileSet { + files = append(files, file) + } + return files +} + +// isFileModificationTool checks if the tool name indicates a file modification operation. +func isFileModificationTool(toolName string) bool { + for _, t := range FileModificationTools { + if toolName == t { + return true + } + } + return false +} + +// extractFilePathFromToolInput extracts the file path from tool input JSON. +func extractFilePathFromToolInput(input json.RawMessage) string { + if len(input) == 0 { + return "" + } + + // Try FileWriteToolInput structure + var writeInput FileWriteToolInput + if err := json.Unmarshal(input, &writeInput); err == nil { + if writeInput.FilePath != "" { + return writeInput.FilePath + } + if writeInput.Path != "" { + return writeInput.Path + } + } + + // Try FileEditToolInput structure + var editInput FileEditToolInput + if err := json.Unmarshal(input, &editInput); err == nil { + if editInput.FilePath != "" { + return editInput.FilePath + } + if editInput.Path != "" { + return editInput.Path + } + } + + // Try generic map extraction + var generic map[string]interface{} + if err := json.Unmarshal(input, &generic); err == nil { + // Check common field names + for _, key := range []string{"file_path", "path", "filepath", "file", "filename"} { + if val, ok := generic[key]; ok { + if str, ok := val.(string); ok && str != "" { + return str + } + } + } + } + + return "" +} + +// bytesReader creates an io.Reader from bytes (helper for testing). +func bytesReader(data []byte) io.Reader { + return strings.NewReader(string(data)) +} + +// SerializeTranscript converts TranscriptLines back to JSONL format. +func SerializeTranscript(lines []TranscriptLine) ([]byte, error) { + var result strings.Builder + encoder := json.NewEncoder(&result) + encoder.SetEscapeHTML(false) + + for _, line := range lines { + if err := encoder.Encode(line); err != nil { + return nil, fmt.Errorf("failed to encode transcript line: %w", err) + } + } + + return []byte(result.String()), nil +} + +// FindCheckpointLine finds the line index containing a specific tool result. +// Used for checkpoint rewind operations. +func FindCheckpointLine(lines []TranscriptLine, toolUseID string) (int, bool) { + for i, line := range lines { + if line.ToolResult != nil && line.ToolResult.ToolUseID == toolUseID { + return i, true + } + } + return 0, false +} + +// TruncateAtLine returns a new transcript truncated at the given line index (inclusive). +func TruncateAtLine(lines []TranscriptLine, lineIndex int) []TranscriptLine { + if lineIndex < 0 || lineIndex >= len(lines) { + return lines + } + return lines[:lineIndex+1] +} + +// TruncateAtToolResult returns a new transcript truncated at the given tool result. +func TruncateAtToolResult(lines []TranscriptLine, toolUseID string) ([]TranscriptLine, bool) { + lineIndex, found := FindCheckpointLine(lines, toolUseID) + if !found { + return nil, false + } + return TruncateAtLine(lines, lineIndex), true +} + +// GetLastToolUse finds the last tool use in the transcript. +func GetLastToolUse(lines []TranscriptLine) *ToolUse { + for i := len(lines) - 1; i >= 0; i-- { + if lines[i].ToolUse != nil { + return lines[i].ToolUse + } + } + return nil +} + +// GetToolResultForUse finds the tool result for a given tool use ID. +func GetToolResultForUse(lines []TranscriptLine, toolUseID string) *ToolResult { + for _, line := range lines { + if line.ToolResult != nil && line.ToolResult.ToolUseID == toolUseID { + return line.ToolResult + } + } + return nil +} + +// CountToolUses counts the occurrences of a specific tool in the transcript. +func CountToolUses(lines []TranscriptLine, toolName string) int { + count := 0 + for _, line := range lines { + if line.ToolUse != nil && line.ToolUse.Name == toolName { + count++ + } + } + return count +} + +// ReadTranscriptFile reads and parses a transcript file. +func ReadTranscriptFile(path string) ([]TranscriptLine, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read transcript file: %w", err) + } + return ParseTranscriptFromBytes(data) +} + +// WriteTranscriptFile writes transcript lines to a file. +func WriteTranscriptFile(path string, lines []TranscriptLine) error { + data, err := SerializeTranscript(lines) + if err != nil { + return fmt.Errorf("failed to serialize transcript: %w", err) + } + + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("failed to write transcript file: %w", err) + } + + return nil +} + +// MergeTranscripts merges multiple transcripts into one. +// Handles deduplication of lines based on timestamp and content. +func MergeTranscripts(transcripts [][]TranscriptLine) []TranscriptLine { + seen := make(map[string]struct{}) + var result []TranscriptLine + + for _, transcript := range transcripts { + for _, line := range transcript { + // Create a key for deduplication + key := line.Timestamp + string(line.Message) + if _, exists := seen[key]; !exists { + seen[key] = struct{}{} + result = append(result, line) + } + } + } + + return result +} From e31e001848b9cb98dc034ffb9134b8dd03217b02 Mon Sep 17 00:00:00 2001 From: WangxuMarshall Date: Sat, 7 Mar 2026 15:09:54 +0800 Subject: [PATCH 04/15] feat(iflow): register agent and add tests - Add iflow agent to registry with AgentNameIFlow and AgentTypeIFlow - Import iflow package in hooks_cmd.go for auto-registration - Add comprehensive unit tests for all iflow agent components --- cmd/entire/cli/agent/iflow/hooks_test.go | 335 +++++++++++++ cmd/entire/cli/agent/iflow/iflow_test.go | 202 ++++++++ cmd/entire/cli/agent/iflow/lifecycle_test.go | 292 ++++++++++++ cmd/entire/cli/agent/iflow/transcript_test.go | 441 ++++++++++++++++++ cmd/entire/cli/agent/registry.go | 2 + cmd/entire/cli/hooks_cmd.go | 1 + 6 files changed, 1273 insertions(+) create mode 100644 cmd/entire/cli/agent/iflow/hooks_test.go create mode 100644 cmd/entire/cli/agent/iflow/iflow_test.go create mode 100644 cmd/entire/cli/agent/iflow/lifecycle_test.go create mode 100644 cmd/entire/cli/agent/iflow/transcript_test.go diff --git a/cmd/entire/cli/agent/iflow/hooks_test.go b/cmd/entire/cli/agent/iflow/hooks_test.go new file mode 100644 index 000000000..e8cbd6b71 --- /dev/null +++ b/cmd/entire/cli/agent/iflow/hooks_test.go @@ -0,0 +1,335 @@ +package iflow + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestInstallHooks(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + // Create .iflow directory + if err := os.MkdirAll(filepath.Join(dir, ".iflow"), 0o755); err != nil { + t.Fatal(err) + } + + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + ctx := context.Background() + + // Install hooks + count, err := ag.InstallHooks(ctx, false, false) + if err != nil { + t.Fatalf("InstallHooks failed: %v", err) + } + + if count == 0 { + t.Error("Expected hooks to be installed, got 0") + } + + // Verify settings.json was created + settingsPath := filepath.Join(dir, ".iflow", IFlowSettingsFileName) + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("Failed to read settings.json: %v", err) + } + + var settings IFlowSettings + if err := json.Unmarshal(data, &settings); err != nil { + t.Fatalf("Failed to unmarshal settings.json: %v", err) + } + + // Check that hooks were installed + if len(settings.Hooks.Stop) == 0 { + t.Error("Expected Stop hooks to be installed") + } + if len(settings.Hooks.SessionStart) == 0 { + t.Error("Expected SessionStart hooks to be installed") + } + if len(settings.Hooks.PreToolUse) == 0 { + t.Error("Expected PreToolUse hooks to be installed") + } + + // Check permissions + if len(settings.Permissions.Deny) == 0 { + t.Error("Expected permissions.deny to be set") + } +} + +func TestInstallHooks_Idempotent(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + if err := os.MkdirAll(filepath.Join(dir, ".iflow"), 0o755); err != nil { + t.Fatal(err) + } + + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + ctx := context.Background() + + // Install hooks first time + count1, err := ag.InstallHooks(ctx, false, false) + if err != nil { + t.Fatalf("First InstallHooks failed: %v", err) + } + if count1 == 0 { + t.Error("Expected hooks to be installed on first run") + } + + // Install hooks second time (should be idempotent) + count2, err := ag.InstallHooks(ctx, false, false) + if err != nil { + t.Fatalf("Second InstallHooks failed: %v", err) + } + if count2 != 0 { + t.Errorf("Expected 0 hooks on second install, got %d", count2) + } +} + +func TestInstallHooks_Force(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + if err := os.MkdirAll(filepath.Join(dir, ".iflow"), 0o755); err != nil { + t.Fatal(err) + } + + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + ctx := context.Background() + + // Install hooks first + _, err := ag.InstallHooks(ctx, false, false) + if err != nil { + t.Fatalf("First InstallHooks failed: %v", err) + } + + // Force reinstall + count, err := ag.InstallHooks(ctx, false, true) + if err != nil { + t.Fatalf("Force InstallHooks failed: %v", err) + } + if count == 0 { + t.Error("Expected hooks to be reinstalled with force=true") + } +} + +func TestUninstallHooks(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + if err := os.MkdirAll(filepath.Join(dir, ".iflow"), 0o755); err != nil { + t.Fatal(err) + } + + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + ctx := context.Background() + + // Install hooks + _, err := ag.InstallHooks(ctx, false, false) + if err != nil { + t.Fatalf("InstallHooks failed: %v", err) + } + + // Verify hooks are installed + if !ag.AreHooksInstalled(ctx) { + t.Error("Expected hooks to be installed") + } + + // Uninstall hooks + if err := ag.UninstallHooks(ctx); err != nil { + t.Fatalf("UninstallHooks failed: %v", err) + } + + // Verify hooks are not installed + if ag.AreHooksInstalled(ctx) { + t.Error("Expected hooks to be uninstalled") + } +} + +func TestAreHooksInstalled(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + ctx := context.Background() + + // Initially no hooks + if ag.AreHooksInstalled(ctx) { + t.Error("Expected no hooks initially") + } + + // Create .iflow directory and install hooks + if err := os.MkdirAll(filepath.Join(dir, ".iflow"), 0o755); err != nil { + t.Fatal(err) + } + + _, err := ag.InstallHooks(ctx, false, false) + if err != nil { + t.Fatalf("InstallHooks failed: %v", err) + } + + // Now hooks should be installed + if !ag.AreHooksInstalled(ctx) { + t.Error("Expected hooks to be detected") + } +} + +func TestHookNames(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + names := ag.HookNames() + + expected := []string{ + HookNamePreToolUse, + HookNamePostToolUse, + HookNameSetUpEnvironment, + HookNameStop, + HookNameSubagentStop, + HookNameSessionStart, + HookNameSessionEnd, + HookNameUserPromptSubmit, + HookNameNotification, + } + + if len(names) != len(expected) { + t.Errorf("Expected %d hook names, got %d", len(expected), len(names)) + } + + for i, name := range expected { + if i >= len(names) || names[i] != name { + t.Errorf("Expected hook name %q at position %d, got %q", name, i, names[i]) + } + } +} + +func TestIsEntireHook(t *testing.T) { + tests := []struct { + command string + expected bool + }{ + {"entire hooks iflow stop", true}, + {"go run ${IFLOW_PROJECT_DIR}/cmd/entire/main.go hooks iflow stop", true}, + {"some other command", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + result := isEntireHook(tt.command) + if result != tt.expected { + t.Errorf("isEntireHook(%q) = %v, want %v", tt.command, result, tt.expected) + } + }) + } +} + +func TestAddHookMatcher(t *testing.T) { + tests := []struct { + name string + initial []IFlowHookMatcher + matcherPattern string + command string + expectedLen int + }{ + { + name: "add to empty", + initial: []IFlowHookMatcher{}, + matcherPattern: "*", + command: "test command", + expectedLen: 1, + }, + { + name: "add to existing matcher", + initial: []IFlowHookMatcher{ + {Matcher: "*", Hooks: []IFlowHookEntry{{Type: "command", Command: "existing"}}}, + }, + matcherPattern: "*", + command: "new command", + expectedLen: 1, + }, + { + name: "add new matcher", + initial: []IFlowHookMatcher{ + {Matcher: "Edit", Hooks: []IFlowHookEntry{{Type: "command", Command: "edit hook"}}}, + }, + matcherPattern: "Write", + command: "write hook", + expectedLen: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := addHookMatcher(tt.initial, tt.matcherPattern, tt.command) + if len(result) != tt.expectedLen { + t.Errorf("Expected %d matchers, got %d", tt.expectedLen, len(result)) + } + }) + } +} + +func TestHookMatcherExists(t *testing.T) { + matchers := []IFlowHookMatcher{ + { + Matcher: "*", + Hooks: []IFlowHookEntry{ + {Type: "command", Command: "entire hooks iflow stop"}, + }, + }, + } + + tests := []struct { + matcherPattern string + command string + expected bool + }{ + {"*", "entire hooks iflow stop", true}, + {"*", "other command", false}, + {"Edit", "entire hooks iflow stop", false}, + } + + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + result := hookMatcherExists(matchers, tt.matcherPattern, tt.command) + if result != tt.expected { + t.Errorf("hookMatcherExists(%q, %q) = %v, want %v", tt.matcherPattern, tt.command, result, tt.expected) + } + }) + } +} + +func TestRemoveEntireHooks(t *testing.T) { + matchers := []IFlowHookMatcher{ + { + Matcher: "*", + Hooks: []IFlowHookEntry{ + {Type: "command", Command: "entire hooks iflow stop"}, + {Type: "command", Command: "other hook"}, + }, + }, + { + Matcher: "Edit", + Hooks: []IFlowHookEntry{ + {Type: "command", Command: "entire hooks iflow pre-tool-use"}, + }, + }, + } + + result := removeEntireHooks(matchers) + + // Should have 1 matcher (Edit matcher removed because all hooks were Entire hooks) + if len(result) != 1 { + t.Errorf("Expected 1 matcher after removal, got %d", len(result)) + } + + // The remaining matcher should have 1 hook (other hook) + if len(result[0].Hooks) != 1 { + t.Errorf("Expected 1 hook in remaining matcher, got %d", len(result[0].Hooks)) + } + + if result[0].Hooks[0].Command != "other hook" { + t.Errorf("Expected 'other hook', got %q", result[0].Hooks[0].Command) + } +} diff --git a/cmd/entire/cli/agent/iflow/iflow_test.go b/cmd/entire/cli/agent/iflow/iflow_test.go new file mode 100644 index 000000000..1ca103a9c --- /dev/null +++ b/cmd/entire/cli/agent/iflow/iflow_test.go @@ -0,0 +1,202 @@ +package iflow + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestIFlowCLIAgent_Name(t *testing.T) { + ag := NewIFlowCLIAgent() + if ag.Name() != agent.AgentNameIFlow { + t.Errorf("Expected name %q, got %q", agent.AgentNameIFlow, ag.Name()) + } +} + +func TestIFlowCLIAgent_Type(t *testing.T) { + ag := NewIFlowCLIAgent() + if ag.Type() != agent.AgentTypeIFlow { + t.Errorf("Expected type %q, got %q", agent.AgentTypeIFlow, ag.Type()) + } +} + +func TestIFlowCLIAgent_Description(t *testing.T) { + ag := NewIFlowCLIAgent() + expected := "iFlow CLI - Alibaba's AI coding assistant" + if ag.Description() != expected { + t.Errorf("Expected description %q, got %q", expected, ag.Description()) + } +} + +func TestIFlowCLIAgent_IsPreview(t *testing.T) { + ag := NewIFlowCLIAgent() + if !ag.IsPreview() { + t.Error("Expected IsPreview to return true") + } +} + +func TestIFlowCLIAgent_ProtectedDirs(t *testing.T) { + ag := NewIFlowCLIAgent() + dirs := ag.ProtectedDirs() + if len(dirs) != 1 || dirs[0] != ".iflow" { + t.Errorf("Expected protected dirs [.iflow], got %v", dirs) + } +} + +func TestIFlowCLIAgent_DetectPresence(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + expected bool + }{ + { + name: "detects .iflow directory", + setup: func(t *testing.T) string { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, ".iflow"), 0o755); err != nil { + t.Fatal(err) + } + return dir + }, + expected: true, + }, + { + name: "detects settings.json", + setup: func(t *testing.T) string { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, ".iflow"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".iflow", "settings.json"), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + return dir + }, + expected: true, + }, + { + name: "no iFlow config", + setup: func(t *testing.T) string { + return t.TempDir() + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setup(t) + t.Chdir(dir) + + ag := NewIFlowCLIAgent() + present, err := ag.DetectPresence(context.Background()) + if err != nil { + t.Fatalf("DetectPresence failed: %v", err) + } + if present != tt.expected { + t.Errorf("Expected DetectPresence=%v, got %v", tt.expected, present) + } + }) + } +} + +func TestIFlowCLIAgent_FormatResumeCommand(t *testing.T) { + ag := NewIFlowCLIAgent() + sessionID := "test-session-123" + cmd := ag.FormatResumeCommand(sessionID) + expected := "iflow -r test-session-123" + if cmd != expected { + t.Errorf("Expected resume command %q, got %q", expected, cmd) + } +} + +func TestSanitizePathForIFlow(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"/home/user/project", "-home-user-project"}, + {"my-project", "my-project"}, + {"my_project", "my-project"}, + {"my.project", "my-project"}, + {"my@project", "my-project"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := SanitizePathForIFlow(tt.input) + if result != tt.expected { + t.Errorf("SanitizePathForIFlow(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestIFlowCLIAgent_ResolveSessionFile(t *testing.T) { + ag := NewIFlowCLIAgent() + sessionDir := "/home/user/.iflow/projects/my-project" + sessionID := "test-session" + + path := ag.ResolveSessionFile(sessionDir, sessionID) + expected := filepath.Join(sessionDir, "test-session.jsonl") + if path != expected { + t.Errorf("Expected session file path %q, got %q", expected, path) + } +} + +func TestIFlowCLIAgent_GetSessionID(t *testing.T) { + ag := NewIFlowCLIAgent() + input := &agent.HookInput{ + SessionID: "test-session-id", + } + + sessionID := ag.GetSessionID(input) + if sessionID != "test-session-id" { + t.Errorf("Expected session ID %q, got %q", "test-session-id", sessionID) + } +} + +// Test interface compliance +func TestIFlowCLIAgent_ImplementsAgent(t *testing.T) { + var _ agent.Agent = (*IFlowCLIAgent)(nil) +} + +func TestIFlowCLIAgent_ImplementsHookSupport(t *testing.T) { + var _ agent.HookSupport = (*IFlowCLIAgent)(nil) +} + +func TestIFlowCLIAgent_ImplementsTranscriptAnalyzer(t *testing.T) { + var _ agent.TranscriptAnalyzer = (*IFlowCLIAgent)(nil) +} + +func TestIFlowCLIAgent_ImplementsHookResponseWriter(t *testing.T) { + var _ agent.HookResponseWriter = (*IFlowCLIAgent)(nil) +} + +// Ensure agent is registered +func TestIFlowCLIAgent_Registered(t *testing.T) { + ag, err := agent.Get(agent.AgentNameIFlow) + if err != nil { + t.Fatalf("iFlow agent not registered: %v", err) + } + if ag == nil { + t.Fatal("iFlow agent is nil") + } + if ag.Name() != agent.AgentNameIFlow { + t.Errorf("Expected name %q, got %q", agent.AgentNameIFlow, ag.Name()) + } +} + +// Test GetByAgentType +func TestGetByAgentType_IFlow(t *testing.T) { + ag, err := agent.GetByAgentType(agent.AgentTypeIFlow) + if err != nil { + t.Fatalf("Failed to get iFlow agent by type: %v", err) + } + if ag.Type() != agent.AgentTypeIFlow { + t.Errorf("Expected type %q, got %q", agent.AgentTypeIFlow, ag.Type()) + } +} diff --git a/cmd/entire/cli/agent/iflow/lifecycle_test.go b/cmd/entire/cli/agent/iflow/lifecycle_test.go new file mode 100644 index 000000000..42f2c95f5 --- /dev/null +++ b/cmd/entire/cli/agent/iflow/lifecycle_test.go @@ -0,0 +1,292 @@ +package iflow + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestParseSessionStart(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + + tests := []struct { + name string + input map[string]interface{} + expected agent.EventType + wantErr bool + }{ + { + name: "valid session start", + input: map[string]interface{}{ + "session_id": "test-session", + "cwd": "/home/user/project", + "hook_event_name": "SessionStart", + "transcript_path": "/home/user/.iflow/projects/test-session.jsonl", + "source": "startup", + "model": "glm-5", + }, + expected: agent.SessionStart, + wantErr: false, + }, + { + name: "session start without model", + input: map[string]interface{}{ + "session_id": "test-session", + "cwd": "/home/user/project", + "hook_event_name": "SessionStart", + }, + expected: agent.SessionStart, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, _ := json.Marshal(tt.input) + stdin := strings.NewReader(string(data)) + + event, err := ag.ParseHookEvent(context.Background(), HookNameSessionStart, stdin) + if (err != nil) != tt.wantErr { + t.Errorf("ParseHookEvent() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil { + return + } + + if event.Type != tt.expected { + t.Errorf("Expected event type %v, got %v", tt.expected, event.Type) + } + + if event.SessionID != tt.input["session_id"] { + t.Errorf("Expected session ID %q, got %q", tt.input["session_id"], event.SessionID) + } + + if model, ok := tt.input["model"].(string); ok && event.Model != model { + t.Errorf("Expected model %q, got %q", model, event.Model) + } + }) + } +} + +func TestParseUserPromptSubmit(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + + input := map[string]interface{}{ + "session_id": "test-session", + "cwd": "/home/user/project", + "hook_event_name": "UserPromptSubmit", + "transcript_path": "/home/user/.iflow/projects/test-session.jsonl", + "prompt": "Write a function to calculate fibonacci numbers", + } + + data, _ := json.Marshal(input) + stdin := strings.NewReader(string(data)) + + event, err := ag.ParseHookEvent(context.Background(), HookNameUserPromptSubmit, stdin) + if err != nil { + t.Fatalf("ParseHookEvent failed: %v", err) + } + + if event.Type != agent.TurnStart { + t.Errorf("Expected event type TurnStart, got %v", event.Type) + } + + if event.Prompt != input["prompt"] { + t.Errorf("Expected prompt %q, got %q", input["prompt"], event.Prompt) + } +} + +func TestParseStop(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + + tests := []struct { + name string + input map[string]interface{} + expectedType agent.EventType + expectedDur int64 + expectedTurns int + }{ + { + name: "stop with metrics", + input: map[string]interface{}{ + "session_id": "test-session", + "cwd": "/home/user/project", + "hook_event_name": "Stop", + "transcript_path": "/home/user/.iflow/projects/test-session.jsonl", + "duration_ms": 5000, + "turn_count": 3, + }, + expectedType: agent.TurnEnd, + expectedDur: 5000, + expectedTurns: 3, + }, + { + name: "stop without metrics", + input: map[string]interface{}{ + "session_id": "test-session", + "cwd": "/home/user/project", + "hook_event_name": "Stop", + }, + expectedType: agent.TurnEnd, + expectedDur: 0, + expectedTurns: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, _ := json.Marshal(tt.input) + stdin := strings.NewReader(string(data)) + + event, err := ag.ParseHookEvent(context.Background(), HookNameStop, stdin) + if err != nil { + t.Fatalf("ParseHookEvent failed: %v", err) + } + + if event.Type != tt.expectedType { + t.Errorf("Expected event type %v, got %v", tt.expectedType, event.Type) + } + + if event.DurationMs != tt.expectedDur { + t.Errorf("Expected duration %d, got %d", tt.expectedDur, event.DurationMs) + } + + if event.TurnCount != tt.expectedTurns { + t.Errorf("Expected turn count %d, got %d", tt.expectedTurns, event.TurnCount) + } + }) + } +} + +func TestParseSessionEnd(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + + input := map[string]interface{}{ + "session_id": "test-session", + "cwd": "/home/user/project", + "hook_event_name": "SessionEnd", + "transcript_path": "/home/user/.iflow/projects/test-session.jsonl", + } + + data, _ := json.Marshal(input) + stdin := strings.NewReader(string(data)) + + event, err := ag.ParseHookEvent(context.Background(), HookNameSessionEnd, stdin) + if err != nil { + t.Fatalf("ParseHookEvent failed: %v", err) + } + + if event.Type != agent.SessionEnd { + t.Errorf("Expected event type SessionEnd, got %v", event.Type) + } + + if event.SessionID != input["session_id"] { + t.Errorf("Expected session ID %q, got %q", input["session_id"], event.SessionID) + } +} + +func TestParseSubagentStop(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + + input := map[string]interface{}{ + "session_id": "test-session", + "cwd": "/home/user/project", + "hook_event_name": "SubagentStop", + "transcript_path": "/home/user/.iflow/projects/test-session.jsonl", + "subagent_id": "subagent-123", + "duration_ms": 2000, + } + + data, _ := json.Marshal(input) + stdin := strings.NewReader(string(data)) + + event, err := ag.ParseHookEvent(context.Background(), HookNameSubagentStop, stdin) + if err != nil { + t.Fatalf("ParseHookEvent failed: %v", err) + } + + if event.Type != agent.SubagentEnd { + t.Errorf("Expected event type SubagentEnd, got %v", event.Type) + } + + if event.SubagentID != input["subagent_id"] { + t.Errorf("Expected subagent ID %q, got %q", input["subagent_id"], event.SubagentID) + } + + if event.DurationMs != int64(input["duration_ms"].(int)) { + t.Errorf("Expected duration %d, got %d", input["duration_ms"], event.DurationMs) + } +} + +func TestParseHookEvent_UnknownHook(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + + // Unknown hooks should return nil, nil + event, err := ag.ParseHookEvent(context.Background(), "unknown-hook", strings.NewReader("{}")) + if err != nil { + t.Errorf("Expected no error for unknown hook, got %v", err) + } + if event != nil { + t.Errorf("Expected nil event for unknown hook, got %v", event) + } +} + +func TestParseHookEvent_NonLifecycleHooks(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + + // SetUpEnvironment and Notification should return nil, nil + nonLifecycleHooks := []string{HookNameSetUpEnvironment, HookNameNotification} + + for _, hookName := range nonLifecycleHooks { + t.Run(hookName, func(t *testing.T) { + event, err := ag.ParseHookEvent(context.Background(), hookName, strings.NewReader("{}")) + if err != nil { + t.Errorf("Expected no error for %s, got %v", hookName, err) + } + if event != nil { + t.Errorf("Expected nil event for %s, got %v", hookName, event) + } + }) + } +} + +func TestWriteHookResponse(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + + // This test just verifies the function doesn't panic + // In a real test, we'd capture stdout + err := ag.WriteHookResponse("Test message") + if err != nil { + t.Errorf("WriteHookResponse failed: %v", err) + } +} + +func TestEventTimestamp(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + + input := map[string]interface{}{ + "session_id": "test-session", + "cwd": "/home/user/project", + "hook_event_name": "SessionStart", + } + + data, _ := json.Marshal(input) + stdin := strings.NewReader(string(data)) + + before := time.Now() + event, err := ag.ParseHookEvent(context.Background(), HookNameSessionStart, stdin) + after := time.Now() + + if err != nil { + t.Fatalf("ParseHookEvent failed: %v", err) + } + + if event.Timestamp.Before(before) || event.Timestamp.After(after) { + t.Error("Event timestamp is not within expected range") + } +} diff --git a/cmd/entire/cli/agent/iflow/transcript_test.go b/cmd/entire/cli/agent/iflow/transcript_test.go new file mode 100644 index 000000000..3fbd1de57 --- /dev/null +++ b/cmd/entire/cli/agent/iflow/transcript_test.go @@ -0,0 +1,441 @@ +package iflow + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestParseTranscriptFromBytes(t *testing.T) { + transcript := `{"type": "user", "timestamp": "2024-01-01T00:00:00Z", "message": {}} +{"type": "assistant", "timestamp": "2024-01-01T00:00:01Z", "message": {}} +{"type": "tool_use", "timestamp": "2024-01-01T00:00:02Z", "tool_use": {"id": "tool-1", "name": "write_file", "input": {"file_path": "test.txt"}}}` + + lines, err := ParseTranscriptFromBytes([]byte(transcript)) + if err != nil { + t.Fatalf("ParseTranscriptFromBytes failed: %v", err) + } + + if len(lines) != 3 { + t.Errorf("Expected 3 lines, got %d", len(lines)) + } + + if lines[2].ToolUse == nil { + t.Fatal("Expected tool use on line 3") + } + + if lines[2].ToolUse.Name != "write_file" { + t.Errorf("Expected tool name 'write_file', got %q", lines[2].ToolUse.Name) + } +} + +func TestParseTranscriptFromBytes_Empty(t *testing.T) { + lines, err := ParseTranscriptFromBytes([]byte("")) + if err != nil { + t.Fatalf("ParseTranscriptFromBytes failed: %v", err) + } + + if len(lines) != 0 { + t.Errorf("Expected 0 lines for empty input, got %d", len(lines)) + } +} + +func TestParseTranscriptFromBytes_InvalidJSON(t *testing.T) { + // Invalid JSON lines should be skipped + transcript := `{"type": "user", "timestamp": "2024-01-01T00:00:00Z"} +invalid json line +{"type": "assistant", "timestamp": "2024-01-01T00:00:01Z"}` + + lines, err := ParseTranscriptFromBytes([]byte(transcript)) + if err != nil { + t.Fatalf("ParseTranscriptFromBytes failed: %v", err) + } + + if len(lines) != 2 { + t.Errorf("Expected 2 valid lines, got %d", len(lines)) + } +} + +func TestExtractModifiedFiles(t *testing.T) { + lines := []TranscriptLine{ + { + Type: "tool_use", + Timestamp: "2024-01-01T00:00:00Z", + ToolUse: &ToolUse{ + ID: "tool-1", + Name: "write_file", + Input: json.RawMessage(`{"file_path": "file1.txt"}`), + }, + }, + { + Type: "tool_use", + Timestamp: "2024-01-01T00:00:01Z", + ToolUse: &ToolUse{ + ID: "tool-2", + Name: "replace", + Input: json.RawMessage(`{"path": "file2.txt"}`), + }, + }, + { + Type: "tool_use", + Timestamp: "2024-01-01T00:00:02Z", + ToolUse: &ToolUse{ + ID: "tool-3", + Name: "read_file", + Input: json.RawMessage(`{"file_path": "file3.txt"}`), + }, + }, + } + + files := ExtractModifiedFiles(lines) + + if len(files) != 2 { + t.Errorf("Expected 2 modified files, got %d", len(files)) + } + + // Check that file1.txt and file2.txt are in the list + found := make(map[string]bool) + for _, f := range files { + found[f] = true + } + + if !found["file1.txt"] { + t.Error("Expected file1.txt in modified files") + } + if !found["file2.txt"] { + t.Error("Expected file2.txt in modified files") + } + if found["file3.txt"] { + t.Error("file3.txt should not be in modified files (read_file is not a modification tool)") + } +} + +func TestExtractModifiedFiles_Duplicates(t *testing.T) { + lines := []TranscriptLine{ + { + Type: "tool_use", + Timestamp: "2024-01-01T00:00:00Z", + ToolUse: &ToolUse{ + ID: "tool-1", + Name: "write_file", + Input: json.RawMessage(`{"file_path": "same.txt"}`), + }, + }, + { + Type: "tool_use", + Timestamp: "2024-01-01T00:00:01Z", + ToolUse: &ToolUse{ + ID: "tool-2", + Name: "write_file", + Input: json.RawMessage(`{"file_path": "same.txt"}`), + }, + }, + } + + files := ExtractModifiedFiles(lines) + + if len(files) != 1 { + t.Errorf("Expected 1 unique file, got %d", len(files)) + } + + if files[0] != "same.txt" { + t.Errorf("Expected 'same.txt', got %q", files[0]) + } +} + +func TestIsFileModificationTool(t *testing.T) { + tests := []struct { + toolName string + expected bool + }{ + {"write_file", true}, + {"replace", true}, + {"multi_edit", true}, + {"read_file", false}, + {"run_shell_command", false}, + {"list_directory", false}, + } + + for _, tt := range tests { + t.Run(tt.toolName, func(t *testing.T) { + result := isFileModificationTool(tt.toolName) + if result != tt.expected { + t.Errorf("isFileModificationTool(%q) = %v, want %v", tt.toolName, result, tt.expected) + } + }) + } +} + +func TestExtractFilePathFromToolInput(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + expected string + }{ + { + name: "file_path field", + input: map[string]interface{}{"file_path": "test.txt"}, + expected: "test.txt", + }, + { + name: "path field", + input: map[string]interface{}{"path": "src/main.go"}, + expected: "src/main.go", + }, + { + name: "filepath field", + input: map[string]interface{}{"filepath": "docs/readme.md"}, + expected: "docs/readme.md", + }, + { + name: "no file path", + input: map[string]interface{}{"content": "hello"}, + expected: "", + }, + { + name: "empty input", + input: map[string]interface{}{}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, _ := json.Marshal(tt.input) + result := extractFilePathFromToolInput(data) + if result != tt.expected { + t.Errorf("extractFilePathFromToolInput() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestSerializeTranscript(t *testing.T) { + lines := []TranscriptLine{ + { + Type: "user", + Timestamp: "2024-01-01T00:00:00Z", + }, + { + Type: "assistant", + Timestamp: "2024-01-01T00:00:01Z", + }, + } + + data, err := SerializeTranscript(lines) + if err != nil { + t.Fatalf("SerializeTranscript failed: %v", err) + } + + // Parse it back to verify + parsed, err := ParseTranscriptFromBytes(data) + if err != nil { + t.Fatalf("ParseTranscriptFromBytes failed: %v", err) + } + + if len(parsed) != 2 { + t.Errorf("Expected 2 lines after round-trip, got %d", len(parsed)) + } +} + +func TestFindCheckpointLine(t *testing.T) { + lines := []TranscriptLine{ + {Type: "user", Timestamp: "2024-01-01T00:00:00Z"}, + { + Type: "tool_result", + Timestamp: "2024-01-01T00:00:01Z", + ToolResult: &ToolResult{ToolUseID: "tool-1"}, + }, + {Type: "assistant", Timestamp: "2024-01-01T00:00:02Z"}, + { + Type: "tool_result", + Timestamp: "2024-01-01T00:00:03Z", + ToolResult: &ToolResult{ToolUseID: "tool-2"}, + }, + } + + index, found := FindCheckpointLine(lines, "tool-2") + if !found { + t.Error("Expected to find tool-2") + } + if index != 3 { + t.Errorf("Expected index 3, got %d", index) + } + + _, found = FindCheckpointLine(lines, "non-existent") + if found { + t.Error("Expected not to find non-existent tool") + } +} + +func TestTruncateAtLine(t *testing.T) { + lines := []TranscriptLine{ + {Type: "user", Timestamp: "2024-01-01T00:00:00Z"}, + {Type: "assistant", Timestamp: "2024-01-01T00:00:01Z"}, + {Type: "user", Timestamp: "2024-01-01T00:00:02Z"}, + } + + truncated := TruncateAtLine(lines, 1) + if len(truncated) != 2 { + t.Errorf("Expected 2 lines after truncate, got %d", len(truncated)) + } + + // Test out of bounds + truncated = TruncateAtLine(lines, 10) + if len(truncated) != 3 { + t.Errorf("Expected original lines for out of bounds, got %d", len(truncated)) + } +} + +func TestReadTranscriptFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.jsonl") + + content := `{"type": "user", "timestamp": "2024-01-01T00:00:00Z"} +{"type": "assistant", "timestamp": "2024-01-01T00:00:01Z"}` + + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + lines, err := ReadTranscriptFile(path) + if err != nil { + t.Fatalf("ReadTranscriptFile failed: %v", err) + } + + if len(lines) != 2 { + t.Errorf("Expected 2 lines, got %d", len(lines)) + } +} + +func TestWriteTranscriptFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.jsonl") + + lines := []TranscriptLine{ + {Type: "user", Timestamp: "2024-01-01T00:00:00Z"}, + {Type: "assistant", Timestamp: "2024-01-01T00:00:01Z"}, + } + + if err := WriteTranscriptFile(path, lines); err != nil { + t.Fatalf("WriteTranscriptFile failed: %v", err) + } + + // Read it back + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + parsed, err := ParseTranscriptFromBytes(data) + if err != nil { + t.Fatalf("ParseTranscriptFromBytes failed: %v", err) + } + + if len(parsed) != 2 { + t.Errorf("Expected 2 lines after write/read, got %d", len(parsed)) + } +} + +func TestMergeTranscripts(t *testing.T) { + transcript1 := []TranscriptLine{ + {Type: "user", Timestamp: "2024-01-01T00:00:00Z"}, + {Type: "assistant", Timestamp: "2024-01-01T00:00:01Z"}, + } + + transcript2 := []TranscriptLine{ + {Type: "assistant", Timestamp: "2024-01-01T00:00:01Z"}, // Duplicate + {Type: "user", Timestamp: "2024-01-01T00:00:02Z"}, + } + + merged := MergeTranscripts([][]TranscriptLine{transcript1, transcript2}) + + if len(merged) != 3 { + t.Errorf("Expected 3 unique lines after merge, got %d", len(merged)) + } +} + +func TestGetLastToolUse(t *testing.T) { + lines := []TranscriptLine{ + {Type: "user", Timestamp: "2024-01-01T00:00:00Z"}, + { + Type: "tool_use", + Timestamp: "2024-01-01T00:00:01Z", + ToolUse: &ToolUse{ID: "tool-1", Name: "write_file"}, + }, + {Type: "assistant", Timestamp: "2024-01-01T00:00:02Z"}, + } + + toolUse := GetLastToolUse(lines) + if toolUse == nil { + t.Fatal("Expected to find last tool use") + } + if toolUse.ID != "tool-1" { + t.Errorf("Expected tool-1, got %q", toolUse.ID) + } +} + +func TestGetToolResultForUse(t *testing.T) { + lines := []TranscriptLine{ + { + Type: "tool_use", + Timestamp: "2024-01-01T00:00:00Z", + ToolUse: &ToolUse{ID: "tool-1", Name: "write_file"}, + }, + { + Type: "tool_result", + Timestamp: "2024-01-01T00:00:01Z", + ToolResult: &ToolResult{ToolUseID: "tool-1"}, + }, + } + + result := GetToolResultForUse(lines, "tool-1") + if result == nil { + t.Fatal("Expected to find tool result") + } + if result.ToolUseID != "tool-1" { + t.Errorf("Expected tool_use_id tool-1, got %q", result.ToolUseID) + } + + result = GetToolResultForUse(lines, "non-existent") + if result != nil { + t.Error("Expected nil for non-existent tool use") + } +} + +func TestCountToolUses(t *testing.T) { + lines := []TranscriptLine{ + {Type: "user", Timestamp: "2024-01-01T00:00:00Z"}, + { + Type: "tool_use", + Timestamp: "2024-01-01T00:00:01Z", + ToolUse: &ToolUse{ID: "tool-1", Name: "write_file"}, + }, + { + Type: "tool_use", + Timestamp: "2024-01-01T00:00:02Z", + ToolUse: &ToolUse{ID: "tool-2", Name: "write_file"}, + }, + { + Type: "tool_use", + Timestamp: "2024-01-01T00:00:03Z", + ToolUse: &ToolUse{ID: "tool-3", Name: "read_file"}, + }, + } + + count := CountToolUses(lines, "write_file") + if count != 2 { + t.Errorf("Expected 2 write_file uses, got %d", count) + } + + count = CountToolUses(lines, "read_file") + if count != 1 { + t.Errorf("Expected 1 read_file use, got %d", count) + } + + count = CountToolUses(lines, "non-existent") + if count != 0 { + t.Errorf("Expected 0 uses for non-existent tool, got %d", count) + } +} diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 500b74594..e0721287a 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -105,6 +105,7 @@ const ( AgentNameCursor types.AgentName = "cursor" AgentNameFactoryAIDroid types.AgentName = "factoryai-droid" AgentNameGemini types.AgentName = "gemini" + AgentNameIFlow types.AgentName = "iflow" AgentNameOpenCode types.AgentName = "opencode" AgentNameTraeAgent types.AgentName = "trae-agent" ) @@ -116,6 +117,7 @@ const ( AgentTypeCursor types.AgentType = "Cursor" AgentTypeFactoryAIDroid types.AgentType = "Factory AI Droid" AgentTypeGemini types.AgentType = "Gemini CLI" + AgentTypeIFlow types.AgentType = "iFlow CLI" AgentTypeOpenCode types.AgentType = "OpenCode" AgentTypeTraeAgent types.AgentType = "Trae Agent" AgentTypeUnknown types.AgentType = "Agent" // Fallback for backwards compatibility diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index 7ba5f05fe..e5178e7e6 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -14,6 +14,7 @@ import ( _ "github.com/entireio/cli/cmd/entire/cli/agent/cursor" _ "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + _ "github.com/entireio/cli/cmd/entire/cli/agent/iflow" _ "github.com/entireio/cli/cmd/entire/cli/agent/opencode" _ "github.com/entireio/cli/cmd/entire/cli/agent/traeagent" _ "github.com/entireio/cli/cmd/entire/cli/agent/vogon" From 4a7e7b9466ea6487b81fd5a3173a515ae5360785 Mon Sep 17 00:00:00 2001 From: WangxuMarshall Date: Sat, 7 Mar 2026 15:10:12 +0800 Subject: [PATCH 05/15] chore: add CLAUDE.md to gitignore CLAUDE.md is a local development file that should not be committed. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 19540465c..84f8cc252 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # +CLAUDE.md # Binaries for programs and plugins *.exe *.exe~ From 4e58562130451b4f45a31b8c14e143ff6585720b Mon Sep 17 00:00:00 2001 From: WangxuMarshall Date: Mon, 9 Mar 2026 18:17:29 +0800 Subject: [PATCH 06/15] feat(iflow): enhance tests and types for hook parsing - Add comprehensive lifecycle tests for all hook types - Improve type definitions with better JSON tags - Add WriteHookResponse implementation - Add transcript parsing tests --- cmd/entire/cli/agent/iflow/iflow.go | 34 ++++ cmd/entire/cli/agent/iflow/iflow_test.go | 114 +++++++++++- cmd/entire/cli/agent/iflow/lifecycle.go | 1 + cmd/entire/cli/agent/iflow/lifecycle_test.go | 163 ++++++++++++++++++ cmd/entire/cli/agent/iflow/transcript_test.go | 32 ++-- cmd/entire/cli/agent/iflow/types.go | 47 +++-- 6 files changed, 357 insertions(+), 34 deletions(-) diff --git a/cmd/entire/cli/agent/iflow/iflow.go b/cmd/entire/cli/agent/iflow/iflow.go index 9b5062411..213a79e0b 100644 --- a/cmd/entire/cli/agent/iflow/iflow.go +++ b/cmd/entire/cli/agent/iflow/iflow.go @@ -271,3 +271,37 @@ var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`) func SanitizePathForIFlow(path string) string { return nonAlphanumericRegex.ReplaceAllString(path, "-") } + +// CalculateTokenUsage computes token usage from the transcript starting at the given line offset. +// iFlow transcripts are JSONL format where each line may contain token usage data. +// If token data is not available in the transcript, returns empty TokenUsage. +func (i *IFlowCLIAgent) CalculateTokenUsage(transcriptData []byte, fromOffset int) (*agent.TokenUsage, error) { + lines, err := ParseTranscriptFromBytes(transcriptData) + if err != nil { + return &agent.TokenUsage{}, fmt.Errorf("failed to parse transcript for token usage: %w", err) + } + + usage := &agent.TokenUsage{} + + // Skip lines before fromOffset + for idx := fromOffset; idx < len(lines); idx++ { + line := lines[idx] + + // Only count tokens from assistant messages (type "assistant" or "gemini") + // iFlow may use different message types + if line.Type != "assistant" && line.Type != "gemini" && line.Type != "ai" { + continue + } + + if line.Tokens == nil { + continue + } + + usage.APICallCount++ + usage.InputTokens += line.Tokens.Input + usage.OutputTokens += line.Tokens.Output + usage.CacheReadTokens += line.Tokens.Cached + } + + return usage, nil +} diff --git a/cmd/entire/cli/agent/iflow/iflow_test.go b/cmd/entire/cli/agent/iflow/iflow_test.go index 1ca103a9c..5c92ec40f 100644 --- a/cmd/entire/cli/agent/iflow/iflow_test.go +++ b/cmd/entire/cli/agent/iflow/iflow_test.go @@ -139,7 +139,7 @@ func TestIFlowCLIAgent_ResolveSessionFile(t *testing.T) { ag := NewIFlowCLIAgent() sessionDir := "/home/user/.iflow/projects/my-project" sessionID := "test-session" - + path := ag.ResolveSessionFile(sessionDir, sessionID) expected := filepath.Join(sessionDir, "test-session.jsonl") if path != expected { @@ -152,7 +152,7 @@ func TestIFlowCLIAgent_GetSessionID(t *testing.T) { input := &agent.HookInput{ SessionID: "test-session-id", } - + sessionID := ag.GetSessionID(input) if sessionID != "test-session-id" { t.Errorf("Expected session ID %q, got %q", "test-session-id", sessionID) @@ -200,3 +200,113 @@ func TestGetByAgentType_IFlow(t *testing.T) { t.Errorf("Expected type %q, got %q", agent.AgentTypeIFlow, ag.Type()) } } + +func TestIFlowCLIAgent_ImplementsTokenCalculator(t *testing.T) { + var _ agent.TokenCalculator = (*IFlowCLIAgent)(nil) +} + +func TestCalculateTokenUsage(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + + tests := []struct { + name string + transcript string + fromOffset int + expectUsage *agent.TokenUsage + expectErr bool + }{ + { + name: "empty transcript", + transcript: "", + fromOffset: 0, + expectUsage: &agent.TokenUsage{}, + expectErr: false, + }, + { + name: "transcript with tokens", + transcript: `{"type":"user","timestamp":"2024-01-01T00:00:00Z","message":"hello"} +{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","tokens":{"input":100,"output":50,"cached":10}} +{"type":"assistant","timestamp":"2024-01-01T00:00:02Z","tokens":{"input":200,"output":75,"cached":20}}`, + fromOffset: 0, + expectUsage: &agent.TokenUsage{ + InputTokens: 300, + OutputTokens: 125, + CacheReadTokens: 30, + APICallCount: 2, + }, + expectErr: false, + }, + { + name: "transcript with fromOffset", + transcript: `{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","tokens":{"input":100,"output":50}} +{"type":"assistant","timestamp":"2024-01-01T00:00:02Z","tokens":{"input":200,"output":75}}`, + fromOffset: 1, + expectUsage: &agent.TokenUsage{ + InputTokens: 200, + OutputTokens: 75, + APICallCount: 1, + }, + expectErr: false, + }, + { + name: "transcript without tokens", + transcript: `{"type":"user","timestamp":"2024-01-01T00:00:00Z","message":"hello"} +{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":"hi there"}`, + fromOffset: 0, + expectUsage: &agent.TokenUsage{}, + expectErr: false, + }, + { + name: "mixed message types", + transcript: `{"type":"user","timestamp":"2024-01-01T00:00:00Z","message":"hello","tokens":{"input":50}} +{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","tokens":{"input":100,"output":50}} +{"type":"ai","timestamp":"2024-01-01T00:00:02Z","tokens":{"input":200,"output":75}}`, + fromOffset: 0, + expectUsage: &agent.TokenUsage{ + InputTokens: 300, + OutputTokens: 125, + APICallCount: 2, + }, + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usage, err := ag.CalculateTokenUsage([]byte(tt.transcript), tt.fromOffset) + if (err != nil) != tt.expectErr { + t.Errorf("CalculateTokenUsage() error = %v, expectErr %v", err, tt.expectErr) + return + } + if usage.InputTokens != tt.expectUsage.InputTokens { + t.Errorf("InputTokens = %d, want %d", usage.InputTokens, tt.expectUsage.InputTokens) + } + if usage.OutputTokens != tt.expectUsage.OutputTokens { + t.Errorf("OutputTokens = %d, want %d", usage.OutputTokens, tt.expectUsage.OutputTokens) + } + if usage.CacheReadTokens != tt.expectUsage.CacheReadTokens { + t.Errorf("CacheReadTokens = %d, want %d", usage.CacheReadTokens, tt.expectUsage.CacheReadTokens) + } + if usage.APICallCount != tt.expectUsage.APICallCount { + t.Errorf("APICallCount = %d, want %d", usage.APICallCount, tt.expectUsage.APICallCount) + } + }) + } +} + +func TestCalculateTokenUsage_InvalidJSON(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + + // Invalid JSON should not cause panic, but return empty usage + transcript := `{invalid json` + usage, err := ag.CalculateTokenUsage([]byte(transcript), 0) + + // The current implementation should handle this gracefully + // ParseTranscriptFromBytes skips malformed lines + if err != nil { + t.Errorf("Expected no error for malformed JSON, got %v", err) + } + if usage == nil { + t.Error("Expected non-nil usage") + } +} diff --git a/cmd/entire/cli/agent/iflow/lifecycle.go b/cmd/entire/cli/agent/iflow/lifecycle.go index 3b676abe1..bf963e179 100644 --- a/cmd/entire/cli/agent/iflow/lifecycle.go +++ b/cmd/entire/cli/agent/iflow/lifecycle.go @@ -15,6 +15,7 @@ import ( var ( _ agent.TranscriptAnalyzer = (*IFlowCLIAgent)(nil) _ agent.HookResponseWriter = (*IFlowCLIAgent)(nil) + _ agent.TokenCalculator = (*IFlowCLIAgent)(nil) ) // WriteHookResponse outputs a JSON hook response to stdout. diff --git a/cmd/entire/cli/agent/iflow/lifecycle_test.go b/cmd/entire/cli/agent/iflow/lifecycle_test.go index 42f2c95f5..ebcd12a02 100644 --- a/cmd/entire/cli/agent/iflow/lifecycle_test.go +++ b/cmd/entire/cli/agent/iflow/lifecycle_test.go @@ -290,3 +290,166 @@ func TestEventTimestamp(t *testing.T) { t.Error("Event timestamp is not within expected range") } } + +func TestParsePreToolUse(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + + tests := []struct { + name string + input map[string]interface{} + }{ + { + name: "pre-tool-use with file_path", + input: map[string]interface{}{ + "session_id": "test-session", + "cwd": "/home/user/project", + "hook_event_name": "PreToolUse", + "transcript_path": "/home/user/.iflow/projects/test-session.jsonl", + "tool_name": "write_file", + "tool_aliases": []string{"write", "create"}, + "tool_input": map[string]interface{}{ + "file_path": "/home/user/project/test.go", + "content": "package main", + }, + }, + }, + { + name: "pre-tool-use with edit tool", + input: map[string]interface{}{ + "session_id": "test-session", + "cwd": "/home/user/project", + "hook_event_name": "PreToolUse", + "tool_name": "replace", + "tool_aliases": []string{"Edit", "edit"}, + "tool_input": map[string]interface{}{ + "file_path": "/home/user/project/main.go", + "old_string": "old", + "new_string": "new", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, _ := json.Marshal(tt.input) + stdin := strings.NewReader(string(data)) + + // PreToolUse currently returns nil (no lifecycle event) + event, err := ag.ParseHookEvent(context.Background(), HookNamePreToolUse, stdin) + if err != nil { + t.Errorf("ParseHookEvent failed: %v", err) + } + // PreToolUse doesn't generate a lifecycle event in current implementation + if event != nil { + t.Errorf("Expected nil event for PreToolUse, got %v", event) + } + }) + } +} + +func TestParsePostToolUse(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + + tests := []struct { + name string + input map[string]interface{} + }{ + { + name: "post-tool-use with response", + input: map[string]interface{}{ + "session_id": "test-session", + "cwd": "/home/user/project", + "hook_event_name": "PostToolUse", + "transcript_path": "/home/user/.iflow/projects/test-session.jsonl", + "tool_name": "write_file", + "tool_aliases": []string{"write", "create"}, + "tool_input": map[string]interface{}{ + "file_path": "/home/user/project/test.go", + "content": "package main", + }, + "tool_response": map[string]interface{}{ + "result": map[string]interface{}{ + "llmContent": "File written successfully", + }, + }, + }, + }, + { + name: "post-tool-use without response", + input: map[string]interface{}{ + "session_id": "test-session", + "cwd": "/home/user/project", + "hook_event_name": "PostToolUse", + "tool_name": "read_file", + "tool_input": map[string]interface{}{ + "file_path": "/home/user/project/main.go", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, _ := json.Marshal(tt.input) + stdin := strings.NewReader(string(data)) + + // PostToolUse currently returns nil (no lifecycle event) + event, err := ag.ParseHookEvent(context.Background(), HookNamePostToolUse, stdin) + if err != nil { + t.Errorf("ParseHookEvent failed: %v", err) + } + // PostToolUse doesn't generate a lifecycle event in current implementation + if event != nil { + t.Errorf("Expected nil event for PostToolUse, got %v", event) + } + }) + } +} + +func TestParseSetUpEnvironment(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + + input := map[string]interface{}{ + "session_id": "test-session", + "cwd": "/home/user/project", + "hook_event_name": "SetUpEnvironment", + "transcript_path": "/home/user/.iflow/projects/test-session.jsonl", + } + + data, _ := json.Marshal(input) + stdin := strings.NewReader(string(data)) + + // SetUpEnvironment returns nil (no lifecycle event) + event, err := ag.ParseHookEvent(context.Background(), HookNameSetUpEnvironment, stdin) + if err != nil { + t.Errorf("ParseHookEvent failed: %v", err) + } + if event != nil { + t.Errorf("Expected nil event for SetUpEnvironment, got %v", event) + } +} + +func TestParseNotification(t *testing.T) { + ag := NewIFlowCLIAgent().(*IFlowCLIAgent) + + input := map[string]interface{}{ + "session_id": "test-session", + "cwd": "/home/user/project", + "hook_event_name": "Notification", + "transcript_path": "/home/user/.iflow/projects/test-session.jsonl", + "message": "Permission request for file access", + } + + data, _ := json.Marshal(input) + stdin := strings.NewReader(string(data)) + + // Notification returns nil (no lifecycle event) + event, err := ag.ParseHookEvent(context.Background(), HookNameNotification, stdin) + if err != nil { + t.Errorf("ParseHookEvent failed: %v", err) + } + if event != nil { + t.Errorf("Expected nil event for Notification, got %v", event) + } +} diff --git a/cmd/entire/cli/agent/iflow/transcript_test.go b/cmd/entire/cli/agent/iflow/transcript_test.go index 3fbd1de57..5bede61cf 100644 --- a/cmd/entire/cli/agent/iflow/transcript_test.go +++ b/cmd/entire/cli/agent/iflow/transcript_test.go @@ -63,8 +63,8 @@ func TestExtractModifiedFiles(t *testing.T) { Type: "tool_use", Timestamp: "2024-01-01T00:00:00Z", ToolUse: &ToolUse{ - ID: "tool-1", - Name: "write_file", + ID: "tool-1", + Name: "write_file", Input: json.RawMessage(`{"file_path": "file1.txt"}`), }, }, @@ -72,8 +72,8 @@ func TestExtractModifiedFiles(t *testing.T) { Type: "tool_use", Timestamp: "2024-01-01T00:00:01Z", ToolUse: &ToolUse{ - ID: "tool-2", - Name: "replace", + ID: "tool-2", + Name: "replace", Input: json.RawMessage(`{"path": "file2.txt"}`), }, }, @@ -81,8 +81,8 @@ func TestExtractModifiedFiles(t *testing.T) { Type: "tool_use", Timestamp: "2024-01-01T00:00:02Z", ToolUse: &ToolUse{ - ID: "tool-3", - Name: "read_file", + ID: "tool-3", + Name: "read_file", Input: json.RawMessage(`{"file_path": "file3.txt"}`), }, }, @@ -117,8 +117,8 @@ func TestExtractModifiedFiles_Duplicates(t *testing.T) { Type: "tool_use", Timestamp: "2024-01-01T00:00:00Z", ToolUse: &ToolUse{ - ID: "tool-1", - Name: "write_file", + ID: "tool-1", + Name: "write_file", Input: json.RawMessage(`{"file_path": "same.txt"}`), }, }, @@ -126,8 +126,8 @@ func TestExtractModifiedFiles_Duplicates(t *testing.T) { Type: "tool_use", Timestamp: "2024-01-01T00:00:01Z", ToolUse: &ToolUse{ - ID: "tool-2", - Name: "write_file", + ID: "tool-2", + Name: "write_file", Input: json.RawMessage(`{"file_path": "same.txt"}`), }, }, @@ -243,15 +243,15 @@ func TestFindCheckpointLine(t *testing.T) { lines := []TranscriptLine{ {Type: "user", Timestamp: "2024-01-01T00:00:00Z"}, { - Type: "tool_result", - Timestamp: "2024-01-01T00:00:01Z", - ToolResult: &ToolResult{ToolUseID: "tool-1"}, + Type: "tool_result", + Timestamp: "2024-01-01T00:00:01Z", + ToolResult: &ToolResult{ToolUseID: "tool-1"}, }, {Type: "assistant", Timestamp: "2024-01-01T00:00:02Z"}, { - Type: "tool_result", - Timestamp: "2024-01-01T00:00:03Z", - ToolResult: &ToolResult{ToolUseID: "tool-2"}, + Type: "tool_result", + Timestamp: "2024-01-01T00:00:03Z", + ToolResult: &ToolResult{ToolUseID: "tool-2"}, }, } diff --git a/cmd/entire/cli/agent/iflow/types.go b/cmd/entire/cli/agent/iflow/types.go index 87700921d..70931a4fb 100644 --- a/cmd/entire/cli/agent/iflow/types.go +++ b/cmd/entire/cli/agent/iflow/types.go @@ -4,8 +4,8 @@ import "encoding/json" // IFlowSettings represents the .iflow/settings.json structure type IFlowSettings struct { - Hooks IFlowHooks `json:"hooks,omitempty"` - Permissions IFlowPermissions `json:"permissions,omitempty"` + Hooks IFlowHooks `json:"hooks,omitempty"` + Permissions IFlowPermissions `json:"permissions,omitempty"` } // IFlowHooks contains the hook configurations @@ -28,7 +28,7 @@ type IFlowPermissions struct { // IFlowHookMatcher matches hooks to specific patterns type IFlowHookMatcher struct { - Matcher string `json:"matcher,omitempty"` + Matcher string `json:"matcher,omitempty"` Hooks []IFlowHookEntry `json:"hooks"` } @@ -58,6 +58,11 @@ type ToolHookInput struct { ToolResponse json.RawMessage `json:"tool_response,omitempty"` } +// ToolResponseContent represents the structure of tool_response.result +type ToolResponseContent struct { + LLMContent string `json:"llmContent,omitempty"` +} + // UserPromptSubmitInput is the JSON structure from UserPromptSubmit hook type UserPromptSubmitInput struct { BaseHookInput @@ -95,11 +100,21 @@ type SubagentStopInput struct { // TranscriptLine represents a single line in the JSONL transcript type TranscriptLine struct { - Type string `json:"type"` - Timestamp string `json:"timestamp"` - Message json.RawMessage `json:"message,omitempty"` - ToolUse *ToolUse `json:"tool_use,omitempty"` - ToolResult *ToolResult `json:"tool_result,omitempty"` + Type string `json:"type"` + Timestamp string `json:"timestamp"` + Message json.RawMessage `json:"message,omitempty"` + ToolUse *ToolUse `json:"tool_use,omitempty"` + ToolResult *ToolResult `json:"tool_result,omitempty"` + Tokens *IFlowMessageTokens `json:"tokens,omitempty"` +} + +// IFlowMessageTokens represents token usage from an iFlow API response. +// This structure may be present in transcript lines for assistant messages. +type IFlowMessageTokens struct { + Input int `json:"input,omitempty"` + Output int `json:"output,omitempty"` + Cached int `json:"cached,omitempty"` + Total int `json:"total,omitempty"` } // ToolUse represents a tool invocation in the transcript @@ -129,14 +144,14 @@ type FileWriteToolInput struct { // Tool names used in iFlow CLI transcripts const ( - ToolWrite = "write_file" - ToolEdit = "replace" - ToolMultiEdit = "multi_edit" - ToolShell = "run_shell_command" - ToolRead = "read_file" - ToolList = "list_directory" - ToolSearch = "search_file_content" - ToolGlob = "glob" + ToolWrite = "write_file" + ToolEdit = "replace" + ToolMultiEdit = "multi_edit" + ToolShell = "run_shell_command" + ToolRead = "read_file" + ToolList = "list_directory" + ToolSearch = "search_file_content" + ToolGlob = "glob" ) // FileModificationTools lists tools that create or modify files From 13e92e43fc71dbd07359d7908bf5f49dbff3db07 Mon Sep 17 00:00:00 2001 From: WangxuMarshall Date: Mon, 9 Mar 2026 18:27:44 +0800 Subject: [PATCH 07/15] docs: add IFLOW.md architecture overview --- IFLOW.md | 244 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 IFLOW.md diff --git a/IFLOW.md b/IFLOW.md new file mode 100644 index 000000000..675e22f49 --- /dev/null +++ b/IFLOW.md @@ -0,0 +1,244 @@ +# Entire CLI 架构概述 + +## 1. 项目概览 + +Entire CLI 是一个与 Git 工作流集成的工具,用于捕获和管理 AI 代理(Claude Code、Gemini CLI、Cursor、OpenCode 等)的会话数据。核心功能: + +- 记录 AI 代理的完整交互过程(提示词、响应、修改的文件) +- 提供会话恢复和回滚功能 +- 在单独的分支上保存会话元数据,保持主分支 Git 历史整洁 +- 支持审计和合规要求 + +**技术栈**: Go 1.26.x, Cobra CLI, go-git/v6, mise 构建工具 + +## 2. 构建与命令 + +### 开发命令 +```bash +mise install # 安装依赖 +mise run build # 构建 CLI +mise run fmt # 代码格式化 +mise run lint # 代码检查 +``` + +### 测试命令 +```bash +mise run test # 单元测试 +mise run test:integration # 集成测试 +mise run test:ci # CI 完整测试(单元+集成+E2E canary) +mise run test:e2e:canary # E2E canary 测试(Vogon 代理,无 API 调用) +``` + +### 提交前必须执行 +```bash +mise run fmt && mise run lint && mise run test:ci +``` + +## 3. 核心架构 + +### Agent 系统 (`cmd/entire/cli/agent/`) + +Agent 系统使用**控制反转**模式:代理是被动数据提供者,将原生 hook 载荷转换为标准化生命周期事件,框架负责所有编排。 + +**核心接口** (`agent.Agent`): +- `Name()` / `Type()` / `Description()` - 身份标识 +- `DetectPresence()` - 检测代理是否配置 +- `ProtectedDirs()` - 回滚时保护的目录 +- `ReadTranscript()` / `ChunkTranscript()` / `ReassembleTranscript()` - 会话记录处理 +- `ParseHookEvent()` - **核心贡献点** - 将原生 hook 转换为标准化事件 + +**可选接口**: +- `HookSupport` - 自动安装 hooks 到代理配置文件 +- `TranscriptAnalyzer` - 从会话记录提取文件列表和提示词 +- `TokenCalculator` - 计算 token 使用量 +- `SubagentAwareExtractor` - 处理子代理贡献 + +**已支持的代理**: Claude Code, Gemini CLI, Cursor, OpenCode, Factory AI Droid, Vogon (测试用) + +### Strategy 系统 (`cmd/entire/cli/strategy/`) + +Manual-Commit 策略管理会话数据和检查点: +- 创建影子分支 `entire/-` 存储会话元数据 +- 用户提交时压缩到 `entire/checkpoints/v1` 分支 +- 支持 Git worktrees,每个 worktree 独立跟踪 +- 不修改活跃分支历史,安全用于 main/master + +**关键文件**: +- `strategy.go` - 接口定义和上下文结构 +- `manual_commit.go` - 主实现 +- `manual_commit_session.go` - 会话状态管理 +- `manual_commit_condensation.go` - 压缩逻辑 +- `manual_commit_rewind.go` - 回滚实现 + +### Checkpoint 系统 (`cmd/entire/cli/checkpoint/`) + +- `temporary.go` - 影子分支操作 +- `committed.go` - 元数据分支操作 +- 使用 12 位十六进制检查点 ID 进行双向链接 + +### Session 状态机 (`cmd/entire/cli/session/`) + +**阶段**: `ACTIVE` → `IDLE` → `ENDED` + +**事件**: `TurnStart`, `TurnEnd`, `GitCommit`, `SessionStart`, `SessionEnd` + +## 4. 代码风格 + +### Go 代码规范 +- 遵循 `golangci-lint` 检查规则 +- 使用 `slog` 进行结构化日志记录 +- 所有测试使用 `t.Parallel()` 并行执行(除非修改进程全局状态) + +### 错误处理模式 +- `root.go` 设置 `SilenceErrors: true`,Cobra 不打印错误 +- `main.go` 打印错误到 stderr,除非是 `SilentError` +- 使用 `NewSilentError()` 当需要自定义用户友好错误消息时 + +### Settings 访问 +```go +import "github.com/entireio/cli/cmd/entire/cli/settings" + +s, err := settings.Load(ctx) +if s.Enabled { ... } +``` +**禁止**: 直接读取 `.entire/settings.json` + +### 日志 vs 用户输出 +- **内部日志**: `logging.Debug/Info/Warn/Error(ctx, msg, attrs...)` → `.entire/logs/` +- **用户输出**: `fmt.Fprint*(cmd.OutOrStdout(), ...)` +- **隐私**: 不记录用户内容(提示词、文件内容、提交消息) + +### Git 操作注意事项 + +**禁止使用 go-git v5/v6 进行 `checkout` 或 `reset --hard`**: +go-git 有 bug 会错误删除 `.gitignore` 中列出的未跟踪目录。使用 git CLI: +```go +// 正确方式 +cmd := exec.CommandContext(ctx, "git", "reset", "--hard", hash.String()) +``` + +**路径处理**: +始终使用仓库根目录而非 `os.Getwd()`: +```go +// 错误 - 从子目录运行时会出错 +cwd, _ := os.Getwd() +absPath := filepath.Join(cwd, file) + +// 正确 +repoRoot, _ := paths.WorktreeRoot() +absPath := filepath.Join(repoRoot, file) +``` + +## 5. 测试 + +### 测试隔离 +**测试必须使用隔离的临时仓库**,禁止使用真实仓库 CWD: +```go +tmpDir := t.TempDir() +testutil.InitRepo(t, tmpDir) // git init + user config + disable GPG +testutil.WriteFile(t, tmpDir, "f.txt", "init") +testutil.GitAdd(t, tmpDir, "f.txt") +testutil.GitCommit(t, tmpDir, "init") +t.Chdir(tmpDir) +``` + +### 测试文件位置 +- 单元测试: 与源文件同目录 `*_test.go` +- 集成测试: `cmd/entire/cli/integration_test/` (使用 `//go:build integration` 标签) +- E2E 测试: `e2e/tests/` (使用 `//go:build e2e` 标签) + +### 代码重复检查 +```bash +mise run dup # 综合检查(阈值 50) +mise run dup:staged # 仅检查暂存文件 +``` + +## 6. 安全 + +### 数据保护 +- `settings.local.json` 默认不提交到 Git +- 会话元数据存储在单独分支,与主分支分离 +- `redact/` 包处理敏感信息 + +### 权限控制 +- 设置文件使用权限 `0o644` +- 遵循最小权限原则 + +### 遥测 +- 支持匿名使用统计,可通过配置关闭 +- 非阻塞方式收集,不影响核心功能 + +## 7. 配置 + +### 配置文件 +位于 `.entire/` 目录: + +**settings.json** (项目设置,提交到 Git): +```json +{ + "enabled": true, + "commit_linking": "prompt", + "strategy_options": { + "push_sessions": true, + "summarize": { "enabled": true } + } +} +``` + +**settings.local.json** (本地设置,不提交): +```json +{ + "enabled": false, + "log_level": "debug" +} +``` + +### 主要配置选项 + +| 选项 | 值 | 描述 | +|------|-----|------| +| `enabled` | `true`, `false` | 启用/禁用 Entire | +| `log_level` | `debug`, `info`, `warn`, `error` | 日志级别 | +| `commit_linking` | `always`, `prompt` | 提交链接模式 | +| `strategy_options.push_sessions` | `true`, `false` | Git push 时自动推送 checkpoints 分支 | +| `strategy_options.summarize.enabled` | `true`, `false` | 自动生成 AI 摘要 | +| `telemetry` | `true`, `false` | 发送匿名使用统计 | +| `external_agents` | `true`, `false` | 启用外部代理插件发现 | + +## 8. 无障碍支持 + +环境变量 `ACCESSIBLE=1` 启用无障碍模式,使用简单文本提示替代交互式 TUI 元素。 + +在 `cli` 包中使用 `NewAccessibleForm()`,在 `strategy` 包中使用 `isAccessibleMode()` 辅助函数。 + +## 9. 添加新代理 + +参考 `docs/architecture/agent-guide.md` 和 `docs/architecture/agent-integration-checklist.md`。 + +关键步骤: +1. 创建 `cmd/entire/cli/agent/youragent/` 目录 +2. 实现 `agent.Agent` 接口(19 个方法) +3. 实现 `ParseHookEvent()` - 核心贡献点 +4. 根据需要实现可选接口 +5. 通过 `init()` 注册代理 +6. 添加编译时接口断言 +7. 编写测试 + +## 10. 行为规则 + +### 开发流程 +- 非平凡任务(3+ 步骤或架构决策)先进入计划模式 +- 使用子代理保持主上下文窗口清洁 +- 任务完成后验证正确性(运行测试、检查日志) +- 追求优雅但不过度工程化 + +### 任务管理 +1. 先写计划到 `tasks/todo.md` +2. 开始实现前确认计划 +3. 进度跟踪:完成一项标记一项 +4. 记录结果和经验教训 + +### 核心原则 +- **简洁优先**: 每个改动尽可能简单,影响最小代码 +- **不偷懒**: 找到根本原因,不做临时修复 +- **最小影响**: 只触及必要的代码 From a420daaff5d159f754cac4026dbf51bd5174844c Mon Sep 17 00:00:00 2001 From: WangxuMarshall Date: Mon, 9 Mar 2026 18:28:12 +0800 Subject: [PATCH 08/15] feat: add iFlow agent configuration with hooks --- .iflow/settings.json | 86 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .iflow/settings.json diff --git a/.iflow/settings.json b/.iflow/settings.json new file mode 100644 index 000000000..0165dda42 --- /dev/null +++ b/.iflow/settings.json @@ -0,0 +1,86 @@ +{ + "hooks": { + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "entire hooks iflow notification" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "entire hooks iflow post-tool-use" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "entire hooks iflow pre-tool-use" + } + ] + } + ], + "SessionEnd": [ + { + "type": "command", + "command": "entire hooks iflow session-end" + } + ], + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "entire hooks iflow session-start" + } + ] + } + ], + "SetUpEnvironment": [ + { + "type": "command", + "command": "entire hooks iflow set-up-environment" + } + ], + "Stop": [ + { + "type": "command", + "command": "entire hooks iflow stop" + } + ], + "SubagentStop": [ + { + "type": "command", + "command": "entire hooks iflow subagent-stop" + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "entire hooks iflow user-prompt-submit" + } + ] + } + ] + }, + "permissions": { + "deny": [ + "Read(./.entire/metadata/**)" + ] + } +} From 97e5fda9d4852aa26b8743491a0b21529529bb8b Mon Sep 17 00:00:00 2001 From: WangxuMarshall Date: Tue, 10 Mar 2026 13:26:51 +0800 Subject: [PATCH 09/15] fix(iflow): use correct hook config format per iFlow CLI spec According to iFlow CLI documentation, all hook configurations must use the 'hooks' array wrapper format. Previously, SetUpEnvironment, Stop, SubagentStop, and SessionEnd used a flat format which caused hooks to not be triggered. Changes: - types.go: Unify all hook types to use IFlowHookMatcher format - hooks.go: Update hook installation to use correct nested format Before (incorrect): "Stop": [{"type": "command", "command": "..."}] After (correct): "Stop": [{"hooks": [{"type": "command", "command": "..."}]}] --- cmd/entire/cli/agent/iflow/hooks.go | 133 ++++++++++------------------ cmd/entire/cli/agent/iflow/types.go | 15 +++- 2 files changed, 58 insertions(+), 90 deletions(-) diff --git a/cmd/entire/cli/agent/iflow/hooks.go b/cmd/entire/cli/agent/iflow/hooks.go index 4645b853c..aa75d0d3f 100644 --- a/cmd/entire/cli/agent/iflow/hooks.go +++ b/cmd/entire/cli/agent/iflow/hooks.go @@ -94,18 +94,19 @@ func (i *IFlowCLIAgent) InstallHooks(ctx context.Context, localDev bool, force b } // Parse hook types we need to modify + // All hooks use IFlowHookMatcher format (with hooks array wrapper) per iFlow CLI specification var preToolUse, postToolUse, sessionStart, userPromptSubmit, notification []IFlowHookMatcher - var setUpEnvironment, stop, subagentStop, sessionEnd []IFlowHookEntry + var setUpEnvironment, stop, subagentStop, sessionEnd []IFlowHookMatcher parseHookMatcherType(rawHooks, "PreToolUse", &preToolUse) parseHookMatcherType(rawHooks, "PostToolUse", &postToolUse) parseHookMatcherType(rawHooks, "SessionStart", &sessionStart) parseHookMatcherType(rawHooks, "UserPromptSubmit", &userPromptSubmit) parseHookMatcherType(rawHooks, "Notification", ¬ification) - parseHookEntryType(rawHooks, "SetUpEnvironment", &setUpEnvironment) - parseHookEntryType(rawHooks, "Stop", &stop) - parseHookEntryType(rawHooks, "SubagentStop", &subagentStop) - parseHookEntryType(rawHooks, "SessionEnd", &sessionEnd) + parseHookMatcherType(rawHooks, "SetUpEnvironment", &setUpEnvironment) + parseHookMatcherType(rawHooks, "Stop", &stop) + parseHookMatcherType(rawHooks, "SubagentStop", &subagentStop) + parseHookMatcherType(rawHooks, "SessionEnd", &sessionEnd) // If force is true, remove all existing Entire hooks first if force { @@ -114,10 +115,10 @@ func (i *IFlowCLIAgent) InstallHooks(ctx context.Context, localDev bool, force b sessionStart = removeEntireHooks(sessionStart) userPromptSubmit = removeEntireHooks(userPromptSubmit) notification = removeEntireHooks(notification) - setUpEnvironment = removeEntireHookEntries(setUpEnvironment) - stop = removeEntireHookEntries(stop) - subagentStop = removeEntireHookEntries(subagentStop) - sessionEnd = removeEntireHookEntries(sessionEnd) + setUpEnvironment = removeEntireHooks(setUpEnvironment) + stop = removeEntireHooks(stop) + subagentStop = removeEntireHooks(subagentStop) + sessionEnd = removeEntireHooks(sessionEnd) } // Define hook commands @@ -183,38 +184,34 @@ func (i *IFlowCLIAgent) InstallHooks(ctx context.Context, localDev bool, force b count++ } - // Add SetUpEnvironment hook - if !hookEntryExists(setUpEnvCmd, setUpEnvironment) { - setUpEnvironment = append(setUpEnvironment, IFlowHookEntry{ - Type: "command", - Command: setUpEnvCmd, + // Add SetUpEnvironment hook (no matcher, just hooks array) + if !hookMatcherHasCommand(setUpEnvironment, setUpEnvCmd) { + setUpEnvironment = append(setUpEnvironment, IFlowHookMatcher{ + Hooks: []IFlowHookEntry{{Type: "command", Command: setUpEnvCmd}}, }) count++ } - // Add Stop hook - if !hookEntryExists(stopCmd, stop) { - stop = append(stop, IFlowHookEntry{ - Type: "command", - Command: stopCmd, + // Add Stop hook (no matcher, just hooks array) + if !hookMatcherHasCommand(stop, stopCmd) { + stop = append(stop, IFlowHookMatcher{ + Hooks: []IFlowHookEntry{{Type: "command", Command: stopCmd}}, }) count++ } - // Add SubagentStop hook - if !hookEntryExists(subagentStopCmd, subagentStop) { - subagentStop = append(subagentStop, IFlowHookEntry{ - Type: "command", - Command: subagentStopCmd, + // Add SubagentStop hook (no matcher, just hooks array) + if !hookMatcherHasCommand(subagentStop, subagentStopCmd) { + subagentStop = append(subagentStop, IFlowHookMatcher{ + Hooks: []IFlowHookEntry{{Type: "command", Command: subagentStopCmd}}, }) count++ } - // Add SessionEnd hook - if !hookEntryExists(sessionEndCmd, sessionEnd) { - sessionEnd = append(sessionEnd, IFlowHookEntry{ - Type: "command", - Command: sessionEndCmd, + // Add SessionEnd hook (no matcher, just hooks array) + if !hookMatcherHasCommand(sessionEnd, sessionEndCmd) { + sessionEnd = append(sessionEnd, IFlowHookMatcher{ + Hooks: []IFlowHookEntry{{Type: "command", Command: sessionEndCmd}}, }) count++ } @@ -247,10 +244,10 @@ func (i *IFlowCLIAgent) InstallHooks(ctx context.Context, localDev bool, force b marshalHookMatcherType(rawHooks, "SessionStart", sessionStart) marshalHookMatcherType(rawHooks, "UserPromptSubmit", userPromptSubmit) marshalHookMatcherType(rawHooks, "Notification", notification) - marshalHookEntryType(rawHooks, "SetUpEnvironment", setUpEnvironment) - marshalHookEntryType(rawHooks, "Stop", stop) - marshalHookEntryType(rawHooks, "SubagentStop", subagentStop) - marshalHookEntryType(rawHooks, "SessionEnd", sessionEnd) + marshalHookMatcherType(rawHooks, "SetUpEnvironment", setUpEnvironment) + marshalHookMatcherType(rawHooks, "Stop", stop) + marshalHookMatcherType(rawHooks, "SubagentStop", subagentStop) + marshalHookMatcherType(rawHooks, "SessionEnd", sessionEnd) // Marshal hooks and update raw settings hooksJSON, err := json.Marshal(rawHooks) @@ -312,18 +309,19 @@ func (i *IFlowCLIAgent) UninstallHooks(ctx context.Context) error { } // Parse and clean all hook types + // All hooks use IFlowHookMatcher format (with hooks array wrapper) per iFlow CLI specification var preToolUse, postToolUse, sessionStart, userPromptSubmit, notification []IFlowHookMatcher - var setUpEnvironment, stop, subagentStop, sessionEnd []IFlowHookEntry + var setUpEnvironment, stop, subagentStop, sessionEnd []IFlowHookMatcher parseHookMatcherType(rawHooks, "PreToolUse", &preToolUse) parseHookMatcherType(rawHooks, "PostToolUse", &postToolUse) parseHookMatcherType(rawHooks, "SessionStart", &sessionStart) parseHookMatcherType(rawHooks, "UserPromptSubmit", &userPromptSubmit) parseHookMatcherType(rawHooks, "Notification", ¬ification) - parseHookEntryType(rawHooks, "SetUpEnvironment", &setUpEnvironment) - parseHookEntryType(rawHooks, "Stop", &stop) - parseHookEntryType(rawHooks, "SubagentStop", &subagentStop) - parseHookEntryType(rawHooks, "SessionEnd", &sessionEnd) + parseHookMatcherType(rawHooks, "SetUpEnvironment", &setUpEnvironment) + parseHookMatcherType(rawHooks, "Stop", &stop) + parseHookMatcherType(rawHooks, "SubagentStop", &subagentStop) + parseHookMatcherType(rawHooks, "SessionEnd", &sessionEnd) // Remove Entire hooks from all hook types preToolUse = removeEntireHooks(preToolUse) @@ -331,10 +329,10 @@ func (i *IFlowCLIAgent) UninstallHooks(ctx context.Context) error { sessionStart = removeEntireHooks(sessionStart) userPromptSubmit = removeEntireHooks(userPromptSubmit) notification = removeEntireHooks(notification) - setUpEnvironment = removeEntireHookEntries(setUpEnvironment) - stop = removeEntireHookEntries(stop) - subagentStop = removeEntireHookEntries(subagentStop) - sessionEnd = removeEntireHookEntries(sessionEnd) + setUpEnvironment = removeEntireHooks(setUpEnvironment) + stop = removeEntireHooks(stop) + subagentStop = removeEntireHooks(subagentStop) + sessionEnd = removeEntireHooks(sessionEnd) // Marshal modified hook types back to rawHooks marshalHookMatcherType(rawHooks, "PreToolUse", preToolUse) @@ -342,10 +340,10 @@ func (i *IFlowCLIAgent) UninstallHooks(ctx context.Context) error { marshalHookMatcherType(rawHooks, "SessionStart", sessionStart) marshalHookMatcherType(rawHooks, "UserPromptSubmit", userPromptSubmit) marshalHookMatcherType(rawHooks, "Notification", notification) - marshalHookEntryType(rawHooks, "SetUpEnvironment", setUpEnvironment) - marshalHookEntryType(rawHooks, "Stop", stop) - marshalHookEntryType(rawHooks, "SubagentStop", subagentStop) - marshalHookEntryType(rawHooks, "SessionEnd", sessionEnd) + marshalHookMatcherType(rawHooks, "SetUpEnvironment", setUpEnvironment) + marshalHookMatcherType(rawHooks, "Stop", stop) + marshalHookMatcherType(rawHooks, "SubagentStop", subagentStop) + marshalHookMatcherType(rawHooks, "SessionEnd", sessionEnd) // Also remove the metadata deny rule from permissions var rawPermissions map[string]json.RawMessage @@ -425,9 +423,9 @@ func (i *IFlowCLIAgent) AreHooksInstalled(ctx context.Context) bool { return false } - // Check for at least one of our hooks - return hookEntryExists("entire hooks iflow stop", settings.Hooks.Stop) || - hookEntryExists("go run ${IFLOW_PROJECT_DIR}/cmd/entire/main.go hooks iflow stop", settings.Hooks.Stop) + // Check for at least one of our hooks in Stop hook configuration + return hookMatcherHasCommand(settings.Hooks.Stop, "entire hooks iflow stop") || + hookMatcherHasCommand(settings.Hooks.Stop, "go run ${IFLOW_PROJECT_DIR}/cmd/entire/main.go hooks iflow stop") } // Helper functions for hook management @@ -438,12 +436,6 @@ func parseHookMatcherType(rawHooks map[string]json.RawMessage, hookType string, } } -func parseHookEntryType(rawHooks map[string]json.RawMessage, hookType string, target *[]IFlowHookEntry) { - if data, ok := rawHooks[hookType]; ok { - json.Unmarshal(data, target) - } -} - func marshalHookMatcherType(rawHooks map[string]json.RawMessage, hookType string, matchers []IFlowHookMatcher) { if len(matchers) == 0 { delete(rawHooks, hookType) @@ -456,18 +448,6 @@ func marshalHookMatcherType(rawHooks map[string]json.RawMessage, hookType string rawHooks[hookType] = data } -func marshalHookEntryType(rawHooks map[string]json.RawMessage, hookType string, entries []IFlowHookEntry) { - if len(entries) == 0 { - delete(rawHooks, hookType) - return - } - data, err := json.Marshal(entries) - if err != nil { - return - } - rawHooks[hookType] = data -} - func isEntireHook(command string) bool { for _, prefix := range entireHookPrefixes { if strings.HasPrefix(command, prefix) { @@ -494,16 +474,6 @@ func removeEntireHooks(matchers []IFlowHookMatcher) []IFlowHookMatcher { return result } -func removeEntireHookEntries(entries []IFlowHookEntry) []IFlowHookEntry { - result := make([]IFlowHookEntry, 0, len(entries)) - for _, entry := range entries { - if !isEntireHook(entry.Command) { - result = append(result, entry) - } - } - return result -} - func hookMatcherExists(matchers []IFlowHookMatcher, matcherPattern, command string) bool { for _, matcher := range matchers { if matcher.Matcher == matcherPattern { @@ -517,15 +487,6 @@ func hookMatcherExists(matchers []IFlowHookMatcher, matcherPattern, command stri return false } -func hookEntryExists(command string, entries []IFlowHookEntry) bool { - for _, entry := range entries { - if entry.Command == command { - return true - } - } - return false -} - func hookMatcherHasCommand(matchers []IFlowHookMatcher, command string) bool { for _, matcher := range matchers { for _, hook := range matcher.Hooks { diff --git a/cmd/entire/cli/agent/iflow/types.go b/cmd/entire/cli/agent/iflow/types.go index 70931a4fb..19839e754 100644 --- a/cmd/entire/cli/agent/iflow/types.go +++ b/cmd/entire/cli/agent/iflow/types.go @@ -9,14 +9,15 @@ type IFlowSettings struct { } // IFlowHooks contains the hook configurations +// All hooks use IFlowHookMatcher format (with hooks array wrapper) per iFlow CLI specification type IFlowHooks struct { PreToolUse []IFlowHookMatcher `json:"PreToolUse,omitempty"` PostToolUse []IFlowHookMatcher `json:"PostToolUse,omitempty"` - SetUpEnvironment []IFlowHookEntry `json:"SetUpEnvironment,omitempty"` - Stop []IFlowHookEntry `json:"Stop,omitempty"` - SubagentStop []IFlowHookEntry `json:"SubagentStop,omitempty"` + SetUpEnvironment []IFlowHookMatcher `json:"SetUpEnvironment,omitempty"` + Stop []IFlowHookMatcher `json:"Stop,omitempty"` + SubagentStop []IFlowHookMatcher `json:"SubagentStop,omitempty"` SessionStart []IFlowHookMatcher `json:"SessionStart,omitempty"` - SessionEnd []IFlowHookEntry `json:"SessionEnd,omitempty"` + SessionEnd []IFlowHookMatcher `json:"SessionEnd,omitempty"` UserPromptSubmit []IFlowHookMatcher `json:"UserPromptSubmit,omitempty"` Notification []IFlowHookMatcher `json:"Notification,omitempty"` } @@ -52,6 +53,7 @@ type BaseHookInput struct { // ToolHookInput is the JSON structure from PreToolUse/PostToolUse hooks type ToolHookInput struct { BaseHookInput + ToolName string `json:"tool_name"` ToolAliases []string `json:"tool_aliases,omitempty"` ToolInput json.RawMessage `json:"tool_input"` @@ -66,12 +68,14 @@ type ToolResponseContent struct { // UserPromptSubmitInput is the JSON structure from UserPromptSubmit hook type UserPromptSubmitInput struct { BaseHookInput + Prompt string `json:"prompt"` } // SessionStartInput is the JSON structure from SessionStart hook type SessionStartInput struct { BaseHookInput + Source string `json:"source,omitempty"` // startup, resume, clear, compress Model string `json:"model,omitempty"` } @@ -79,12 +83,14 @@ type SessionStartInput struct { // NotificationInput is the JSON structure from Notification hook type NotificationInput struct { BaseHookInput + Message string `json:"message"` } // StopInput is the JSON structure from Stop hook type StopInput struct { BaseHookInput + DurationMs int64 `json:"duration_ms,omitempty"` TurnCount int `json:"turn_count,omitempty"` } @@ -92,6 +98,7 @@ type StopInput struct { // SubagentStopInput is the JSON structure from SubagentStop hook type SubagentStopInput struct { BaseHookInput + SubagentID string `json:"subagent_id,omitempty"` DurationMs int64 `json:"duration_ms,omitempty"` } From b9895e4dde1ad2b49d4ea9d2bed78e48515703ec Mon Sep 17 00:00:00 2001 From: WangxuMarshall Date: Tue, 10 Mar 2026 13:27:08 +0800 Subject: [PATCH 10/15] fix(iflow): add env var support and transcript_path fallback iFlow CLI passes hook data via environment variables in addition to stdin JSON. Add support for reading from env vars as fallback. Also add computeTranscriptPath() to derive transcript path from session_id when not provided, using pattern: ~/.iflow/projects//.jsonl Changes: - Add env var overrides for all hook parsing functions - Add computeTranscriptPath() for fallback transcript path computation - Pass context through parsing functions for WorktreeRoot access --- cmd/entire/cli/agent/iflow/lifecycle.go | 141 ++++++++++++++++++++---- 1 file changed, 119 insertions(+), 22 deletions(-) diff --git a/cmd/entire/cli/agent/iflow/lifecycle.go b/cmd/entire/cli/agent/iflow/lifecycle.go index bf963e179..1a3c6526b 100644 --- a/cmd/entire/cli/agent/iflow/lifecycle.go +++ b/cmd/entire/cli/agent/iflow/lifecycle.go @@ -9,6 +9,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" ) // Ensure IFlowCLIAgent implements required interfaces @@ -32,22 +33,22 @@ func (i *IFlowCLIAgent) WriteHookResponse(message string) error { // ParseHookEvent translates an iFlow CLI hook into a normalized lifecycle Event. // Returns nil if the hook has no lifecycle significance. -func (i *IFlowCLIAgent) ParseHookEvent(_ context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { +func (i *IFlowCLIAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { switch hookName { case HookNameSessionStart: - return i.parseSessionStart(stdin) + return i.parseSessionStart(ctx, stdin) case HookNameUserPromptSubmit: - return i.parseTurnStart(stdin) + return i.parseTurnStart(ctx, stdin) case HookNamePreToolUse: return i.parsePreToolUse(stdin) case HookNamePostToolUse: return i.parsePostToolUse(stdin) case HookNameStop: - return i.parseStop(stdin) + return i.parseStop(ctx, stdin) case HookNameSessionEnd: - return i.parseSessionEnd(stdin) + return i.parseSessionEnd(ctx, stdin) case HookNameSubagentStop: - return i.parseSubagentStop(stdin) + return i.parseSubagentStop(ctx, stdin) case HookNameSetUpEnvironment, HookNameNotification: // These hooks don't have lifecycle significance for Entire return nil, nil @@ -58,16 +59,56 @@ func (i *IFlowCLIAgent) ParseHookEvent(_ context.Context, hookName string, stdin // --- Internal hook parsing functions --- -func (i *IFlowCLIAgent) parseSessionStart(stdin io.Reader) (*agent.Event, error) { +// computeTranscriptPath returns the transcript path if provided, or computes it from session_id. +// iFlow CLI may not always provide transcript_path in hook input, so we compute it as a fallback. +func (i *IFlowCLIAgent) computeTranscriptPath(ctx context.Context, sessionID, providedPath string) string { + // If transcript_path is provided, use it directly + if providedPath != "" { + return providedPath + } + + // Fallback: compute from session_id + // Path pattern: ~/.iflow/projects//.jsonl + if sessionID == "" { + return "" + } + + repoPath, err := paths.WorktreeRoot(ctx) + if err != nil { + return "" + } + + sessionDir, err := i.GetSessionDir(repoPath) + if err != nil { + return "" + } + + return i.ResolveSessionFile(sessionDir, sessionID) +} + +func (i *IFlowCLIAgent) parseSessionStart(ctx context.Context, stdin io.Reader) (*agent.Event, error) { var input SessionStartInput if err := json.NewDecoder(stdin).Decode(&input); err != nil { return nil, fmt.Errorf("failed to decode session start input: %w", err) } + // Override with environment variables if present (iFlow CLI may use env vars) + sessionID := input.SessionID + if envSessionID := os.Getenv(EnvIFlowSessionID); envSessionID != "" { + sessionID = envSessionID + } + transcriptPath := input.TranscriptPath + if envTranscriptPath := os.Getenv(EnvIFlowTranscriptPath); envTranscriptPath != "" { + transcriptPath = envTranscriptPath + } + + // Fallback: compute transcript path from session_id if not provided + transcriptPath = i.computeTranscriptPath(ctx, sessionID, transcriptPath) + event := &agent.Event{ Type: agent.SessionStart, - SessionID: input.SessionID, - SessionRef: input.TranscriptPath, + SessionID: sessionID, + SessionRef: transcriptPath, Timestamp: time.Now(), Metadata: make(map[string]string), } @@ -83,17 +124,34 @@ func (i *IFlowCLIAgent) parseSessionStart(stdin io.Reader) (*agent.Event, error) return event, nil } -func (i *IFlowCLIAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error) { +func (i *IFlowCLIAgent) parseTurnStart(ctx context.Context, stdin io.Reader) (*agent.Event, error) { var input UserPromptSubmitInput if err := json.NewDecoder(stdin).Decode(&input); err != nil { return nil, fmt.Errorf("failed to decode user prompt submit input: %w", err) } + // Override with environment variables if present (iFlow CLI may use env vars) + sessionID := input.SessionID + if envSessionID := os.Getenv(EnvIFlowSessionID); envSessionID != "" { + sessionID = envSessionID + } + transcriptPath := input.TranscriptPath + if envTranscriptPath := os.Getenv(EnvIFlowTranscriptPath); envTranscriptPath != "" { + transcriptPath = envTranscriptPath + } + prompt := input.Prompt + if envPrompt := os.Getenv(EnvIFlowUserPrompt); envPrompt != "" { + prompt = envPrompt + } + + // Fallback: compute transcript path from session_id if not provided + transcriptPath = i.computeTranscriptPath(ctx, sessionID, transcriptPath) + return &agent.Event{ Type: agent.TurnStart, - SessionID: input.SessionID, - SessionRef: input.TranscriptPath, - Prompt: input.Prompt, + SessionID: sessionID, + SessionRef: transcriptPath, + Prompt: prompt, Timestamp: time.Now(), }, nil } @@ -124,16 +182,29 @@ func (i *IFlowCLIAgent) parsePostToolUse(stdin io.Reader) (*agent.Event, error) return nil, nil } -func (i *IFlowCLIAgent) parseStop(stdin io.Reader) (*agent.Event, error) { +func (i *IFlowCLIAgent) parseStop(ctx context.Context, stdin io.Reader) (*agent.Event, error) { var input StopInput if err := json.NewDecoder(stdin).Decode(&input); err != nil { return nil, fmt.Errorf("failed to decode stop input: %w", err) } + // Override with environment variables if present (iFlow CLI may use env vars) + sessionID := input.SessionID + if envSessionID := os.Getenv(EnvIFlowSessionID); envSessionID != "" { + sessionID = envSessionID + } + transcriptPath := input.TranscriptPath + if envTranscriptPath := os.Getenv(EnvIFlowTranscriptPath); envTranscriptPath != "" { + transcriptPath = envTranscriptPath + } + + // Fallback: compute transcript path from session_id if not provided + transcriptPath = i.computeTranscriptPath(ctx, sessionID, transcriptPath) + event := &agent.Event{ Type: agent.TurnEnd, - SessionID: input.SessionID, - SessionRef: input.TranscriptPath, + SessionID: sessionID, + SessionRef: transcriptPath, Timestamp: time.Now(), } @@ -147,30 +218,56 @@ func (i *IFlowCLIAgent) parseStop(stdin io.Reader) (*agent.Event, error) { return event, nil } -func (i *IFlowCLIAgent) parseSessionEnd(stdin io.Reader) (*agent.Event, error) { +func (i *IFlowCLIAgent) parseSessionEnd(ctx context.Context, stdin io.Reader) (*agent.Event, error) { var input BaseHookInput if err := json.NewDecoder(stdin).Decode(&input); err != nil { return nil, fmt.Errorf("failed to decode session end input: %w", err) } + // Override with environment variables if present (iFlow CLI may use env vars) + sessionID := input.SessionID + if envSessionID := os.Getenv(EnvIFlowSessionID); envSessionID != "" { + sessionID = envSessionID + } + transcriptPath := input.TranscriptPath + if envTranscriptPath := os.Getenv(EnvIFlowTranscriptPath); envTranscriptPath != "" { + transcriptPath = envTranscriptPath + } + + // Fallback: compute transcript path from session_id if not provided + transcriptPath = i.computeTranscriptPath(ctx, sessionID, transcriptPath) + return &agent.Event{ Type: agent.SessionEnd, - SessionID: input.SessionID, - SessionRef: input.TranscriptPath, + SessionID: sessionID, + SessionRef: transcriptPath, Timestamp: time.Now(), }, nil } -func (i *IFlowCLIAgent) parseSubagentStop(stdin io.Reader) (*agent.Event, error) { +func (i *IFlowCLIAgent) parseSubagentStop(ctx context.Context, stdin io.Reader) (*agent.Event, error) { var input SubagentStopInput if err := json.NewDecoder(stdin).Decode(&input); err != nil { return nil, fmt.Errorf("failed to decode subagent stop input: %w", err) } + // Override with environment variables if present (iFlow CLI may use env vars) + sessionID := input.SessionID + if envSessionID := os.Getenv(EnvIFlowSessionID); envSessionID != "" { + sessionID = envSessionID + } + transcriptPath := input.TranscriptPath + if envTranscriptPath := os.Getenv(EnvIFlowTranscriptPath); envTranscriptPath != "" { + transcriptPath = envTranscriptPath + } + + // Fallback: compute transcript path from session_id if not provided + transcriptPath = i.computeTranscriptPath(ctx, sessionID, transcriptPath) + event := &agent.Event{ Type: agent.SubagentEnd, - SessionID: input.SessionID, - SessionRef: input.TranscriptPath, + SessionID: sessionID, + SessionRef: transcriptPath, Timestamp: time.Now(), } From db9450c095956cfd32bf0b2e4e08dced478bf51b Mon Sep 17 00:00:00 2001 From: WangxuMarshall Date: Tue, 10 Mar 2026 13:27:19 +0800 Subject: [PATCH 11/15] fix(setup): load settings before installing agent hooks Previously, agent hooks were installed using opts.LocalDev directly, which caused re-running 'entire enable' without --local-dev flag to overwrite existing localDev settings in agent hook commands. Fix by loading existing settings BEFORE installing agent hooks, then using settings.LocalDev (merged with opts flags) for hook installation. This ensures existing localDev setting is preserved when re-enabling. --- cmd/entire/cli/setup.go | 61 +++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 4ba84d9ec..7109ccacc 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -174,9 +174,24 @@ func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent return fmt.Errorf("failed to clean up deselected agents: %w", err) } - // Setup agent hooks for all selected agents + // Load existing settings EARLY to get LocalDev for agent hook installation. + // This ensures re-running `entire enable` without --local-dev preserves existing localDev setting. + settings, err := LoadEntireSettings(ctx) + if err != nil { + // If we can't load, start with defaults + settings = &EntireSettings{} + } + // Merge opts flags into settings (opts takes precedence) + if opts.LocalDev { + settings.LocalDev = true + } + if opts.AbsoluteGitHookPath { + settings.AbsoluteGitHookPath = true + } + + // Setup agent hooks for all selected agents using merged settings.LocalDev for _, ag := range agents { - if _, err := setupAgentHooks(ctx, ag, opts.LocalDev, opts.ForceHooks); err != nil { + if _, err := setupAgentHooks(ctx, ag, settings.LocalDev, opts.ForceHooks); err != nil { return fmt.Errorf("failed to setup %s hooks: %w", ag.Type(), err) } } @@ -186,20 +201,8 @@ func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent return fmt.Errorf("failed to setup .entire directory: %w", err) } - // Load existing settings to preserve other options (like strategy_options.push) - settings, err := LoadEntireSettings(ctx) - if err != nil { - // If we can't load, start with defaults - settings = &EntireSettings{} - } // Update the specific fields settings.Enabled = true - if opts.LocalDev { - settings.LocalDev = true - } - if opts.AbsoluteGitHookPath { - settings.AbsoluteGitHookPath = true - } // Set push_sessions option if --skip-push-sessions flag was provided if opts.SkipPushSessions { @@ -596,24 +599,14 @@ func setupAgentHooksNonInteractive(ctx context.Context, w io.Writer, ag agent.Ag fmt.Fprintf(w, "Agent: %s\n\n", ag.Type()) - // Install agent hooks (agent hooks don't depend on settings) - installedHooks, err := hookAgent.InstallHooks(ctx, opts.LocalDev, opts.ForceHooks) - if err != nil { - return fmt.Errorf("failed to install hooks for %s: %w", agentName, err) - } - - // Setup .entire directory - if _, err := setupEntireDirectory(ctx); err != nil { - return fmt.Errorf("failed to setup .entire directory: %w", err) - } - - // Load existing settings to preserve other options (like strategy_options.push) + // Load existing settings EARLY to get LocalDev for agent hook installation. + // This ensures re-running `entire enable --agent X` without --local-dev preserves existing localDev setting. settings, err := LoadEntireSettings(ctx) if err != nil { // If we can't load, start with defaults settings = &EntireSettings{} } - settings.Enabled = true + // Merge opts flags into settings (opts takes precedence) if opts.LocalDev { settings.LocalDev = true } @@ -621,6 +614,20 @@ func setupAgentHooksNonInteractive(ctx context.Context, w io.Writer, ag agent.Ag settings.AbsoluteGitHookPath = true } + // Install agent hooks using merged settings.LocalDev + installedHooks, err := hookAgent.InstallHooks(ctx, settings.LocalDev, opts.ForceHooks) + if err != nil { + return fmt.Errorf("failed to install hooks for %s: %w", agentName, err) + } + + // Setup .entire directory + if _, err := setupEntireDirectory(ctx); err != nil { + return fmt.Errorf("failed to setup .entire directory: %w", err) + } + + // Update the specific fields + settings.Enabled = true + // Set push_sessions option if --skip-push-sessions flag was provided if opts.SkipPushSessions { if settings.StrategyOptions == nil { From 65cd46f6e1050545142d390bf9fd9d23e5a9c978 Mon Sep 17 00:00:00 2001 From: WangxuMarshall Date: Tue, 10 Mar 2026 13:27:31 +0800 Subject: [PATCH 12/15] fix(traeagent): update tests to use correct interface methods The agent.Agent interface doesn't have SupportsHooks() method. HookSupport interface provides hook-related methods. Changes: - Remove TestTraeAgent_SupportsHooks (method doesn't exist) - Update TestTraeAgent_GetHookNames to use HookSupport interface - Remove TestTraeAgent_GetSupportedHooks (method doesn't exist) - Minor code cleanup in traeagent.go --- cmd/entire/cli/agent/traeagent/traeagent.go | 40 +++++++++---------- .../cli/agent/traeagent/traeagent_test.go | 35 +++++----------- 2 files changed, 31 insertions(+), 44 deletions(-) diff --git a/cmd/entire/cli/agent/traeagent/traeagent.go b/cmd/entire/cli/agent/traeagent/traeagent.go index 768c365fd..2de3fe9b4 100644 --- a/cmd/entire/cli/agent/traeagent/traeagent.go +++ b/cmd/entire/cli/agent/traeagent/traeagent.go @@ -292,51 +292,51 @@ func ExtractModifiedFiles(data []byte) ([]string, error) { // Raw data structures for parsing hooks type sessionStartRaw struct { - SessionID string `json:"session_id"` + SessionID string `json:"session_id"` TrajectoryPath string `json:"trajectory_path"` } type sessionEndRaw struct { - SessionID string `json:"session_id"` + SessionID string `json:"session_id"` TrajectoryPath string `json:"trajectory_path"` } type preToolUseRaw struct { - SessionID string `json:"session_id"` + SessionID string `json:"session_id"` TrajectoryPath string `json:"trajectory_path"` - ToolName string `json:"tool_name"` - ToolUseID string `json:"tool_use_id"` - ToolInput json.RawMessage `json:"tool_input"` + ToolName string `json:"tool_name"` + ToolUseID string `json:"tool_use_id"` + ToolInput json.RawMessage `json:"tool_input"` } type postToolUseRaw struct { - SessionID string `json:"session_id"` + SessionID string `json:"session_id"` TrajectoryPath string `json:"trajectory_path"` - ToolName string `json:"tool_name"` - ToolUseID string `json:"tool_use_id"` - ToolInput json.RawMessage `json:"tool_input"` - ToolResponse json.RawMessage `json:"tool_response"` + ToolName string `json:"tool_name"` + ToolUseID string `json:"tool_use_id"` + ToolInput json.RawMessage `json:"tool_input"` + ToolResponse json.RawMessage `json:"tool_response"` } type preModelRaw struct { - SessionID string `json:"session_id"` + SessionID string `json:"session_id"` TrajectoryPath string `json:"trajectory_path"` - ModelName string `json:"model_name"` - Prompt string `json:"prompt"` + ModelName string `json:"model_name"` + Prompt string `json:"prompt"` } type postModelRaw struct { - SessionID string `json:"session_id"` + SessionID string `json:"session_id"` TrajectoryPath string `json:"trajectory_path"` - ModelName string `json:"model_name"` - Response string `json:"response"` - TokenUsage json.RawMessage `json:"token_usage"` + ModelName string `json:"model_name"` + Response string `json:"response"` + TokenUsage json.RawMessage `json:"token_usage"` } type preCompressRaw struct { - SessionID string `json:"session_id"` + SessionID string `json:"session_id"` TrajectoryPath string `json:"trajectory_path"` - Context string `json:"context"` + Context string `json:"context"` } type notificationRaw struct { diff --git a/cmd/entire/cli/agent/traeagent/traeagent_test.go b/cmd/entire/cli/agent/traeagent/traeagent_test.go index a07d7749b..4bbca6d05 100644 --- a/cmd/entire/cli/agent/traeagent/traeagent_test.go +++ b/cmd/entire/cli/agent/traeagent/traeagent_test.go @@ -9,6 +9,7 @@ import ( ) func TestNewTraeAgent(t *testing.T) { + t.Parallel() ag := NewTraeAgent() assert.NotNil(t, ag) assert.Equal(t, agent.AgentNameTraeAgent, ag.Name()) @@ -16,22 +17,26 @@ func TestNewTraeAgent(t *testing.T) { assert.Equal(t, "Trae Agent - ByteDance's LLM-based software engineering agent", ag.Description()) } -func TestTraeAgent_SupportsHooks(t *testing.T) { +func TestTraeAgent_HookSupport(t *testing.T) { + t.Parallel() ag := NewTraeAgent() - assert.True(t, ag.SupportsHooks()) + _, ok := ag.(agent.HookSupport) + assert.True(t, ok, "TraeAgent should implement HookSupport") } func TestTraeAgent_ProtectedDirs(t *testing.T) { + t.Parallel() ag := NewTraeAgent() dirs := ag.ProtectedDirs() assert.Equal(t, []string{".trae"}, dirs) } -func TestTraeAgent_GetHookNames(t *testing.T) { +func TestTraeAgent_HookNames(t *testing.T) { + t.Parallel() ag := NewTraeAgent() - hookHandler, ok := ag.(agent.HookHandler) - assert.True(t, ok, "TraeAgent should implement HookHandler") - hookNames := hookHandler.GetHookNames() + hookSupport, ok := ag.(agent.HookSupport) + assert.True(t, ok, "TraeAgent should implement HookSupport") + hookNames := hookSupport.HookNames() assert.Contains(t, hookNames, HookNameSessionStart) assert.Contains(t, hookNames, HookNameSessionEnd) assert.Contains(t, hookNames, HookNameBeforeAgent) @@ -44,21 +49,3 @@ func TestTraeAgent_GetHookNames(t *testing.T) { assert.Contains(t, hookNames, HookNamePreCompress) assert.Contains(t, hookNames, HookNameNotification) } - -func TestTraeAgent_GetSupportedHooks(t *testing.T) { - ag := NewTraeAgent() - hookSupport, ok := ag.(agent.HookSupport) - assert.True(t, ok, "TraeAgent should implement HookSupport") - supportedHooks := hookSupport.GetSupportedHooks() - assert.Contains(t, supportedHooks, agent.HookSessionStart) - assert.Contains(t, supportedHooks, agent.HookSessionEnd) - assert.Contains(t, supportedHooks, agent.HookBeforeAgent) - assert.Contains(t, supportedHooks, agent.HookAfterAgent) - assert.Contains(t, supportedHooks, agent.HookBeforeModel) - assert.Contains(t, supportedHooks, agent.HookAfterModel) - assert.Contains(t, supportedHooks, agent.HookBeforeToolSelection) - assert.Contains(t, supportedHooks, agent.HookPreTool) - assert.Contains(t, supportedHooks, agent.HookAfterTool) - assert.Contains(t, supportedHooks, agent.HookPreCompress) - assert.Contains(t, supportedHooks, agent.HookNotification) -} From 66bb242487f4c408c3574b5702fcf95c7f5167cc Mon Sep 17 00:00:00 2001 From: WangxuMarshall Date: Tue, 10 Mar 2026 13:27:54 +0800 Subject: [PATCH 13/15] style: fix whitespace formatting in common_test.go Fix inconsistent whitespace in map literals after gofmt run. --- cmd/entire/cli/strategy/common_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/entire/cli/strategy/common_test.go b/cmd/entire/cli/strategy/common_test.go index 96cff0db6..43f85c0e2 100644 --- a/cmd/entire/cli/strategy/common_test.go +++ b/cmd/entire/cli/strategy/common_test.go @@ -1432,8 +1432,8 @@ func TestReadLatestSessionPromptFromCommittedTree(t *testing.T) { // Session 1 (latest) has no prompt.txt, session 0 does. // This happens when a test session gets condensed alongside a real one. tree := buildCommittedTree(t, map[string]string{ - "a3/b2c4d5e6f7/0/prompt.txt": "Real session prompt", - "a3/b2c4d5e6f7/1/metadata.json": `{"session_id":"test"}`, + "a3/b2c4d5e6f7/0/prompt.txt": "Real session prompt", + "a3/b2c4d5e6f7/1/metadata.json": `{"session_id":"test"}`, }) got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 2) @@ -1446,9 +1446,9 @@ func TestReadLatestSessionPromptFromCommittedTree(t *testing.T) { t.Parallel() // Sessions 2 and 1 have no prompt, session 0 does. tree := buildCommittedTree(t, map[string]string{ - "a3/b2c4d5e6f7/0/prompt.txt": "Original prompt", - "a3/b2c4d5e6f7/1/metadata.json": `{"session_id":"s1"}`, - "a3/b2c4d5e6f7/2/metadata.json": `{"session_id":"s2"}`, + "a3/b2c4d5e6f7/0/prompt.txt": "Original prompt", + "a3/b2c4d5e6f7/1/metadata.json": `{"session_id":"s1"}`, + "a3/b2c4d5e6f7/2/metadata.json": `{"session_id":"s2"}`, }) got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 3) From 7736cf93b91f7115feb1716662867b4071be0b31 Mon Sep 17 00:00:00 2001 From: wangxumarshall <37137833+wangxumarshall@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:58:03 +0800 Subject: [PATCH 14/15] Update .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c686cf18a..e8014559e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # -CLAUDE.md + # Binaries for programs and plugins *.exe *.exe~ From dcba8dba2b522228e0a6c0182d2d4ad6ea6d88d1 Mon Sep 17 00:00:00 2001 From: wangxumarshall <37137833+wangxumarshall@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:58:34 +0800 Subject: [PATCH 15/15] Update .gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index e8014559e..dec6ba738 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # - # Binaries for programs and plugins *.exe *.exe~