ajhahn.de
← eeco
Markdown 385 lines
<div align="center">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="../assets/eeco_logo_dark.png">
    <img src="../assets/eeco_logo_light.png" alt="eeco" width="280">
  </picture>

<h1>Architecture</h1>

<p><i>The directory tree and the runtime story behind a single eeco invocation.</i></p>

<p>
    <a href="../README.md"><b>README</b></a> ·
    <a href="../VISION.md"><b>Vision</b></a> ·
    <a href="COCKPIT.md"><b>Cockpit</b></a> ·
    <a href="USAGE.md"><b>Usage</b></a> ·
    <b>Architecture</b> ·
    <a href="PUBLIC_API.md"><b>Public API</b></a> ·
    <a href="../EXTENDING.md"><b>Extending</b></a> ·
    <a href="../CONTRIBUTING.md"><b>Contributing</b></a> ·
    <a href="UPGRADING.md"><b>Upgrading</b></a> ·
    <a href="../VERSIONING.md"><b>Versioning</b></a> ·
    <a href="../CHANGELOG.md"><b>Changelog</b></a> ·
    <a href="../SECURITY.md"><b>Security</b></a>
  </p>

</div>

---

A one-paragraph orientation, the directory tree, and the runtime story
behind a single `eeco` invocation. Companion to [`USAGE.md`](USAGE.md)
(the user-facing reference). The full specification lives in the build
runbook that ships with the source tree; this document is the short
overview for the operator and curious users.

## Overview

eeco is two complementary halves: a self-maintaining workflow ecosystem
(the heart) and a deterministic, no-AI-spend knowledge layer any AI
assistant can plug into. Architecturally, both are served by one process:

eeco is a single static Go binary that runs inside a target repository
while a developer is coding. It maintains a private, gitignored
workspace inside the repo (`.eeco/` by default) that holds the engine's
state, the memory store, scaffolded workflows, queue items, and hook
ledger. The binary detects the project profile, runs built-in or
user-scaffolded workflows under a strict exit-code contract, and gates
every AI call behind explicit consent and a budget cap. It never
commits, pushes, or activates a generated workflow on its own.

## Directory layout

```
cmd/eeco/
  main.go             CLI entry; subcommand dispatch (stdlib flag)
  init.go             eeco init (workspace bootstrap + .gitignore)
  initgit.go          init-only HOST .gitignore commit (sanctioned git write)
  historygit.go       private workspace-history repo ops (sanctioned git write)
  history.go          eeco history (log / snapshot)
  status.go           no-args path: digest or TUI
  run.go              eeco run [--ai] <workflow>
  new.go              eeco new <workflow> (template scaffolder)
  gc.go               eeco gc (memory garbage collection)
  hooks.go            eeco hooks (status / on|off / session-emit)
  update.go           eeco update (read-only remote tag check)
  doctor.go           eeco doctor (14 diagnostic probes)
internal/
  config/             repo-root + profile detection; config.local
                      loader; WriteLocalKeys upsert; SessionSettingsPath
  memory/             one-fact files, frontmatter, store, MEMORY.md
                      index, word-overlap Select, GC table
  workflow/           registry, exit-code contract (0/1/2/3),
                      Run + ScriptRun, embed.FS templates, attribution
                      Detector, builtin workflows
  ai/                 Provider interface; cliProvider (shells
                      ai_command); notConfigured stub; shared Gate
                      (consent, budget, parking); AI-call ledger;
                      background ProjectDigest/Understand
  queue/              append-only state/queue.md; portable presence
                      lock (.queue.lock); ErrLocked sentinel
  hooks/              pre-commit (SHA/marker exact-match reversible);
                      session-start + commit-guard (JSON edit + workspace
                      backup + validate + restore); state/hooks.json ledger
  gitx/               read-only git helpers: Available, TrackedFiles,
                      HeadSHA, ChangesSince, RemoteTags
  tui/                Bubble Tea control center; OneScreen digest;
                      dispatch, completion, styles
workflows/            embedded builtin workflow definitions (embed.FS)
scripts/              build.sh, gen-packaging.sh, release.sh, regen-demo.sh
Makefile              build / release / verify / gates / bench / packaging
.github/workflows/    ci.yml (matrix verify + Windows smoke);
                      release.yml (cross-build + sign + attest + upload)
docs/                 USAGE.md (user guide), ARCHITECTURE.md (this file),
                      UPGRADING.md (post-v0.1.0 upgrade notes)
```

