diff --git a/.claude/skills/agent-integration/researcher.md b/.claude/skills/agent-integration/researcher.md index 6f4223d43..748ed4840 100644 --- a/.claude/skills/agent-integration/researcher.md +++ b/.claude/skills/agent-integration/researcher.md @@ -8,7 +8,7 @@ Assess whether a target AI coding agent's hook/lifecycle model is compatible wit Read `docs/architecture/agent-guide.md` to understand what Entire expects from agents: EventType names, required interfaces, hook patterns, and lifecycle flow. This gives you the vocabulary to map the target agent's native hooks to Entire's event model. -**Do NOT read other internal Entire source files** (`agent.go`, `event.go`, `hook_registry.go`, `lifecycle.go`, or reference implementations). The implementer handles those. +You may also reference `cmd/entire/cli/agent/agent.go` and `cmd/entire/cli/agent/event.go` for exact interface signatures and EventType constants. Prefer `agent-guide.md` as the primary source, but the source files are the ground truth if the guide is out of date. Avoid reading other internal files (`hook_registry.go`, `lifecycle.go`, strategy code) — the implementer handles those, and architecture tests enforce this boundary at the code level. ### Phase 2: Static Capability Checks @@ -158,4 +158,4 @@ If blocked at any point (auth, sandbox, binary not found): - **Non-destructive.** All artifacts go under `.entire/tmp/` (gitignored). The one-pager goes in the agent package directory. - **Agent-specific scripts.** Adapt based on Phase 2 findings, not a generic template. - **Ask, don't assume.** If the hook mechanism is unclear, ask the user. -- **External focus.** Do not read internal Entire source files beyond `agent-guide.md`. The implementer reads those. +- **External focus.** Prefer `agent-guide.md` for Entire's expectations. You may reference `agent.go` and `event.go` for exact names, but avoid deeper internals — architecture tests enforce the boundary at the code level. diff --git a/cmd/entire/cli/agent/architecture_test.go b/cmd/entire/cli/agent/architecture_test.go new file mode 100644 index 000000000..481cce12b --- /dev/null +++ b/cmd/entire/cli/agent/architecture_test.go @@ -0,0 +1,260 @@ +package agent + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// TestAgentPackages_NoForbiddenImports verifies that agent implementation packages +// (claudecode, geminicli, opencode, cursor, etc.) only import from allowed packages. +// +// This prevents agent implementations from coupling to framework internals +// (strategy, checkpoint, session, commands, hook_registry, lifecycle) which +// would create brittle dependencies that break when internals change. +// +// Agents should depend only on: +// - The agent contract package (agent, agent/types, agent/testutil) +// - Utility packages (logging, paths, jsonutil, textutil, transcript, etc.) +// - Standard library and external dependencies +func TestAgentPackages_NoForbiddenImports(t *testing.T) { + t.Parallel() + + const repoPrefix = "github.com/entireio/cli/cmd/entire/cli/" + + // Packages that agent implementations must NOT import. + // These are framework internals that agents should be decoupled from. + forbiddenSuffixes := []string{ + "strategy", + "checkpoint", + "session", + "commands", + } + + // Individual files in the cli package that agents must not depend on. + // We check for the cli package import itself since these are all in + // the top-level cli package (not sub-packages). + forbiddenCLIPackage := repoPrefix[:len(repoPrefix)-1] // "github.com/entireio/cli/cmd/entire/cli" + + // Packages within the repo that agents ARE allowed to import. + allowedPrefixes := []string{ + repoPrefix + "agent", // agent contract + sub-packages + repoPrefix + "logging", // logging utilities + repoPrefix + "paths", // path utilities + repoPrefix + "jsonutil", // JSON utilities + repoPrefix + "textutil", // text utilities + repoPrefix + "transcript", // transcript utilities + repoPrefix + "trailers", // git trailer utilities + repoPrefix + "stringutil", // string utilities + repoPrefix + "telemetry", // telemetry + repoPrefix + "validation", // validation utilities + repoPrefix + "settings", // settings (read-only access) + } + + agentDir := findAgentDir(t) + agentPkgs := discoverAgentPackages(t, agentDir) + + if len(agentPkgs) == 0 { + t.Fatal("no agent packages found — test setup is broken") + } + + for _, pkgDir := range agentPkgs { + pkgName := filepath.Base(pkgDir) + t.Run(pkgName, func(t *testing.T) { + t.Parallel() + imports := extractImports(t, pkgDir) + for _, imp := range imports { + if !strings.HasPrefix(imp, repoPrefix) && imp != forbiddenCLIPackage { + continue // external or stdlib — allowed + } + + // Check if it's the top-level cli package itself + if imp == forbiddenCLIPackage { + t.Errorf("forbidden import %q — agent packages must not import the top-level cli package directly", imp) + continue + } + + // Check forbidden suffixes + rel := strings.TrimPrefix(imp, repoPrefix) + isForbidden := false + for _, forbidden := range forbiddenSuffixes { + if rel == forbidden || strings.HasPrefix(rel, forbidden+"/") { + t.Errorf("forbidden import %q — agent packages must not import %s internals", imp, forbidden) + isForbidden = true + } + } + if isForbidden { + continue + } + + // Check it's in the allowed list + allowed := false + for _, prefix := range allowedPrefixes { + if imp == prefix || strings.HasPrefix(imp, prefix+"/") { + allowed = true + break + } + } + if !allowed { + t.Errorf("unexpected internal import %q — if this is intentional, add it to allowedPrefixes in architecture_test.go", imp) + } + } + }) + } +} + +// findAgentDir returns the absolute path to cmd/entire/cli/agent/. +// Go test runner sets cwd to the package directory, so os.Getwd() gives us +// the agent dir directly. +func findAgentDir(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("os.Getwd: %v", err) + } + return wd +} + +// discoverAgentPackages finds all subdirectories of the agent dir that contain +// Go files, excluding "types" and "testutil" (those are contract/shared packages). +func discoverAgentPackages(t *testing.T, agentDir string) []string { + t.Helper() + + skipDirs := map[string]bool{ + "types": true, // contract types, not an agent implementation + "testutil": true, // shared test utilities + } + + entries, err := os.ReadDir(agentDir) + if err != nil { + t.Fatalf("ReadDir(%s): %v", agentDir, err) + } + + var pkgs []string + for _, e := range entries { + if !e.IsDir() || skipDirs[e.Name()] { + continue + } + dir := filepath.Join(agentDir, e.Name()) + // Only include if it has Go files + goFiles, err := filepath.Glob(filepath.Join(dir, "*.go")) + if err != nil { + t.Fatalf("filepath.Glob(%s): %v", dir, err) + } + if len(goFiles) > 0 { + pkgs = append(pkgs, dir) + } + } + return pkgs +} + +// extractImports parses all Go files in a directory and returns unique import paths. +// Includes both production and test files — test-only imports should also respect boundaries. +func extractImports(t *testing.T, dir string) []string { + t.Helper() + + fset := token.NewFileSet() + //nolint:staticcheck // ParseDir is deprecated in favor of go/packages, but we intentionally + // scan all files regardless of build tags to catch forbidden imports in test files too. + pkgs, err := parser.ParseDir(fset, dir, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parser.ParseDir(%s): %v", dir, err) + } + + seen := make(map[string]bool) + for _, pkg := range pkgs { + for _, file := range pkg.Files { + for _, imp := range file.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + t.Fatalf("strconv.Unquote(%s): %v", imp.Path.Value, err) + } + seen[path] = true + } + } + } + + imports := make([]string, 0, len(seen)) + for imp := range seen { + imports = append(imports, imp) + } + return imports +} + +// TestAgentPackages_SelfRegister verifies that each agent package has an init() +// function that calls agent.Register(). This ensures agents are properly +// discoverable via the registry. +func TestAgentPackages_SelfRegister(t *testing.T) { + t.Parallel() + + agentDir := findAgentDir(t) + agentPkgs := discoverAgentPackages(t, agentDir) + + if len(agentPkgs) == 0 { + t.Fatal("no agent packages found — test setup is broken") + } + + for _, pkgDir := range agentPkgs { + pkgName := filepath.Base(pkgDir) + t.Run(pkgName, func(t *testing.T) { + t.Parallel() + if !hasInitWithRegister(t, pkgDir) { + t.Errorf("agent package %q has no init() function calling Register() — agents must self-register", pkgName) + } + }) + } +} + +// hasInitWithRegister checks if any Go file in the directory has an init() +// function that contains a call to Register. +func hasInitWithRegister(t *testing.T, dir string) bool { + t.Helper() + + fset := token.NewFileSet() + //nolint:staticcheck // See extractImports for rationale. + pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool { + return !strings.HasSuffix(fi.Name(), "_test.go") + }, 0) + if err != nil { + t.Fatalf("parser.ParseDir(%s): %v", dir, err) + } + + for _, pkg := range pkgs { + for _, file := range pkg.Files { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != "init" || fn.Recv != nil { + continue + } + // Walk the init body looking for a call to Register or agent.Register + found := false + ast.Inspect(fn.Body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + switch fun := call.Fun.(type) { + case *ast.Ident: + if fun.Name == "Register" { + found = true + } + case *ast.SelectorExpr: + if fun.Sel.Name == "Register" { + found = true + } + } + return !found + }) + if found { + return true + } + } + } + } + return false +}