// Package config detects the target repository root and project profile // and resolves the eeco workspace configuration. // // The package is deliberately small: it answers "where is the repo // root", "what kind of project is this", "who owns the workspace", // "where is the workspace", and "what config.local overrides apply". // Its only internal dependency is gitx, for the read-only git-identity // lookup that scopes the workspace under //. Workspace // creation lives alongside in init.go. package config import ( "errors" "fmt" "os" "path/filepath" "strconv" "strings" "github.com/ajhahnde/eeco/internal/gitx" ) // Profile identifies the kind of project a repository contains. It // drives the default parse/build gate command and may shape workflow // behaviour later. type Profile string const ( ProfileGo Profile = "go" ProfileZig Profile = "zig" ProfileRust Profile = "rust" ProfileNode Profile = "node" ProfilePython Profile = "python" ProfileGeneric Profile = "generic" ) // DefaultWorkspace is the default engine workspace directory name. It // is a hidden directory scaffolded inside the per-user workspace dir // (//.eeco). const DefaultWorkspace = ".eeco" // UsernameEnv, when set, overrides the username used to scope the // workspace under //. It is the non-interactive // injection point — CI, tests, and the future `eeco init --username` // flag — and takes precedence over `git config user.name`. const UsernameEnv = "EECO_USERNAME" // FallbackUsername is the last-resort workspace owner directory name, // used only when no username can be resolved from the environment or // git. It keeps Load total: the workspace always has a valid path even // on a machine with no git identity configured. const FallbackUsername = "eeco-workspace" // DefaultStaleDays is the default age in days after which a // reference-type memory fact is considered stale by garbage collection. const DefaultStaleDays = 30 // Automation selects how much housekeeping eeco performs on its own. // Raising the level lets eeco prepare more without prompting; it never // lets eeco act on the tracked tree on its own (the floor invariants in // PLAN.md hold at every level). type Automation string const ( // AutomationManual: no background analysis; new workflows only via // `eeco new`. AutomationManual Automation = "manual" // AutomationPropose (default): analysis only on explicit run / --ai; // a drafted workflow becomes a queue proposal. AutomationPropose Automation = "propose" // AutomationScaffold: as propose, but a drafted workflow is written // inactive into the workspace and queued "ready to activate". AutomationScaffold Automation = "scaffold" // AutomationAuto: analysis may run automatically within the budget // cap (this setting is the AI consent); workflows as scaffold. AutomationAuto Automation = "auto" ) // DefaultAutomation is the conservative default automation level. const DefaultAutomation = AutomationPropose // DefaultAIBudget caps the number of gated passes a single eeco // invocation may spend. A tool-using pass may make several model calls // but counts as one. One pass is enough for the builtins; 0 disables AI // entirely (every pass is then parked). const DefaultAIBudget = 1 // DefaultAIAPIKeyEnv is the environment-variable NAME the native // provider reads its API key from when `ai_api_key_env` is unset. Only // the variable name is configured; the secret itself is never stored. const DefaultAIAPIKeyEnv = "ANTHROPIC_API_KEY" // DefaultBugReportDir is the workspace-relative directory where // `eeco report-bug` writes per-invocation Markdown records. Overridable // via the `bug_report_dir` key in config.local. const DefaultBugReportDir = "bug-reports" // DefaultContextPath is the workspace-relative file `eeco go --write` // renders the project brief into. Overridable via the `context_path` // key in config.local. const DefaultContextPath = "context.md" // DefaultContextBudget is the default byte cap on the file `eeco go // --write` renders. 0 means no cap — the full brief is written as-is. // Overridable via the `context_budget` key in config.local. const DefaultContextBudget = 0 // DefaultBriefIncludeNotes is the default for whether `eeco go` adds a // "Recent notes" section to the brief. False keeps bare `eeco go` // byte-identical to the notes-free brief; opt in via the // `brief_include_notes` key in config.local. const DefaultBriefIncludeNotes = false // DefaultSessionStartMailbox is the default filename of the // repo-root mailbox the bundled session-start hook surfaces when it has // unprocessed content. Overridable via `session_start_mailbox` in // config.local; an empty override disables the mailbox block. const DefaultSessionStartMailbox = "Ideas.md" // DefaultSessionStartPinnedBodies is the default for whether the // bundled session-start hook composes a fourth block that emits the // FULL BODY of every `pin: true` memory fact alongside the existing // three blocks. False keeps the hook's output byte-identical to the // three-block behaviour; opt in via the `session_start_pinned_bodies` // key in config.local OR the `--with-pinned-bodies` flag on // `eeco hooks session-emit`. Useful when an AI assistant treats hook // output as a system-reminder so a pinned policy memory (for example // no-AI-attribution) is in the model's context from session start. const DefaultSessionStartPinnedBodies = false // DefaultSessionStartRoadmapGlob is the default glob, relative to the // repo root, used to discover the live planning surface the bundled // session-start hook appends to the reading routine. The // most-recently-modified match wins. Overridable via // `session_start_roadmap_glob`; an empty override disables roadmap // discovery. const DefaultSessionStartRoadmapGlob = "roadmap*.md" // DefaultPreCommitWorkflows returns the builtin workflow names wired // into the eeco-managed pre-commit hook when the operator has not // overridden them via `pre_commit_workflows`. The default is the // gate-family pair that is safe to run on every commit: `leak-guard` // (the long-standing default wiring) and `version-sync` (silent on // projects with no `version_locations`, so opt-in per project). // `comment-hygiene` is omitted: it is opinionated about prose in the // tracked tree and would surprise a fresh adopter on first install. // Callers receive a fresh slice; mutating it does not affect the // default returned by a subsequent call. func DefaultPreCommitWorkflows() []string { return []string{"leak-guard", "version-sync"} } // DefaultPostMergeWorkflows returns the builtin workflow names wired // into the eeco-managed post-merge hook when the operator has not // overridden them via `post_merge_workflows`. The default is the // drift-detection pair: `memory-drift` and `doc-drift`. A merge (a // `git pull` / `git merge`) is the moment another author's changes land // in the tree, so it is the natural trigger to re-check whether eeco's // recorded state has drifted from the code. Both are silent no-ops on a // project that carries no memory `ref:` facts and no CHANGELOG/tags, so // they are safe to wire by default. `manifest-refresh` joins them: a merge // can add or remove files in the knowledge dirs, so it is the natural moment // to rebuild the deterministic .ai.json skeletons; it is a no-op on a repo // with no knowledge dirs. `cockpit-sync` joins them too: a merge can ship new // playbook content (an eeco upgrade) that leaves every generated cockpit // artifact behind its source, so a merge is the natural moment to flag the // drift; it is a silent no-op on a repo where the cockpit was never generated // (its empty-ledger gate). Callers receive a fresh slice; mutating it does not // affect the default returned by a subsequent call. func DefaultPostMergeWorkflows() []string { return []string{"memory-drift", "doc-drift", "manifest-refresh", "cockpit-sync"} } // normalizeAutomation maps any value to a known level. An unknown or // future value is tolerated and falls back to the default rather than // failing (PLAN.md §Automation level). func normalizeAutomation(v string) Automation { switch Automation(v) { case AutomationManual, AutomationPropose, AutomationScaffold, AutomationAuto: return Automation(v) default: return DefaultAutomation } } // ImpliesAIConsent reports whether the level is itself standing consent // for a gated, budget-capped AI pass. Only `auto` is: every other level // requires an explicit --ai (PLAN.md §AI providers). func (a Automation) ImpliesAIConsent() bool { return a == AutomationAuto } // ScaffoldsWorkflows reports whether a drafted workflow is written // (inactive) into the workspace rather than only proposed via the queue. func (a Automation) ScaffoldsWorkflows() bool { return a == AutomationScaffold || a == AutomationAuto } // ReconcilesCockpit reports whether eeco may regenerate a drifted or missing // cockpit artifact on its own — a deterministic render→write into the // gitignored workspace tree, performed by the cockpit-sync workflow. Only // `auto` is. It is a file-write consent, distinct from ImpliesAIConsent (no // model spend is involved), so it has its own predicate rather than overloading // that one. Orphan removal stays operator-in-the-loop even at auto (deleting a // file is destructive). func (a Automation) ReconcilesCockpit() bool { return a == AutomationAuto } // WorkspaceHistory selects whether eeco keeps a private, local git // repository inside its own workspace directory (//) to // version the knowledge layer — memory, queue, decisions, manifests — // over time, and how often it commits. It is a different axis from // Automation (which governs background analysis), so it has its own key. type WorkspaceHistory string const ( // WorkspaceHistoryOff: no private repo. `eeco init` does not create // one; `eeco history` reports it is off. The durable opt-out. WorkspaceHistoryOff WorkspaceHistory = "off" // WorkspaceHistoryManual (default): the private repo exists, but eeco // commits to it only on an explicit `eeco history snapshot`. WorkspaceHistoryManual WorkspaceHistory = "manual" // WorkspaceHistoryAuto: as manual, plus eeco commits automatically // after each mutating verb (the cmd layer calls maybeAutoCommit at every // write site; see cmd/eeco/historygit.go). Still local-only — no remote, // no push. WorkspaceHistoryAuto WorkspaceHistory = "auto" ) // DefaultWorkspaceHistory is the safe-default floor: the repo is created // at init, but nothing is committed without an explicit snapshot. The // floor is manual, never auto. const DefaultWorkspaceHistory = WorkspaceHistoryManual // normalizeWorkspaceHistory maps any value to a known level. An unknown // or future value is tolerated and falls back to the default rather than // failing config load (floor invariant, mirroring normalizeAutomation). func normalizeWorkspaceHistory(v string) WorkspaceHistory { switch WorkspaceHistory(v) { case WorkspaceHistoryOff, WorkspaceHistoryManual, WorkspaceHistoryAuto: return WorkspaceHistory(v) default: return DefaultWorkspaceHistory } } // Enabled reports whether a private workspace-history repo should exist // (every level except off). func (h WorkspaceHistory) Enabled() bool { return h != WorkspaceHistoryOff } // Auto reports whether eeco should commit automatically after each // mutating verb (only the auto level). func (h WorkspaceHistory) Auto() bool { return h == WorkspaceHistoryAuto } // Config is the resolved configuration for an eeco invocation. type Config struct { // RepoRoot is the absolute path to the repository root (the directory // containing .git). RepoRoot string // Username is the slugged identity that owns the workspace. It scopes // the workspace under // and is the single // directory Init adds to .gitignore. Resolved by resolveUsername from // UsernameEnv, `git config user.name`, $USER, or FallbackUsername; it // is never empty. Username string // UserDir is the absolute path to the per-user workspace parent, // //. It holds the engine workspace (the .eeco // directory) plus the project-type-aware knowledge directories. UserDir string // WorkspaceName is the engine workspace directory name, relative to // UserDir (the leaf component of Workspace, e.g. ".eeco"). WorkspaceName string // Workspace is the absolute path to the workspace directory. Workspace string // Profile is the detected project profile. Profile Profile // Gate is the project's parse/build gate: an ordered chain of // commands (each an argv slice) run in sequence, stopping at the // first failure. The default is a single-step chain from the // detected profile; the repeatable `gate` key in config.local // overrides it — the first occurrence resets the profile default, // each occurrence appends one step. Empty (a lone `gate=`, or the // generic profile) means no gate. The `gate` builtin workflow runs // the chain. Gate [][]string // StaleDays controls when reference-type memory facts age out of // the store. Overridable via the `stale_days` key in config.local. StaleDays int // AttributionPatterns holds operator-supplied extra regexes appended // to the built-in AI-attribution denylist. Populated from repeatable // `attribution_pattern` keys in config.local; empty by default. AttributionPatterns []string // Automation is the resolved automation level. Overridable via the // `automation` key in config.local; defaults to DefaultAutomation. Automation Automation // WorkspaceHistory selects whether eeco keeps a private, local git // repository inside UserDir to version the knowledge layer over time, // and how often it commits (off | manual | auto). Overridable via the // `workspace_history` key in config.local; defaults to // DefaultWorkspaceHistory (manual). An unknown value falls back to the // default (floor invariant). The private repo is local-only — no // remote, no push — and confined to the gitignored workspace; the // engine never writes to it (all private-repo git lives in the cmd // layer, cmd/eeco/historygit.go). WorkspaceHistory WorkspaceHistory // AICommand is the argv for the wired CLI-based AI provider, taken // from the `ai_command` key (whitespace-split). Empty means no // provider is configured: every AI pass is parked, never failed. AICommand []string // AIBudget caps gated passes per invocation (a tool-using pass may // make several model calls but counts as one). From the `ai_budget` // key; defaults to DefaultAIBudget. 0 disables AI (passes are parked). AIBudget int // AIProvider selects the provider implementation. From the // `ai_provider` key; `cli` selects the CLI provider, and any other // value (empty/auto, or the legacy `anthropic`) falls back to // auto-select: an explicit `ai_command` picks the CLI provider, else // the not-configured stub. An unknown value is tolerated and never an // error (floor invariant). AIProvider string // AIModel is an opaque model identifier carried in config. From the // `ai_model` key. Inert passthrough: the CLI provider ignores it. AIModel string // AIAPIKeyEnv is the NAME of an environment variable for an API key, // from the `ai_api_key_env` key; defaults to DefaultAIAPIKeyEnv. Inert // passthrough since the in-binary API provider was retired. The key // value itself is never read from disk or stored (secret-handling floor). AIAPIKeyEnv string // SessionSettingsPath is the absolute path to the AI CLI's // user-global JSON settings file that the opt-in session-start hook // edits. From the `session_settings_path` key, or the // EECO_SESSION_SETTINGS environment variable when the key is unset. // Empty means session-start wiring is not configured: `eeco hooks // session-start on` reports that and touches nothing. No brand path // is baked in (Constraint 4). SessionSettingsPath string // BugReportDir is the workspace-relative directory where `eeco // report-bug` writes per-invocation Markdown records. From the // `bug_report_dir` key in config.local; defaults to // DefaultBugReportDir. The value is held to the workspace by the // write-scope guard; absolute paths and `..` traversal are rejected // at parse time. BugReportDir string // ContextPath is the workspace-relative file `eeco go --write` // renders the project brief into. From the `context_path` key in // config.local; defaults to DefaultContextPath. The value is held to // the workspace by the write-scope guard; absolute paths and `..` // traversal are rejected at parse time. ContextPath string // ContextBudget is the byte cap on the file `eeco go --write` // renders. From the `context_budget` key in config.local; defaults // to DefaultContextBudget (0, no cap). When positive, `eeco go // --write` trims the brief down a deterministic ladder until it // fits the budget. Negative values are rejected at parse time. ContextBudget int // BriefIncludeNotes opts the brief into a "Recent notes" section // drawn from /notes/. From the `brief_include_notes` key // in config.local; defaults to DefaultBriefIncludeNotes (false), so // bare `eeco go` stays byte-identical to the notes-free brief. The // notes section appears in Markdown output only; the JSON brief's // nine frozen top-level keys are unchanged. BriefIncludeNotes bool // SessionStartPinnedBodies opts the bundled session-start hook into // a fourth block that lists the full body of every `pin: true` // memory fact, separated by markdown dividers. From the // `session_start_pinned_bodies` key in config.local; defaults to // DefaultSessionStartPinnedBodies (false), so the three-block output // stays byte-identical. The `--with-pinned-bodies` flag on // `eeco hooks session-emit` sets this for one invocation, taking // precedence over the config value. SessionStartPinnedBodies bool // SessionStartDocs is the explicit reading-routine the bundled // session-start hook surfaces, in order. Populated from repeatable // `session_start_docs` keys in config.local (one path per // occurrence, repo-relative). When empty the hook falls back to // auto-detection over a list of common docs. SessionStartDocs []string // SessionStartMailbox is the repo-relative filename of the mailbox // the bundled session-start hook checks for unprocessed content. // From the `session_start_mailbox` key in config.local; defaults to // DefaultSessionStartMailbox. An empty override disables the // mailbox block; absolute paths and `..` traversal are rejected at // parse time. SessionStartMailbox string // SessionStartRoadmapGlob is the glob, relative to the repo root, // used by the bundled session-start hook to discover the live // planning surface. The most-recently-modified match is appended to // the reading routine. From `session_start_roadmap_glob`; defaults // to DefaultSessionStartRoadmapGlob. Empty disables discovery. SessionStartRoadmapGlob string // HandoverGlob is an optional glob, relative to the repo root, the // cockpit's SessionStart orient block uses to find the newest handover / // resume note (the session's resume point). The most-recently-modified // match wins, mirroring SessionStartRoadmapGlob. From the `handover_glob` // key in config.local; empty (the default) falls back to the newest note // under /notes/. HandoverGlob string // VersionLocations is the operator-declared list of `path:regex` // entries the `version-sync` builtin reads to detect drift between // version strings inside the repository. Each entry is split on the // first colon; the path is repo-relative and the regex must declare // at least one capture group (group 1 captures the version string). // Populated from repeatable `version_locations` keys in // config.local; empty disables the gate (`version-sync` exits 0). // Absolute paths and `..` traversal are rejected at parse time. // // The reserved value `version_locations=auto` switches `version-sync` // to auto-detect a fixed set of common version files instead of an // explicit list. It cannot be mixed with `path:regex` entries; when // declared, this slice is exactly the single element "auto". VersionLocations []string // VersionAnchor selects the source of truth `version-sync` compares // declared `version_locations` against. Three modes: // // "" (unset, default) — consistency-only. The first declared // location is the anchor; the rest must match it. Slice-1 // behaviour, preserved for backward compatibility. // "tag" — tag-anchor. The latest semver-shaped reachable git tag // (via gitx.LatestSemverTag) is the expected version. Declared // locations must be semver-greater-or-equal to it so a release // commit can bump declared locations ahead of the tag (the tag // is pushed after the commit). Backward-drift fails. If no // semver-shaped tag is reachable yet, falls back to // consistency-only. // ":" — designated-file. The path:regex pair is // parsed like a `version_locations` entry; the captured value // is the expected version. Declared locations must strict-equal // it. A missing path exits 2 (blocked). // // Populated from the `version_anchor` key in config.local; empty // keeps the default. Absolute paths and `..` traversal in the // designated-file form are rejected at parse time. VersionAnchor string // PreCommitWorkflows is the ordered list of builtin workflow names // the eeco-managed pre-commit hook runs, in declared sequence, // stopping at the first non-zero exit. Populated from repeatable // `pre_commit_workflows` keys in config.local; the first occurrence // in the file resets the default, subsequent occurrences append. // When config.local declares the key with an empty value the list // is cleared and `eeco hooks pre-commit on` refuses to install. // When config.local does not declare the key at all, the default // from DefaultPreCommitWorkflows is used. Names are not validated // here (the config package cannot import the workflow registry // without a cycle); validation happens at hook-install time. PreCommitWorkflows []string // PostMergeWorkflows is the ordered list of builtin workflow names // the eeco-managed post-merge hook runs after a `git merge` / // `git pull`. Populated from repeatable `post_merge_workflows` keys // in config.local with the same reset-then-append semantics as // PreCommitWorkflows: the first occurrence resets the default, // subsequent occurrences append, an empty value clears the list and // `eeco hooks post-merge on` refuses to install. When the key is not // declared the default from DefaultPostMergeWorkflows is used. Names // are validated at hook-install time, not here (registry cycle). PostMergeWorkflows []string // SessionFiles is the operator-declared list of paths where the // session-start hook maintains a marker block carrying the same // content `eeco hooks session-emit` prints. Each entry is one // delivery target — either repo-relative (held inside the repo by // the same path-traversal guard `session_start_docs` uses) or // absolute (matching the precedent set by `session_settings_path`). // Populated from repeatable `session_files` keys in config.local; // empty by default. Together with the JSON-settings channel keyed // by `session_settings_path`, this is the brand-free second delivery // channel for assistants that read a plain text or markdown file // at session start (e.g. Cursor's `.cursorrules`, OpenAI Codex's // `AGENTS.md`, a repo-level `CLAUDE.md`). Either channel is enough // for `eeco hooks session-start on` to succeed; both compose. SessionFiles []string // KnowledgeDirs is the project-type-aware set of knowledge // directories Init scaffolds inside UserDir, alongside the engine // workspace. It is empty for a Config produced by Load: the dir set // is resolved by `eeco init` from the project-type detector // (internal/projecttype) and assigned before the Init call, so Load // stays a pure, non-interactive configuration read. Names are held // to a single safe path component by Init. KnowledgeDirs []string // InitDetectionThreshold is the deterministic-confidence floor at or // above which `eeco init` accepts the project-type marker scan // without an interactive prompt. From the `init_detection_threshold` // key in config.local; 0 (the default) means "use the detector's // built-in default". Values are constrained to [0,1] at parse time. InitDetectionThreshold float64 } // ErrNotInRepo is returned when no enclosing git repository can be // located by walking upwards from the start directory. var ErrNotInRepo = errors.New("not inside a git repository") // FindRepoRoot walks upwards from start until it finds a directory that // contains a `.git` entry (a directory in a normal clone, or a file in // a worktree). The start path may be relative; the returned path is // always absolute and cleaned. func FindRepoRoot(start string) (string, error) { return walkUpForGitRoot(start, func(string) bool { return true }) } // walkUpForGitRoot walks upwards from start and returns the first directory // that both contains a `.git` entry and satisfies accept. A `.git` directory // for which accept returns false is walked past rather than returned, letting // a caller ignore a specific kind of nested repo. The start path may be // relative; the returned path is always absolute and cleaned. func walkUpForGitRoot(start string, accept func(dir string) bool) (string, error) { abs, err := filepath.Abs(start) if err != nil { return "", fmt.Errorf("resolve start path: %w", err) } dir := abs for { if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil && accept(dir) { return dir, nil } parent := filepath.Dir(dir) if parent == dir { return "", fmt.Errorf("%w: searched up from %s", ErrNotInRepo, abs) } dir = parent } } // resolveProjectRoot finds the host project's repo root for cwd, walking past // eeco's own private workspace-history repo (/.git, created by // `eeco init`) so that running from inside / — the directory the // harness launches from to load the emitted cockpit — resolves the project // root and not the private repo. If no non-private repo is found above (a // private repo with no host repo, which `eeco init` never produces), it falls // back to the nearest git root so behavior is never worse than FindRepoRoot. func resolveProjectRoot(start string) (string, error) { root, err := walkUpForGitRoot(start, func(dir string) bool { return !isPrivateWorkspaceRepo(dir) }) if errors.Is(err, ErrNotInRepo) { return FindRepoRoot(start) } return root, err } // isPrivateWorkspaceRepo reports whether dir is eeco's private // workspace-history repo rather than a host project root. The private repo // lives at /.git with the engine workspace /.eeco beside it; // a host project root never has the workspace directory directly under it (the // workspace is always nested under /). The check is a single stat so // root detection stays cheap on the hot path (Load runs on every command). func isPrivateWorkspaceRepo(dir string) bool { info, err := os.Stat(filepath.Join(dir, DefaultWorkspace)) return err == nil && info.IsDir() } // DetectProfile inspects marker files at the repository root and // returns the best-matching profile. When several markers are present, // the first match in the documented order wins (go, zig, rust, node, // python). It never returns an error: an unrecognised tree is generic. func DetectProfile(repoRoot string) Profile { exists := func(name string) bool { _, err := os.Stat(filepath.Join(repoRoot, name)) return err == nil } hasGlob := func(pattern string) bool { matches, err := filepath.Glob(filepath.Join(repoRoot, pattern)) return err == nil && len(matches) > 0 } switch { case exists("go.mod"): return ProfileGo case exists("build.zig"): return ProfileZig case exists("Cargo.toml"): return ProfileRust case exists("package.json"): return ProfileNode case exists("pyproject.toml"), exists(".venv"), hasGlob("requirements*.txt"): return ProfilePython default: return ProfileGeneric } } // resolveUsername picks the directory name that owns the workspace, // scoping it under //. Resolution order: UsernameEnv, // then `git config user.name`, then $USER / $USERNAME, then // FallbackUsername. Each candidate is slugged to a safe single path // component; the first non-empty slug wins. It never returns empty and // never fails: Load runs on every command and must stay non-interactive // (the interactive "pick a username" prompt belongs to `eeco init`). func resolveUsername(root string) string { candidates := []string{os.Getenv(UsernameEnv)} if name, err := gitx.UserName(root); err == nil { candidates = append(candidates, name) } candidates = append(candidates, os.Getenv("USER"), os.Getenv("USERNAME")) for _, c := range candidates { if s := slugUsername(c); s != "" { return s } } return FallbackUsername } // slugUsername reduces an arbitrary identity string to a safe single // path component: it keeps ASCII letters, digits, '-', '_', and '.', // maps spaces to '-', drops everything else, and trims leading/trailing // dots so the result can never be "." or "..". An empty result signals // "no usable name" to resolveUsername. func slugUsername(s string) string { var b strings.Builder for _, r := range strings.TrimSpace(s) { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': b.WriteRune(r) case r == ' ': b.WriteRune('-') } } return strings.Trim(b.String(), ".") } // GateFor returns the default parse/build gate for a profile as a // single-step chain. The generic profile has no gate and yields nil. // The returned value is a fresh copy and safe to mutate. func GateFor(p Profile) [][]string { var cmd []string switch p { case ProfileGo: cmd = []string{"go", "vet", "./..."} case ProfileZig: cmd = []string{"zig", "build", "--summary", "none"} case ProfileRust: cmd = []string{"cargo", "check", "--quiet"} case ProfileNode: cmd = []string{"npm", "run", "--if-present", "typecheck"} case ProfilePython: cmd = []string{"python3", "-m", "compileall", "-q", "."} default: return nil } step := make([]string, len(cmd)) copy(step, cmd) return [][]string{step} } // GateSteps renders each step of a gate chain as a single joined // command string (its argv joined by spaces). The result is a non-nil // (possibly empty) slice — one element per step — suitable for display // or, joined by " && ", for a one-line summary of the whole chain. func GateSteps(chain [][]string) []string { out := make([]string, 0, len(chain)) for _, step := range chain { out = append(out, strings.Join(step, " ")) } return out } // Load resolves the configuration for the given working directory and // workspace name. It detects the repo root, the profile, fills in the // default gate, then applies any overrides from // /config.local. The workspace itself does not need to // exist yet — Load is safe to call before `eeco init`. // // Pass an empty workspaceName to use DefaultWorkspace. func Load(cwd, workspaceName string) (*Config, error) { if workspaceName == "" { workspaceName = DefaultWorkspace } if err := validateWorkspaceName(workspaceName); err != nil { return nil, err } root, err := resolveProjectRoot(cwd) if err != nil { return nil, err } username := resolveUsername(root) cfg := &Config{ RepoRoot: root, Username: username, UserDir: filepath.Join(root, username), WorkspaceName: workspaceName, Workspace: filepath.Join(root, username, workspaceName), Profile: DetectProfile(root), StaleDays: DefaultStaleDays, Automation: DefaultAutomation, WorkspaceHistory: DefaultWorkspaceHistory, AIBudget: DefaultAIBudget, AIAPIKeyEnv: DefaultAIAPIKeyEnv, BugReportDir: DefaultBugReportDir, ContextPath: DefaultContextPath, ContextBudget: DefaultContextBudget, BriefIncludeNotes: DefaultBriefIncludeNotes, SessionStartPinnedBodies: DefaultSessionStartPinnedBodies, SessionStartMailbox: DefaultSessionStartMailbox, SessionStartRoadmapGlob: DefaultSessionStartRoadmapGlob, PreCommitWorkflows: DefaultPreCommitWorkflows(), PostMergeWorkflows: DefaultPostMergeWorkflows(), // Env is the default; a config.local key overrides it below. SessionSettingsPath: os.Getenv("EECO_SESSION_SETTINGS"), } cfg.Gate = GateFor(cfg.Profile) // Three-layer resolution: defaults (set above) → user-global // config → workspace config.local. Each later layer overrides the // earlier ones. if err := applyConfigFile(cfg, globalConfigLocalPath()); err != nil { return nil, fmt.Errorf("read global config: %w", err) } if err := applyLocal(cfg); err != nil { return nil, fmt.Errorf("read config.local: %w", err) } return cfg, nil } // GlobalConfigEnv overrides the directory eeco reads user-global // settings from. It takes precedence over XDG_CONFIG_HOME and the // ~/.config default, and is the hermetic test seam (mirrors // UsernameEnv) plus a power-user escape hatch. const GlobalConfigEnv = "EECO_CONFIG_HOME" // GlobalConfigDir resolves the user-global eeco config directory: // $EECO_CONFIG_HOME, else $XDG_CONFIG_HOME/eeco, else $HOME/.config/eeco. // It returns "" only when none can be resolved (no env, no HOME), in // which case the global layer is simply skipped. func GlobalConfigDir() string { if dir := os.Getenv(GlobalConfigEnv); dir != "" { return dir } if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { return filepath.Join(xdg, "eeco") } if home, err := os.UserHomeDir(); err == nil && home != "" { return filepath.Join(home, ".config", "eeco") } return "" } // globalConfigLocalPath is the user-global config.local file, or "" when // no global config dir can be resolved. func globalConfigLocalPath() string { dir := GlobalConfigDir() if dir == "" { return "" } return filepath.Join(dir, LocalFilename) } // validateWorkspaceName rejects names that would escape the repo root // or otherwise misbehave as a relative path component. func validateWorkspaceName(name string) error { if name == "" { return errors.New("workspace name is empty") } if name != filepath.Clean(name) { return fmt.Errorf("workspace name %q is not a clean path component", name) } if filepath.IsAbs(name) || strings.ContainsAny(name, `/\`) { return fmt.Errorf("workspace name %q must be a single path component", name) } if name == "." || name == ".." { return fmt.Errorf("workspace name %q is not allowed", name) } return nil } // applyLocal applies /config.local over cfg when the // workspace exists. It is the workspace (last-wins) layer of the // three-layer resolution defaults → global → workspace; see Load and // applyConfigFile. func applyLocal(cfg *Config) error { info, err := os.Stat(cfg.Workspace) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil } return err } if !info.IsDir() { // Workspace path exists but is not a directory. Init will // surface that; don't fail config loading here. return nil } return applyConfigFile(cfg, filepath.Join(cfg.Workspace, "config.local")) } // applyConfigFile reads a single config.local-format file at path and // overrides cfg with the keys it declares. The file is optional — a // missing file (or an empty path) is a no-op. Format is a flat // KEY=VALUE list, one entry per line; blank lines and lines starting // with `#` are ignored. Values may be wrapped in matching single or // double quotes. Multi-word `gate` is split on whitespace into one // chain step; the `gate` key is repeatable, each occurrence adding a // step. Repeatable keys (gate, pre_commit_workflows, // post_merge_workflows) reset their inherited value on their first // occurrence in THIS file, so a later layer fully replaces an earlier // one for that key. func applyConfigFile(cfg *Config, path string) error { if path == "" { return nil } b, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil } return err } // sawPreCommitWorkflows and sawGate track whether the operator has // declared the `pre_commit_workflows` / `gate` key at least once in // this file. The first occurrence of each resets the binary or // profile default so the operator-declared list fully replaces it; // subsequent occurrences append. var sawPreCommitWorkflows bool var sawPostMergeWorkflows bool var sawGate bool for lineNo, raw := range strings.Split(string(b), "\n") { line := strings.TrimSpace(raw) if line == "" || strings.HasPrefix(line, "#") { continue } k, v, ok := strings.Cut(line, "=") if !ok { return fmt.Errorf("%s:%d: missing '='", path, lineNo+1) } key := strings.TrimSpace(k) val := unquote(strings.TrimSpace(v)) switch key { case "profile": cfg.Profile = Profile(val) cfg.Gate = GateFor(cfg.Profile) sawGate = false case "gate": // Repeatable: the first occurrence resets the profile // default so the operator-declared chain fully replaces it; // each occurrence appends one step (a whitespace-split argv // command). An empty value contributes no step, so a lone // `gate=` clears the chain and disables the gate. if !sawGate { cfg.Gate = nil sawGate = true } if fields := strings.Fields(val); len(fields) > 0 { cfg.Gate = append(cfg.Gate, fields) } case "stale_days": n, err := strconv.Atoi(val) if err != nil { return fmt.Errorf("%s:%d: stale_days: %w", path, lineNo+1, err) } if n < 0 { return fmt.Errorf("%s:%d: stale_days must be >= 0 (got %d)", path, lineNo+1, n) } cfg.StaleDays = n case "attribution_pattern": // Repeatable: each occurrence appends one extra regex to the // attribution denylist. An empty value is ignored so a blank // override line cannot disable the gate. if val != "" { cfg.AttributionPatterns = append(cfg.AttributionPatterns, val) } case "automation": // An unknown or future level is tolerated and falls back to // the default (floor invariant — never fail on this key). cfg.Automation = normalizeAutomation(val) case "workspace_history": // off | manual | auto. An empty value resets to the default // (manual); an unknown or future value is tolerated and falls // back to the default (floor invariant — never fail on this // key, mirroring `automation`). if val == "" { cfg.WorkspaceHistory = DefaultWorkspaceHistory } else { cfg.WorkspaceHistory = normalizeWorkspaceHistory(val) } case "ai_command": // Whitespace-split argv for the wired CLI provider. Empty // leaves the provider unconfigured (passes are parked). if val == "" { cfg.AICommand = nil } else { cfg.AICommand = strings.Fields(val) } case "ai_budget": n, err := strconv.Atoi(val) if err != nil { return fmt.Errorf("%s:%d: ai_budget: %w", path, lineNo+1, err) } if n < 0 { return fmt.Errorf("%s:%d: ai_budget must be >= 0 (got %d)", path, lineNo+1, n) } cfg.AIBudget = n case "ai_provider": // Provider selector: `cli` | `anthropic`, or empty for auto. // An unknown value is tolerated and treated as auto (floor // invariant — never fail config loading on this key). cfg.AIProvider = val case "ai_model": // Opaque model identifier passed through to the provider. // Empty resets to the provider's own default. cfg.AIModel = val case "ai_api_key_env": // NAME of the env var holding the API key (never the value). // Empty resets to the default env-var name. if val == "" { cfg.AIAPIKeyEnv = DefaultAIAPIKeyEnv } else { cfg.AIAPIKeyEnv = val } case "session_settings_path": // Absolute path to the AI CLI's user-global settings file. // An empty value clears it (session-start stays unconfigured); // a relative value is rejected so the hook never edits a path // resolved against an unexpected working directory. if val == "" { cfg.SessionSettingsPath = "" } else if !filepath.IsAbs(val) { return fmt.Errorf("%s:%d: session_settings_path must be absolute (got %q)", path, lineNo+1, val) } else { cfg.SessionSettingsPath = val } case "session_start_docs": // Repeatable: each occurrence appends one repo-relative path // to the reading routine, in declared order. Absolute paths // and `..` traversal are rejected at parse time so the hook // only reads inside the repo. An empty value is ignored so a // blank override line does not produce a phantom entry. if val == "" { continue } if filepath.IsAbs(val) || strings.HasPrefix(val, "/") || strings.HasPrefix(val, `\`) { return fmt.Errorf("%s:%d: session_start_docs must be repo-relative (got %q)", path, lineNo+1, val) } cleanDoc := filepath.ToSlash(filepath.Clean(val)) if cleanDoc == ".." || strings.HasPrefix(cleanDoc, "../") { return fmt.Errorf("%s:%d: session_start_docs escapes the repo (got %q)", path, lineNo+1, val) } cfg.SessionStartDocs = append(cfg.SessionStartDocs, cleanDoc) case "session_files": // Repeatable: each occurrence appends one delivery target the // session-start hook writes a marker block to. An entry is // either repo-relative (held inside the repo by the same // path-traversal guard `session_start_docs` uses) or absolute // (mirrors the precedent set by `session_settings_path` for // the AI CLI's user-global file). An empty value is ignored // so a blank override line does not produce a phantom entry. if val == "" { continue } if strings.ContainsAny(val, " \t") { return fmt.Errorf("%s:%d: session_files: value %q must not contain whitespace", path, lineNo+1, val) } if filepath.IsAbs(val) { cfg.SessionFiles = append(cfg.SessionFiles, val) } else { if strings.HasPrefix(val, "/") || strings.HasPrefix(val, `\`) { return fmt.Errorf("%s:%d: session_files must be repo-relative or absolute (got %q)", path, lineNo+1, val) } cleanRel := filepath.ToSlash(filepath.Clean(val)) if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") { return fmt.Errorf("%s:%d: session_files escapes the repo (got %q)", path, lineNo+1, val) } cfg.SessionFiles = append(cfg.SessionFiles, cleanRel) } case "session_start_mailbox": // Repo-relative filename of the mailbox; empty disables the // block. Absolute paths and `..` traversal are rejected. if val == "" { cfg.SessionStartMailbox = "" } else if filepath.IsAbs(val) || strings.HasPrefix(val, "/") || strings.HasPrefix(val, `\`) { return fmt.Errorf("%s:%d: session_start_mailbox must be repo-relative (got %q)", path, lineNo+1, val) } else { cleanMb := filepath.ToSlash(filepath.Clean(val)) if cleanMb == ".." || strings.HasPrefix(cleanMb, "../") { return fmt.Errorf("%s:%d: session_start_mailbox escapes the repo (got %q)", path, lineNo+1, val) } cfg.SessionStartMailbox = cleanMb } case "session_start_roadmap_glob": // Glob relative to the repo root for live-planning discovery. // Empty disables discovery. The glob pattern itself is not // path-validated here; filepath.Glob will return no matches // for anything that escapes the repo. cfg.SessionStartRoadmapGlob = val case "handover_glob": // Glob relative to the repo root for the cockpit orient block's // newest handover note. Empty (the default) falls back to the // workspace notes dir. Not path-validated here; filepath.Glob // returns no match for anything that escapes the repo (mirrors // session_start_roadmap_glob). cfg.HandoverGlob = val case "bug_report_dir": // Workspace-relative directory for `eeco report-bug` records. // An empty value falls back to the default. Absolute paths // and `..` traversal are rejected so the write-scope guard // (Constraint 1) holds at parse time, not just at write time. // The unix-style-prefix check catches `/abs/path` even on // Windows, where filepath.IsAbs returns false without a // drive letter. if val == "" { cfg.BugReportDir = DefaultBugReportDir } else if filepath.IsAbs(val) || strings.HasPrefix(val, "/") || strings.HasPrefix(val, `\`) { return fmt.Errorf("%s:%d: bug_report_dir must be relative (got %q)", path, lineNo+1, val) } else { clean := filepath.ToSlash(filepath.Clean(val)) if clean == ".." || strings.HasPrefix(clean, "../") { return fmt.Errorf("%s:%d: bug_report_dir escapes the workspace (got %q)", path, lineNo+1, val) } cfg.BugReportDir = clean } case "pre_commit_workflows": // Repeatable: the first occurrence in the file resets the // binary default; each non-empty occurrence appends one // builtin workflow name. An empty value clears the list so // `eeco hooks pre-commit on` refuses to install (the // operator's explicit opt-out). Whitespace inside a value is // rejected so a stray `name1 name2` line is caught here // rather than silently producing one workflow with a broken // name. Workflow-name validity is checked at hook-install // time so the config package does not depend on the workflow // registry (cycle). if !sawPreCommitWorkflows { cfg.PreCommitWorkflows = nil sawPreCommitWorkflows = true } if val == "" { continue } if strings.ContainsAny(val, " \t") { return fmt.Errorf("%s:%d: pre_commit_workflows: name %q must not contain whitespace", path, lineNo+1, val) } cfg.PreCommitWorkflows = append(cfg.PreCommitWorkflows, val) case "post_merge_workflows": // Repeatable, mirroring pre_commit_workflows: the first // occurrence resets the binary default, each non-empty // occurrence appends one builtin workflow name, an empty value // clears the list so `eeco hooks post-merge on` refuses to // install. Whitespace inside a value is rejected. Workflow-name // validity is checked at hook-install time (registry cycle). if !sawPostMergeWorkflows { cfg.PostMergeWorkflows = nil sawPostMergeWorkflows = true } if val == "" { continue } if strings.ContainsAny(val, " \t") { return fmt.Errorf("%s:%d: post_merge_workflows: name %q must not contain whitespace", path, lineNo+1, val) } cfg.PostMergeWorkflows = append(cfg.PostMergeWorkflows, val) case "version_locations": // Repeatable: each occurrence appends one `path:regex` entry // the `version-sync` builtin reads to detect drift. The path // is repo-relative; absolute paths and `..` traversal are // rejected here so the gate never reads outside the repo. The // regex syntax is RE2 (Go's `regexp`) and must declare at // least one capture group; the workflow validates that at run // time. An empty value is ignored so a blank override line // does not produce a phantom entry. The reserved value `auto` // switches the gate to auto-detect; it must stand alone — // mixing it with explicit `path:regex` entries (in either // order, or declaring it twice) is rejected here. if val == "" { continue } autoDeclared := len(cfg.VersionLocations) == 1 && cfg.VersionLocations[0] == "auto" if val == "auto" { if len(cfg.VersionLocations) > 0 { return fmt.Errorf("%s:%d: version_locations=auto must be the only version_locations entry", path, lineNo+1) } cfg.VersionLocations = []string{"auto"} continue } if autoDeclared { return fmt.Errorf("%s:%d: version_locations=auto must be the only version_locations entry", path, lineNo+1) } relPart, _, hasColon := strings.Cut(val, ":") if !hasColon || relPart == "" { return fmt.Errorf("%s:%d: version_locations: expected \"path:regex\" or \"auto\" (got %q)", path, lineNo+1, val) } if filepath.IsAbs(relPart) || strings.HasPrefix(relPart, "/") || strings.HasPrefix(relPart, `\`) { return fmt.Errorf("%s:%d: version_locations path must be repo-relative (got %q)", path, lineNo+1, relPart) } cleanRel := filepath.ToSlash(filepath.Clean(relPart)) if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") { return fmt.Errorf("%s:%d: version_locations path escapes the repo (got %q)", path, lineNo+1, relPart) } cfg.VersionLocations = append(cfg.VersionLocations, val) case "version_anchor": // Single-valued. Three modes: empty (consistency-only, // default), "tag" (latest semver-shaped reachable git tag is // the source of truth), or ":" (designated file). // The designated-file form is path-validated here (same reject // table as `version_locations` and the other repo-relative // keys); regex validity is enforced at workflow run time. switch val { case "": cfg.VersionAnchor = "" case "tag": cfg.VersionAnchor = "tag" default: relPart, regexPart, hasColon := strings.Cut(val, ":") if !hasColon || relPart == "" || regexPart == "" { return fmt.Errorf("%s:%d: version_anchor: expected \"tag\" or \"path:regex\" (got %q)", path, lineNo+1, val) } if filepath.IsAbs(relPart) || strings.HasPrefix(relPart, "/") || strings.HasPrefix(relPart, `\`) { return fmt.Errorf("%s:%d: version_anchor path must be repo-relative (got %q)", path, lineNo+1, relPart) } cleanRel := filepath.ToSlash(filepath.Clean(relPart)) if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") { return fmt.Errorf("%s:%d: version_anchor path escapes the repo (got %q)", path, lineNo+1, relPart) } cfg.VersionAnchor = val } case "context_path": // Workspace-relative file `eeco go --write` renders the brief // into. An empty value falls back to the default. Absolute // paths and `..` traversal are rejected so the write-scope // guard (Constraint 1) holds at parse time, not just at write // time. The unix-style-prefix check catches `/abs/path` even // on Windows, where filepath.IsAbs returns false without a // drive letter. if val == "" { cfg.ContextPath = DefaultContextPath } else if filepath.IsAbs(val) || strings.HasPrefix(val, "/") || strings.HasPrefix(val, `\`) { return fmt.Errorf("%s:%d: context_path must be relative (got %q)", path, lineNo+1, val) } else { clean := filepath.ToSlash(filepath.Clean(val)) if clean == ".." || strings.HasPrefix(clean, "../") { return fmt.Errorf("%s:%d: context_path escapes the workspace (got %q)", path, lineNo+1, val) } cfg.ContextPath = clean } case "context_budget": // Byte cap on the file `eeco go --write` renders. An empty // value falls back to the default (0, no cap); a negative // value is rejected. 0 keeps the full brief. if val == "" { cfg.ContextBudget = DefaultContextBudget } else { n, err := strconv.Atoi(val) if err != nil { return fmt.Errorf("%s:%d: context_budget: %w", path, lineNo+1, err) } if n < 0 { return fmt.Errorf("%s:%d: context_budget must be >= 0 (got %d)", path, lineNo+1, n) } cfg.ContextBudget = n } case "brief_include_notes": // Opt into a "Recent notes" section in the `eeco go` brief. // Boolean, default false. An empty value falls back to the // default; any value strconv.ParseBool does not accept // ("true"/"false"/"1"/"0"/"t"/"f" in either case) is rejected // at parse time rather than silently defaulting, so a typo // surfaces immediately. if val == "" { cfg.BriefIncludeNotes = DefaultBriefIncludeNotes } else { b, err := strconv.ParseBool(val) if err != nil { return fmt.Errorf("%s:%d: brief_include_notes: %w", path, lineNo+1, err) } cfg.BriefIncludeNotes = b } case "session_start_pinned_bodies": // Opt into a fourth "pinned memory bodies" block on the // bundled session-start hook output. Boolean, default false. // Same parse contract as brief_include_notes — empty falls // back to the default, typos are loud. if val == "" { cfg.SessionStartPinnedBodies = DefaultSessionStartPinnedBodies } else { b, err := strconv.ParseBool(val) if err != nil { return fmt.Errorf("%s:%d: session_start_pinned_bodies: %w", path, lineNo+1, err) } cfg.SessionStartPinnedBodies = b } case "init_detection_threshold": // Confidence floor `eeco init` uses to accept the project-type // marker scan without prompting. An empty value falls back to // the default (0, which the detector reads as "use my built-in // default"). The value must be a fraction in [0,1]; anything // outside that range is rejected at parse time rather than // silently clamped, so a typo surfaces immediately. if val == "" { cfg.InitDetectionThreshold = 0 } else { f, err := strconv.ParseFloat(val, 64) if err != nil { return fmt.Errorf("%s:%d: init_detection_threshold: %w", path, lineNo+1, err) } if f < 0 || f > 1 { return fmt.Errorf("%s:%d: init_detection_threshold must be in [0,1] (got %s)", path, lineNo+1, val) } cfg.InitDetectionThreshold = f } default: // Unknown keys are tolerated for forward-compatibility. } } return nil } func unquote(s string) string { if len(s) >= 2 { first, last := s[0], s[len(s)-1] if (first == '"' || first == '\'') && first == last { return s[1 : len(s)-1] } } return s }