## Component map

The runtime divides into seven concerns. Each is a leaf package under
`internal/`; nothing in `internal/` is part of the frozen public surface.

| Concern        | Package          | Responsibility                                                                 |
| -------------- | ---------------- | ------------------------------------------------------------------------------ |
| Config         | `internal/config` | Resolve repo root, detect profile, load `config.local`, write upserts.        |
| Memory         | `internal/memory` | Fact files, frontmatter parse/serialise, GC, `MEMORY.md` index, relevance.   |
| Workflow       | `internal/workflow` | Registry, runner, contract enforcement, attribution detector, scaffolder.  |
| AI provider    | `internal/ai`    | Provider interface, the CLI provider (shells `ai_command`), gating (consent / budget / park), AI-call ledger. |
| Queue          | `internal/queue` | Single decision channel; append, count, list/resolve under file lock.          |
| Hooks          | `internal/hooks` | Reversible pre-commit + session-start wiring; ledger of every install.         |
| Read-only git  | `internal/gitx`  | Tracked-set, HEAD SHA, change-since-SHA, remote tags. No write surface.        |
| TUI            | `internal/tui`   | Bubble Tea control center; non-TTY → `OneScreen` and exit 0.                   |

The CLI under `cmd/eeco/` is a thin dispatch layer over these packages.
A no-argument invocation prints the status digest in a piped or CI
environment, or launches the TUI on a real terminal.

**Git-write boundary.** Engine packages (`internal/*`, including `gitx`)
are read-only with respect to git; every git *write* lives in package
`main` so engine code cannot mutate history. There are exactly two such
sites: `initgit.go` (the one-shot `eeco init` `.gitignore` commit on the
HOST repo) and `historygit.go` (the optional private workspace-history
repo — a separate, local, no-remote git repo inside the gitignored
`<username>/`, which records only what eeco already writes there and never
touches the host's tracked tree). `historygit.go` guards the nested-repo
hazard — git searching upward to the host repo — with a `.git`
stat-check plus a `--show-toplevel` assert before every write.
`eeco history compact` rewrites the private repo's log (squash-all to one
parentless commit via `commit-tree` + `reset --soft`) within those same
guards, so the host tree is never touched and the kept tree is unchanged.

## Runtime story (typical `eeco run leak-guard`)

1. `cmd/eeco/main.go` parses the subcommand and flags (stdlib `flag`).
2. `internal/config` walks upward to the repo root, detects the profile
   (`go`, `python`, etc.), then applies configuration in three layers,
   each overriding the previous: built-in defaults, the user-global
   `config.local` (under `EECO_CONFIG_HOME` / `$XDG_CONFIG_HOME/eeco` /
   `~/.config/eeco`), and the workspace `<workspace>/config.local` if the
   workspace exists. A single `applyConfigFile` parses each file layer.
3. `cmd/eeco/run.go` constructs an `Env` (repo root, workspace path,
   profile, automation level, queue handle, memory handle, optional
   `Gate`) and looks up the named workflow in the registry.
4. The workflow runs with the repo root as its working directory. A
   builtin runs natively in Go; a user workflow runs through `ScriptRun`,
   which enforces the same contract.
5. Any AI call routes through `internal/ai.Gate`. The gate selects the
   provider (the CLI provider or the not-configured stub), enforces
   consent (`--ai` or `automation=auto`) and the per-invocation budget
   cap, and on any skip or failure parks the prompt under
   `<workspace>/state/parked/` and appends an `ai-parked` queue item.
   Every attempt — ran, parked, or gated-out — is recorded to
   `<workspace>/state/ai-calls.json`. eeco runs no in-binary model
   client and no agentic tool loop; it configures the harness that runs
   the AI (see `docs/COCKPIT.md`), and each gated pass is a single
   provider call.
