Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.12] - 2026-07-30

### Fixed

- **Goose lifecycle hooks**: Goose shipped a full hooks system in v1.38 (Open Plugins spec), but `GooseAgent` still reported `Hooks: false` and did not implement `LifecycleEventMapper` — so `AGENT_ENV=goose` was absent from `BuildEventPhaseMap()` and callers had to fall back to scanning every other agent's map to resolve a Goose event. Add `EventPhases()` covering `SessionStart`, `SessionEnd`, `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, and `Stop`, plus `AgentENVAliases() == ["goose"]`. `PhaseCompact` is deliberately unmapped: Goose fires no compaction event, so mapping one would promise a re-prime that never happens.
- **Goose context files**: `ContextFiles()` returned `[".goose/config.yaml", ".goosehints"]`. `.goose/config.yaml` is configuration, not an instruction file, and the list omitted `AGENTS.md` entirely — which Goose loads *first*, ahead of `.goosehints`. Now returns `["AGENTS.md", ".goosehints"]` in Goose's own load order, so a caller injecting a context marker writes to the file Goose actually reads first.
- **Goose session support**: `SupportsSession()` was `false`. Goose supplies `session_id` on every hook payload, so it now returns `true`, following the OpenCode precedent. `SessionID(env)` still returns empty — Goose exposes no session-ID environment variable, and callers must read it from the hook payload.
- **Goose capabilities**: `Hooks: true`, `MinVersion: "1.38.0"` (the release hooks landed in), and the `ProjectContext` comment now names `AGENTS.md` alongside `.goosehints`.

## [0.1.11] - 2026-07-23

