ajhahn.de
← eeco
Go 1206 lines
// 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 <repo>/<username>/. 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
// (<repo>/<username>/.eeco).
const DefaultWorkspace = ".eeco"

// UsernameEnv, when set, overrides the username used to scope the
// workspace under <repo>/<username>/. 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 (<repo>/<username>/) 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 <RepoRoot>/<Username>/ 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,
	// <RepoRoot>/<Username>/. 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 <workspace>/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 <workspace>/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.
	//   "<path>:<regex>" — 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 (<username>/.git, created by
// `eeco init`) so that running from inside <username>/ — 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 <userDir>/.git with the engine workspace <userDir>/.eeco beside it;
// a host project root never has the workspace directory directly under it (the
// workspace is always nested under <username>/). 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 <repo>/<username>/. 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
// <workspace>/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 <workspace>/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 "<path>:<regex>" (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
}