6. Decision-bearing output (a finding, a proposal, a draft handover)
   becomes a queue item under a presence lock. Two concurrent writers
   see `queue.ErrLocked`; the loser exits cleanly without corruption.
7. The runner returns one of four exit codes (see below). The process
   exits with that code.

The TUI follows the same contract: every slash command dispatches to an
engine operation that already exists, and every free-text input is one
turn of a multi-turn conversation that passes through the same `Gate`
(built per turn, so each turn gets the configured budget).

## Workflow contract

Every workflow returns one of four exit codes:

| Code | Meaning            |
| ---- | ------------------ |
| `0`  | clean              |
| `1`  | finding or failure |
| `2`  | blocked (a required tool is missing) |
| `3`  | AI pass deferred (no consent)        |

A workflow writes only inside the workspace and routes any decision
through the queue. Gates report-and-fail; they do not queue.
`bug-sweep` and `handover-refresh` queue; `comment-hygiene` and
`leak-guard` do not.

## Trust boundaries

eeco has three concentric zones:

```
+--------------------------------------------------------------+
|  outside the repo                                            |
|  +--------------------------------------------------------+  |
|  |  tracked tree (eeco never writes here)                 |  |
|  |  +-----------------------------------------------+     |  |
|  |  |  workspace (gitignored — eeco writes only here) |   |  |
|  |  |  engine/  memory/  workflows/  state/  docs/    |   |  |
|  |  +-----------------------------------------------+     |  |
|  +--------------------------------------------------------+  |
+--------------------------------------------------------------+
```

Two opt-in, reversible touches escape the workspace:

- `.git/hooks/pre-commit` — local, repo-scoped, untracked. Installed
  only if no pre-commit hook exists; removed only when the on-disk
  script is byte-identical to what eeco wrote.
- Namespaced entries in the AI CLI's user-global JSON settings file:
  the session-start emitter and the opt-in commit-guard PreToolUse hook
  (which denies a `git commit` carrying AI attribution, in any repo). The
  exact path is supplied by `session_settings_path` in `config.local` or
  the `EECO_SESSION_SETTINGS` environment variable; unset means both are a
  no-op.

Both touches are recorded in `<workspace>/state/hooks.json`. Removal
restores the file to its prior state.

The path guard in `internal/config` refuses `..` traversal and rejects
any write target outside the workspace.

The control-center chat lets the model invoke tools, but the tool
registry is **read-only by construction**: only read-only capabilities
(search the knowledge layer, read the project brief, list open decisions,
list memory) are ever registered — no write, git, or otherwise mutating
verb is. The boundary therefore holds by absence of capability, not by a
runtime check, so the workspace-write and tracked-tree-never-written
guarantees survive even if the model is adversarial or hijacked. The
serialized arguments of every tool call also pass the pre-write
attribution scanner before the tool runs; a flagged call aborts the whole
pass before any tool executes.

## Public surface

From v0.1.0 eeco follows semver over an explicitly frozen public
surface (pre-stability; see [`VERSIONING.md`](../VERSIONING.md) §2.1): the
CLI commands and flags, the workflow exit-code contract
and `Env` shape, the read-only `gitx` helpers, the `config.local` keys,
the memory frontmatter, the queue and hook-ledger formats, the builtin
workflow names, and the first line of `eeco version`. The exhaustive
enumeration is the freeze contract in [`PUBLIC_API.md`](PUBLIC_API.md).

Internal package APIs under `internal/` remain unfrozen.

## Build and release pipeline

`Makefile` orchestrates everything: `make build` produces the local
binary with version metadata injected via `-ldflags`; `make verify`
runs `go build/vet/test`; `make gates` runs `comment-hygiene` and
`leak-guard` against the working tree; `make bench` runs the
build-tagged perf gate against a generated 50k-file fixture; `make
release` cross-builds the six-platform matrix into `dist/`; `make
packaging` emits `eeco.rb` and `eeco.json` from the checksums.