### Added
Expand Down
10 changes: 6 additions & 4 deletions agents/agents_comprehensive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,10 +303,10 @@ func allAgentSpecs() []agentSpec {
url: "https://github.com/block/goose",
supportsXDG: true,
projectConfig: "",
contextFiles: []string{".goose/config.yaml", ".goosehints"},
capabilities: agentx.Capabilities{Hooks: false, MCPServers: true, SystemPrompt: true, ProjectContext: true, CustomCommands: false},
contextFiles: []string{"AGENTS.md", ".goosehints"},
capabilities: agentx.Capabilities{Hooks: true, MCPServers: true, SystemPrompt: true, ProjectContext: true, CustomCommands: false, MinVersion: "1.38.0"},
binaryName: "goose",
supportsSession: false,
supportsSession: true,
usesHomeConfig: false,
configSubdir: "goose",
versionCmd: "goose",
Expand All @@ -317,6 +317,9 @@ func allAgentSpecs() []agentSpec {
{"exec path heuristic", map[string]string{"_": "/usr/local/bin/goose"}, true},
{"no env vars", map[string]string{}, false},
},
isLifecycleAgent: true,
envAliases: []string{"goose"},
eventPhaseCount: 6,
},
{
name: "Amp",
Expand Down Expand Up @@ -936,7 +939,6 @@ func TestNonLifecycleAgents_DoNotImplementMapper(t *testing.T) {
{"Cody", NewCodyAgent()},
{"Continue", NewContinueAgent()},
{"CodePuppy", NewCodePuppyAgent()},
{"Goose", NewGooseAgent()},
{"Codex", NewCodexAgent()},
{"Gemini CLI", NewGeminiAgent()},
}
Expand Down
51 changes: 44 additions & 7 deletions agents/goose.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,15 @@ func (a *GooseAgent) ProjectConfigPath() string {
return ""
}

// ContextFiles returns the context/instruction files Goose supports.
// ContextFiles returns the context/instruction files Goose loads into the
// system prompt, in Goose's own load order: AGENTS.md first, then .goosehints.
// Goose resolves both hierarchically from the working directory up to the
// repository root, and also reads the global ~/.config/goose/.goosehints.
// The set is overridable via the CONTEXT_FILE_NAMES environment variable.
//
// Reference: https://block.github.io/goose/docs/guides/context-engineering/using-goosehints
func (a *GooseAgent) ContextFiles() []string {
return []string{".goose/config.yaml", ".goosehints"}
return []string{"AGENTS.md", ".goosehints"}
}

// SupportsXDGConfig returns true as Goose uses ~/.config/goose.
Expand All @@ -87,12 +93,12 @@ func (a *GooseAgent) SupportsXDGConfig() bool {
// Capabilities returns Goose's supported features.
func (a *GooseAgent) Capabilities() agentx.Capabilities {
return agentx.Capabilities{
Hooks: false, // CLI-based
Hooks: true, // lifecycle hooks via plugins (goose >= 1.38)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Goose hook capability has no installation backend

Capabilities() now advertises Hooks: true, but the fully wired setup registry registers a bare GooseAgent and never assigns a HookManager. Callers that select Goose based on this capability receive nil from HookManager() and cannot install, remove, or validate the Goose plugin hook configuration. Implement and register a Goose hook manager for Goose's plugin hooks contract, or leave Hooks disabled until that operation is supported.

Artifacts

Goose lifecycle exported API validation script

  • A Go program imported agentx setup and exercised Goose registration, lifecycle mappings, session support, context metadata, and hook-manager availability; it demonstrates the unsupported Hooks capability contract.

Goose lifecycle behavior before PR 13

  • The same validation program compiled against HEAD^ and showed Goose had no hook capability, lifecycle mapper, alias registration, or session support before the PR.

Goose lifecycle behavior at PR 13 HEAD

  • The validation program at HEAD showed all six lifecycle events and metadata were registered but failed because the exported setup path leaves Goose HookManager nil, proving the advertised hook capability is unusable.

Repository test suite after Goose lifecycle validation

  • The executed `go test ./... -count=1` suite passed across all repository packages, showing existing tests do not cover the exported HookManager capability mismatch.

View artifacts

T-Rex Ran code and verified through T-Rex

Fix in Claude Code

MCPServers: true, // supports MCP extensions
SystemPrompt: true, // config.yaml
ProjectContext: true, // .goosehints
CustomCommands: false, // TBD
MinVersion: "",
ProjectContext: true, // AGENTS.md, .goosehints
CustomCommands: false, // recipes are not slash commands
MinVersion: "1.38.0",
}
}

Expand Down Expand Up @@ -143,7 +149,38 @@ func (a *GooseAgent) IsInstalled(ctx context.Context, env agentx.Environment) (b
return false, nil
}

func (a *GooseAgent) SupportsSession() bool { return false }
// EventPhases returns Goose's native event-to-phase mapping.
//
// Goose follows the Open Plugins hooks specification. It fires several more
// events than are mapped here (PostToolUseFailure, BeforeReadFile,
// AfterFileEdit, BeforeShellExecution, AfterShellExecution); those have no
// canonical phase equivalent and are deliberately omitted.
//
// Goose has no compaction event, so PhaseCompact is unreachable — context
// injected at session start does not survive a Goose compaction.
//
// Reference: https://block.github.io/goose/docs/guides/context-engineering/hooks
func (a *GooseAgent) EventPhases() agentx.EventPhaseMap {
return agentx.EventPhaseMap{
agentx.HookEventSessionStart: agentx.PhaseStart,
agentx.HookEventSessionEnd: agentx.PhaseEnd,
agentx.HookEventPreToolUse: agentx.PhaseBeforeTool,
agentx.HookEventPostToolUse: agentx.PhaseAfterTool,
agentx.HookEventUserPromptSubmit: agentx.PhasePrompt,
agentx.HookEventStop: agentx.PhaseStop,
}
}

// AgentENVAliases returns the AGENT_ENV values that identify Goose.
func (a *GooseAgent) AgentENVAliases() []string {
return []string{"goose"}
}

// SupportsSession returns true; Goose supplies a session_id on every hook
// payload. There is no session-ID environment variable, so SessionID always
// returns empty and callers must read it from the hook payload instead.
func (a *GooseAgent) SupportsSession() bool { return true }
func (a *GooseAgent) SessionID(_ agentx.Environment) string { return "" }

var _ agentx.Agent = (*GooseAgent)(nil)
var _ agentx.LifecycleEventMapper = (*GooseAgent)(nil)
91 changes: 91 additions & 0 deletions agents/goose_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package agents

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/sageox/agentx"
)

// TestGooseContextFiles pins the load ORDER, not just membership. Goose reads
// AGENTS.md first and .goosehints second, and a local file wins over the global
// ~/.config/goose/.goosehints. Callers that inject a prime marker need the first
// entry to be the file they should write to.
func TestGooseContextFiles(t *testing.T) {
agent := NewGooseAgent()
files := agent.ContextFiles()

require.Len(t, files, 2)
assert.Equal(t, "AGENTS.md", files[0], "AGENTS.md must come first — Goose loads it before .goosehints")
assert.Equal(t, ".goosehints", files[1])
}

func TestGooseCapabilities(t *testing.T) {
agent := NewGooseAgent()
caps := agent.Capabilities()

assert.True(t, caps.Hooks, "Goose ships lifecycle hooks via the plugin system")
assert.True(t, caps.MCPServers)
assert.True(t, caps.SystemPrompt)
assert.True(t, caps.ProjectContext)
assert.False(t, caps.CustomCommands, "recipes are not slash commands")
assert.Equal(t, "1.38.0", caps.MinVersion, "hooks landed in goose 1.38")
}

func TestGooseEventPhases(t *testing.T) {
agent := NewGooseAgent()

mapper, ok := agentx.Agent(agent).(agentx.LifecycleEventMapper)
require.True(t, ok, "Goose must implement LifecycleEventMapper")

phases := mapper.EventPhases()

expected := agentx.EventPhaseMap{
agentx.HookEventSessionStart: agentx.PhaseStart,
agentx.HookEventSessionEnd: agentx.PhaseEnd,
agentx.HookEventPreToolUse: agentx.PhaseBeforeTool,
agentx.HookEventPostToolUse: agentx.PhaseAfterTool,
agentx.HookEventUserPromptSubmit: agentx.PhasePrompt,
agentx.HookEventStop: agentx.PhaseStop,
}
assert.Equal(t, expected, phases)

// Goose has no compaction event. Anything relying on PhaseCompact to
// re-inject context after a context reset will never fire under Goose —
// that limitation is upstream and cannot be worked around by a caller.
for _, phase := range phases {
assert.NotEqual(t, agentx.PhaseCompact, phase,
"Goose fires no compaction event; mapping one would silently promise re-priming that never happens")
}

assert.Equal(t, []string{"goose"}, mapper.AgentENVAliases())
}

// TestGooseSession documents why SupportsSession is true while SessionID is
// always empty: Goose puts session_id on the hook payload (stdin), never in the
// environment. Callers must read it from the payload.
func TestGooseSession(t *testing.T) {
agent := NewGooseAgent()

assert.True(t, agent.SupportsSession())
assert.Equal(t, "", agent.SessionID(agentx.NewMockEnvironment(nil)))
assert.Equal(t, "", agent.SessionID(agentx.NewMockEnvironment(map[string]string{
"GOOSE_SESSION_ID": "should-be-ignored",
})), "Goose exposes no session-ID env var")
}

// TestGooseRegisteredInEventPhaseMap guards the reason this mapping exists:
// downstream tools resolve a hook event to a phase by AGENT_ENV alias. Before
// Goose implemented LifecycleEventMapper, "goose" was absent from that map and
// resolution fell through to a scan of every other agent.
func TestGooseRegisteredInEventPhaseMap(t *testing.T) {
byAlias := agentx.BuildEventPhaseMap()

phases, ok := byAlias["goose"]
require.True(t, ok, "AGENT_ENV=goose must resolve to a phase map")
assert.Equal(t, agentx.PhaseStart, phases[agentx.HookEventSessionStart])
assert.Equal(t, agentx.PhaseEnd, phases[agentx.HookEventSessionEnd])
assert.Equal(t, agentx.PhasePrompt, phases[agentx.HookEventUserPromptSubmit])
}
2 changes: 1 addition & 1 deletion agents/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func TestAgentSessionSupport(t *testing.T) {
{"Kiro", NewKiroAgent()},
{"Droid", NewDroidAgent()},
{"OpenCode", NewOpenCodeAgent()},
{"Goose", NewGooseAgent()},
}
for _, tt := range agents {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -61,7 +62,6 @@ func TestAgentSessionSupport(t *testing.T) {
agent agentx.Agent
}{
{"Aider", NewAiderAgent()},
{"Goose", NewGooseAgent()},
{"Cody", NewCodyAgent()},
{"Continue", NewContinueAgent()},
{"CodePuppy", NewCodePuppyAgent()},
Expand Down
Loading