CI runs `make verify` and `make gates` on every PR and `main` push,
across a Linux/Windows matrix with a dedicated Windows smoke step. On
a `v*` tag push, the release workflow cross-builds the matrix, signs
`SHA256SUMS` with keyless cosign, attests every archive with GitHub
build provenance, generates the Homebrew formula and Scoop manifest,
and uploads eleven assets to the GitHub Release page.

## Known scaling limits

eeco is built for a single developer's repository and a small, curated
knowledge store; its costs are bounded by that scale, not by sub-linear
algorithms. The places where a cost grows with input are listed here with
their bound, the trigger that would justify revisiting them, and — where a
tempting optimization was considered and declined — why. None is a hot path
today; the entries exist so a maintainer inherits the reasoning instead of
rediscovering it.

**Filesystem walks.** Two non-test tree walks scale with input. The
`manifest-refresh` knowledge-tree walk (`internal/manifest`) only
*enumerates directories* — no file reads — so it is O(directories) and
cheap. The gate workflows `comment-hygiene` and `leak-guard`
(`internal/workflow/scan.go`) walk the whole working tree *and read every
text file* — O(files × size) — skipping `.git` and the workspace.
*Bound:* a `make bench` CI gate fails if a 50,000-file fixture scan exceeds
a 5-second wall (`internal/workflow/bench_test.go`), so the cost is
measured, not assumed. *Revisit when:* a real repo approaches that file
count, or the bench wall creeps toward the budget. *Not optimized:* no
incremental or cached scan — the bench shows comfortable headroom at 50k
files and a cache would add a staleness surface for no current gain.

**AI-call ledger.** Every gated AI attempt appends one record by reading,
re-marshalling, and rewriting the whole `{"records":[…]}` file
(`internal/ai/ledger.go`, `appendAICall`); `eeco stats` reads the whole
file once (`internal/ai/summary.go`). The cost is O(n) per append for n
lifetime attempts, with no cap; `state/evolve-history.json` shares the same
shape and discipline. *Bound:* realistic solo use is hundreds to low
thousands of records at roughly 300 bytes each — a sub-megabyte file
rewritten in well under a millisecond. *Revisit when:* the ledger reaches
multiple megabytes, or append / `eeco stats` latency becomes perceptible.
*Not optimized:* the on-disk shape is frozen (`docs/PUBLIC_API.md`), so an
append-only migration is a breaking change (see the register below); a
format-preserving record cap was considered and deferred — at sub-megabyte
sizes there is no real cost, and because the ledger is an audit trail a cap
must first decide count-versus-age and delete-versus-archive, which is its
own design and versioning pass when real scale demands it.

**Memory relevance.** Relevance ranking tokenises the query and scores
every fact by keyword overlap — O(n·m) for n facts and m query terms —
then, in `memory.Select`, bumps `last_used` and re-saves each matched fact
as one atomic whole-file write per match (`internal/memory/select.go`).
*Bound:* the store is a small, GC-curated set — dozens of facts in
practice — so O(n·m) is negligible. The live query path (`eeco go` and
`eeco ask`, via `internal/ask`) runs the same overlap scan but deliberately
does **not** bump-and-save, so the per-match write cost is not paid today;
`memory.Select` itself is currently unused in production. *Revisit when:*
`Select` is wired into a frequent path, or the fact count grows past a few
hundred. *Not optimized:* no inverted index or ranking rewrite —
unjustified at a curated store of this size, where recall matters more than
speed (see the register).

**Brief budget ladder.** When `context_budget` is set, `eeco go --write`
collects the brief once and then re-renders it down a fixed ladder (full,
then progressively capped tiers) until it fits (`internal/brief`,
`RenderWithinBudget`). The expensive step — collecting memory, git state,
and workflows — runs **once**; only the in-memory Markdown render repeats,
at most about seven times, over already-trimmed slices. *Bound:* the ladder
is a fixed ≤7 rungs of cheap string building, and the feature is opt-in —
with no `context_budget` (the default) the ladder never runs. *Revisit
when:* effectively never at current brief sizes. *Not optimized:* no
incremental trimming or binary search over the ladder — at most seven
string builds do not justify the complexity.

**Read-only git helpers.** Every `internal/gitx` helper forks one `git`
subprocess per call, with no caching. Most are called once per invocation,
but two paths repeat the same call in one process: `evolve` calls
`ChangesSince` twice per run, and `memory-drift` calls `LastCommitDate`
once per fact carrying a `ref` (O(facts)). *Bound:* a subprocess fork costs
single-digit milliseconds, and both repeating paths are on-demand
maintenance workflows, not interactive hot paths. *Revisit when:* a future
interactive or per-keystroke path calls the same helper repeatedly in one
process. *Not optimized:* no gitx memoization — caching a HEAD SHA or
tracked set inside a process that may itself drive git writes (the private
workspace-history repo) adds a staleness surface on a trust-boundary read,
to save roughly one fork in a non-interactive workflow; the trade is not
worth it.

**Explicitly not optimized.** Recorded so they are not re-litigated:

- **gitx result caching** — declined; staleness on a trust-boundary read
  outweighs roughly one saved subprocess fork in a non-interactive
  workflow.
- **AI-ledger append-only / JSONL migration** — would end the
  full-rewrite-per-append, but changes the frozen `{"records":[…]}` shape
  (`docs/PUBLIC_API.md`) and is therefore a major-version, separate
  planning pass — not done now.
- **Memory ranking rewrite (inverted index or scoring overhaul)**
  unjustified at a curated dozens-of-facts store; recall matters more than
  speed at this scale.

## Extension seams

Adding a new capability means registering it at one known point and, for
the seams that are irreducibly multi-file, touching a short fixed set of
sites. This table is the maintainer's map: where each kind of extension
is registered, what else it touches, and how much friction that is. It
records the one HIGH-friction seam — a new hook type — as intentional
rather than as debt. The companion how-to for a newcomer is
[`EXTENDING.md`](../EXTENDING.md), which expands these rows into worked examples; this
section is the authoritative source of the registration points.

| Extension | Register at | Also touch | Friction |
| --- | --- | --- | --- |
| CLI verb | `cmd/eeco/main.go` (the `run` dispatch switch) | the `usage` const in the same file; a new `cmd/eeco/<verb>.go` runner, reusing the `loadInitedConfig` / `loadRepoConfig` / `newFlagSet` guards in `helpers.go` | low |
| Builtin workflow | `internal/workflow/registry.go` (the `DefaultRegistry` slice) | a new `internal/workflow/<name>.go` implementing the workflow interface (`Name` / `Summary` / `Run`) | low |
| `config.local` key | `internal/config` (the `Config` struct) | the config parse loop that assigns it; a `Default…` const if it carries a default | low |
| Memory frontmatter field | `internal/memory/fact.go` (the `Fact` struct) | `frontmatter.go` — the `setField` parse switch and the `Serialize` writer (and `Validate` if the field is constrained) | medium |
| AI provider | `internal/ai` (the `Select` chooser) | the new provider type (`Name` / `Run`); the `config.local` key that selects it, plus its parse | medium |
| TUI slash-command | `internal/tui/commands.go` (the `commandIndex`) | the `dispatch` switch in the same file; tab-completion is derived automatically | low |
| Hook type | `internal/hooks/hooks.go` (the name const + the `Names` list) | the `ledger` struct field; the enable / disable / refresh trio; the `Status` line; the `cmd/eeco/hooks.go` dispatch; and the session-emit path if the hook emits | HIGH |

The hook-type seam is the one irreducible multi-touch point, and the
friction is the price of the trust boundary. A hook is an opt-in,
reversible escape from the workspace (see Trust boundaries), so every
hook type carries a ledger field that records its install for exact,
byte-identical removal — and that ledger, the enable/disable/refresh
trio, the status read-out, and the CLI dispatch must stay in lock-step
with it. The coupling is deliberate: it is what makes a hook removable
without guesswork. New seams should prefer the single-registration shape
of the rows above; add a hook type only when the capability genuinely
needs to escape the workspace.

---

[← Prev: Usage](USAGE.md) · [Next: Public API →](PUBLIC_API.md)