plain text 1499 lines
Everything eeco can do. The runtime story, directory layout, trust
boundaries, and frozen public surface are documented in
ARCHITECTURE.md (ARCHITECTURE.md).
1. Install
──────────
Single static binary, zero runtime dependencies. Four install routes:
pre-built binary, Homebrew, Scoop, from source.
1.1 Pre-built binary (recommended)
Download the archive for your platform from the
releases page (https://github.com/ajhahnde/eeco/releases), verify it
against SHA256SUMS, extract the eeco binary, and put it on your
PATH.
┌─────────┬───────┬────────────────────────────────────┐
│ OS │ Arch │ Archive │
├─────────┼───────┼────────────────────────────────────┤
│ darwin │ amd64 │ eeco_<version>_darwin_amd64.tar.gz │
│ darwin │ arm64 │ eeco_<version>_darwin_arm64.tar.gz │
│ linux │ amd64 │ eeco_<version>_linux_amd64.tar.gz │
│ linux │ arm64 │ eeco_<version>_linux_arm64.tar.gz │
│ windows │ amd64 │ eeco_<version>_windows_amd64.zip │
│ windows │ arm64 │ eeco_<version>_windows_arm64.zip │
└─────────┴───────┴────────────────────────────────────┘
Checksum verification:
shasum -a 256 -c SHA256SUMS
Signature and provenance (recommended). SHA256SUMS is signed
keylessly with cosign — no key to fetch or trust on file; the signing
identity is the release workflow itself. Verify it:
cosign verify-blob \
--certificate SHA256SUMS.pem \
--certificate-identity-regexp \
'^https://github.com/ajhahnde/eeco/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--signature SHA256SUMS.sig \
SHA256SUMS
Each archive also carries GitHub build provenance. Verify a downloaded
archive came from this repository's release workflow:
gh attestation verify eeco_<version>_<os>_<arch>.<ext> --repo ajhahnde/eeco
macOS Gatekeeper. The darwin binaries are distributed unsigned by
design; their integrity is established by the SHA256SUMS signature
and provenance above rather than Apple's notary service. After
extracting, clear the quarantine attribute the browser sets on the
download:
xattr -d com.apple.quarantine ./eeco
The cosign + provenance route above is the supported integrity check
on macOS; the darwin binaries are intentionally distributed without an
Apple Developer ID signature.
1.2 From source
Requires Go 1.24+.
Released archives are produced by GitHub Actions on every v* tag and
uploaded to the releases page (https://github.com/ajhahnde/eeco/releases).
Running make release locally is an alternate path — useful for
offline rebuilds and for verifying that a published release is
byte-identical to a fresh build from the tag.
Reproducing the released binaries byte-for-byte requires the same Go
toolchain CI used. go.mod pins it; when your installed Go differs,
set GOTOOLCHAIN=go1.24.2 so the build fetches and uses the pinned
version.
git clone https://github.com/ajhahnde/eeco
cd eeco
make build # ./eeco with injected version
make release VERSION=vX.Y.Z # cross-build the published matrix into dist/
make verify # go build ./... && go vet ./... && go test ./...
make build produces ./eeco with version, commit, and buildDate
injected via -ldflags so eeco version reports the real build
identity. make release writes archives plus SHA256SUMS into dist/;
make packaging writes eeco.rb and eeco.json from the checksums.
1.3 Homebrew (macOS, Linux)
brew install ajhahnde/eeco/eeco
Installs the binary from the latest tagged release via the
ajhahnde/eeco tap. brew upgrade eeco follows new tags. The formula
is generated per release and published with the release assets as
eeco.rb.
1.4 Scoop (Windows)
scoop bucket add eeco https://github.com/ajhahnde/scoop-eeco
scoop install eeco
scoop update eeco follows new tags. The manifest is published with
the release assets as eeco.json.
2. First use in a project — eeco init
─────────────────────────────────────
cd /path/to/your/repo
eeco init
eeco init:
• creates a private, per-user workspace at <repo>/<owner>/.eeco/
and adds /<owner>/ to .gitignore if it is not already ignored, so
nothing eeco does can leak into your tracked history. <owner> is your
git user.name; override it with --username NAME or the
EECO_USERNAME environment variable (resolution order: flag, env, git
user.name, then a prompt). --workspace NAME renames the inner
.eeco engine directory;
• scaffolds the engine, memory store, builtin workflows, and state
inside that directory;
• detects the project profile (zig, python, node, go, generic) and
the matching parse/build gate, and classifies the project type to
choose the knowledge directories it scaffolds. Detection is a
deterministic marker scan; init_detection_threshold (a value in
[0,1] in config.local) is the confidence at or above which init
accepts the scan without prompting (0, the default, uses the
detector's own default). --type CATEGORY forces a type and skips
detection; --ai opts into a single gated AI pass for an ambiguous
tree.
The one commit eeco makes. When init adds the .gitignore line it
stages *only that file*, commits it as eeco init, and pushes — the
single sanctioned write to your git history (everything else eeco writes
stays in the gitignored workspace). --no-commit skips the commit and
push; --no-push commits locally without pushing. On a non-git or
not-yet-real repo the step is skipped with a manual hint, and any git
failure is a warning — init never fails because of it.
Migrating an older workspace. If a legacy <repo>/.eeco workspace
from before the per-user layout is found, init reports it and offers to
run eeco migrate v1 (§4) before continuing; declining makes no changes.
This is also the "new project, professional setup" path: one command
gives a repo its private workspace plus the whole ecosystem.
3. The control center (TUI) — eeco
──────────────────────────────────
Run eeco with no arguments to open the control center. Not a terminal
(piped/CI)? It prints a one-screen status digest and exits 0 instead.
The control center opens on a home screen: a centred eeco logo at the
top, the version and one rotating usage tip below it, a hint above the
input, and a thin dim status footer (repo, profile, gate, automation,
memory and queue counts, hook state, last run) below the input. Every
command is discoverable by typing / (the slash-command palette) or
pressing ? (the shortcut overlay), so the home screen stays
uncluttered. Typing or running a command collapses the home view to
input + footer so streamed output reads cleanly above; clearing the
input recovers the home view. Output stays in the normal terminal
scrollback after you quit — no full-screen takeover.
Typing / opens a slash-command palette: a live dropdown of every
command with its one-line purpose, between the input and the footer.
Filter it by typing (/h narrows to /help and /hooks), move the
highlight with Up/Down, and accept the selection with Tab or Enter; the
palette closes on a space, a newline, or when the input clears.
The input is a multi-line composer: type or paste a request that spans
several lines. Enter submits; Alt+Enter or Ctrl+J inserts a newline
(Ctrl+J is the fallback where a terminal swallows Alt+Enter). The box
grows with the draft up to eight rows and then scrolls internally,
shrinking back when you submit or clear.
Free text (no leading slash) no longer opens an in-binary chat. eeco
configures the harness that runs the AI (see eeco cockpit), so the
control center stays a deterministic command surface rather than a
second-rate agent: a free-text line prints a dim one-line reminder to
use a slash command (/run, /memory, …) or ? for help, and spends
nothing. The in-binary chat turn, its read-only tool loop, and /clear
were retired in the move to the cockpit model.
Input accepts:
┌─────────────────────┬─────────────────────────────────────────────────────────┐
│ Input │ What it does │
├─────────────────────┼─────────────────────────────────────────────────────────┤
│ /run [--ai] <wf> │ run a workflow (--ai opts into one gated pass) │
│ /queue │ show items awaiting a decision │
│ /memory │ list stored facts │
│ /gc │ run memory garbage collection │
│ /new <wf> │ scaffold a new workflow │
│ /hooks [<n> on|off] │ show or toggle the opt-in reversible hooks │
│ /settings [<k> <v>] │ view or set the AI config (persists to config.local) │
│ /help │ command and key reference │
│ /quit │ leave the control center │
│ plain text │ a dim reminder to use a slash command (chat is retired) │
└─────────────────────┴─────────────────────────────────────────────────────────┘
Keys: Up/Down browse command history at the top and bottom of the
composer (and move the cursor within a multi-line draft otherwise, or
move the palette highlight when it is open), Tab completes a command or
workflow name, Alt+Enter / Ctrl+J insert a newline, ? toggles a
shortcut overlay, Esc interrupts a running task — an animated spinner in
the footer marks one in flight. q (on an empty line), /quit, and
Ctrl-C always leave the terminal in a sane state. NO_COLOR is honoured.
Plain text never spends silently: each conversation turn is consented and
budget-capped, and parked-and-queued when it cannot run (the conversation
does not advance on a parked turn). Commands never require AI.
4. Commands
───────────
eeco open the control center (digest if non-TTY)
eeco init [--no-track] [--from <path>] bootstrap the ecosystem in this repo (--from imports settings from another eeco project; --no-track skips the private workspace-history repo)
eeco migrate v1 [--yes] move a legacy <repo>/.eeco workspace under <username>/
eeco run <workflow> run one workflow (read-only / safe by default)
eeco run --ai <workflow> allow this run's gated, budget-capped AI pass
eeco new <workflow> scaffold a new workflow from the template
eeco gc run memory garbage collection
eeco queue print the workspace queue (resolve by editing queue.md)
eeco stats print cumulative AI usage from the call ledger (real token counts)
eeco hooks <name> on|off toggle an opt-in hook (reversible; names: pre-commit, post-merge, session-start, commit-msg, commit-guard)
eeco hooks session-start refresh re-render every session_files block from current project state
eeco hooks <name> refresh rewrite an installed hook (pre-commit, post-merge, commit-msg, commit-guard) with the current eeco binary path
eeco config list show every config key, its effective value, and origin (default | global | local)
eeco config get <key> print the effective value of one config key
eeco config set [--global] <key> <value> set a key in this workspace, or (--global) in the cross-project layer
eeco config import [--force] <path> copy config.local, cockpit.json, and workflows from another eeco project
eeco cockpit target [--global] list|add <t>|rm <t> manage the active (or cross-project) harness target set
eeco gates check-attribution [flags] scan tracked files + commit bodies for AI-attribution fingerprints
eeco update [--apply] check for a newer release; --apply verifies + swaps
eeco doctor run workspace and config diagnostics
eeco go [--brief] [--metrics] [--write|--json|--copy] print an AI-ready brief; --brief trims it; --metrics adds a stderr readout; --json emits JSON, --write saves, --copy clipboards
eeco ask [--limit N] [--json] "<question>" answer a question with ranked file:line pointers (no AI)
eeco docs new [--overwrite] <target> scaffold a tracked-tree doc (targets: vision, readme)
eeco docs refresh <target> re-render a scaffolded doc's marker-wrapped block (targets: vision, readme)
eeco docs compact [--keep-last N --heading <prefix>] [--archive <path>] [--dry-run] <path> archive old regions of <path> (markers, or heading-discovered with --keep-last) into a sibling
eeco history show the private workspace-history log (recent commits)
eeco history snapshot [-m <msg>] commit the current workspace state into the private history repo
eeco history compact [--dry-run] [--yes] squash the private history log into one commit (reflog-recoverable)
eeco guide page the in-binary user manual through the host pager
eeco uninstall [--yes] write a handoff summary, print the removal command, and de-init the private history repo (--yes skips the confirm)
eeco report-bug [--submit] file a structured bug report (--submit opens the pre-filled issue URL in your browser)
eeco add note "<text>" append a free-form note to the workspace
eeco add fact --description "<s>" [flags] "<body>" record a durable memory fact
eeco add task "<title>" append an item to the workspace queue
eeco adaptations <name> on|off toggle an AI-adaptation fact on or off
eeco workflows [<name> on|off] list scaffolded workflows or toggle one on/off
eeco show notes list the workspace notes, newest first
eeco show adaptations list AI-adaptation facts with their on/off state
eeco show prompt [name] list the prompt library or print one prompt body
eeco refresh-manifest [<dir>] rebuild the .ai.json manifests in the knowledge dirs
eeco version print the version
eeco help command reference
Exit codes (also the workflow contract): 0 clean ·
1 finding/failure · 2 blocked (a required tool is missing) ·
3 AI pass deferred (no --ai).
4a. Configuration — eeco config
───────────────────────────────
Configuration resolves in three layers, each overriding the one
before it:
built-in defaults
→ user-global ~/.config/eeco/config.local (shared by every project)
→ workspace <repo>/<user>/.eeco/config.local (this project only)
Inspect and edit the two file layers with eeco config:
eeco config list # every key, effective value, and origin
eeco config get automation # one effective value, bare (for scripts)
eeco config set automation=manual # write the workspace layer (this project)
eeco config set --global automation=auto # write the cross-project layer
set accepts key=value or key value. A value is validated against the
same rules config.local uses, so a typo'd key or a malformed number is
rejected before anything is written. (Floor-invariant keys such as
automation tolerate any value and normalize at load, exactly as they do
in a hand-edited config.local.)
Sharing settings across projects — the global layer
Set a key once with --global and every project inherits it, unless
that project overrides it in its own workspace config.local. This is the
git --global model: machine-wide defaults with per-repo overrides. The
global file lives at ~/.config/eeco/config.local — or
$XDG_CONFIG_HOME/eeco/config.local, or wherever EECO_CONFIG_HOME
points (the override also makes the layer hermetic under tests).
eeco config set --global … is the one eeco command that writes outside a
repository, by design; everything else stays inside the project. It does
not need an initialised workspace, or even to be inside a repo.
Cockpit targets share the same model:
eeco cockpit target --global add cursor # new projects inherit this target set
eeco cockpit target list # falls back to the global set when this
# project has no cockpit.json of its own
Copying settings from one specific project — --from / import
Where the global layer is a *live* shared default, --from is a *one-shot
copy* from one named project into another. It carries three things —
config.local, the cockpit selection (cockpit.json), and the scaffolded
workflows/ — and nothing else (project-specific knowledge, state, and bug
reports never travel).
eeco init --from ~/other-project # bootstrap a new repo, then copy from another
eeco config import ~/other-project # copy into an already-initialised project
eeco config import --force ~/other-project # let the source win over existing files/keys
The source path may be the other repo's root or any directory inside it. Into a
fresh workspace the config.local is copied verbatim (full fidelity); into
an existing one it is key-merged. Without --force, files and keys the
destination already has are preserved; --force lets the source win. Importing
from a project of a different type may pull in type-specific keys (profile,
gate) — eeco config set fixes any you don't want.
5. Builtin workflows
────────────────────
┌──────────────────┬──────────────────────────────────────┬─────────────────┬─────┐
│ Workflow │ Inspects │ Writes │ AI │
├──────────────────┼──────────────────────────────────────┼─────────────────┼─────┤
│ comment-hygiene │ source/docs for tooling fingerprints │ nothing │ no │
│ leak-guard │ staged + tracked tree, commit msg │ nothing │ no │
│ version-sync │ declared version_locations │ nothing │ no │
│ gate │ the declared gate command chain │ nothing │ no │
│ bug-sweep │ code, statically │ a triage ledger │ opt │
│ handover-refresh │ code/docs/git history │ queue proposal │ opt │
│ evolve │ run history + repeated actions │ queue │ opt │
│ memory-drift │ memory facts with a ref │ queue │ no │
│ doc-drift │ CHANGELOG.md vs git tags │ queue │ no │
│ manifest-refresh │ knowledge dirs (paths + kinds) │ .ai.json │ no │
└──────────────────┴──────────────────────────────────────┴─────────────────┴─────┘
• comment-hygiene fails if any shippable file carries an
AI-attribution / tooling string.
• leak-guard blocks a commit that would leak an attribution
string, a Co-Authored-By trailer, or a private-workspace path into
tracked files.
• version-sync reports drift between the version strings declared
in <workspace>/config.local's version_locations list. Each entry
is a path:regex pair (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). With no version_locations
entries the workflow exits 0 (the gate is opt-in per project). Use
the multiline flag (?m) when the regex anchors on the start of a
line further down a file — Go's ^ is start-of-string by default.
Set version_locations=auto instead of an explicit list to let
version-sync discover the version locations itself. In auto mode it
scans a fixed, high-precision set of common version files — VERSION,
CHANGELOG.md (the first ## [vX.Y.Z] heading), package.json,
pyproject.toml, and Cargo.toml — and reports drift across
whichever of them the project actually has. A file that is absent, or
present but carrying no version-shaped string, is skipped. auto
cannot be mixed with explicit path:regex entries — it is the whole
list or none of it — and composes with version_anchor the same way
an explicit list does.
The version_anchor config key selects the source of truth declared
locations are checked against. Three modes:
• Unset (default) — consistency-only. The first declared location
is the anchor; the rest must match it. Exits 0 when every declared
location agrees, 1 when any drifts, and 2 when a declared path is
missing on disk. The slice-1 behaviour, preserved for backward
compatibility.
• version_anchor=tag — tag-anchor. The latest semver-shaped
(vX.Y.Z) tag reachable from HEAD is the source of truth.
Declared locations must be semver->= the tag, so a release commit
can bump declared locations ahead of the not-yet-pushed tag (the
tag is pushed after the commit). Backward-drift still fails. If no
semver-shaped tag is reachable yet, falls back to the
consistency-only behaviour with a note in the summary.
• version_anchor=<path>:<regex> — designated-file mode. Same
shape as a version_locations entry; the captured version is the
source of truth and every declared location must strict-equal it.
A missing path exits 2 (blocked) so a typo surfaces rather than
silently falling through to consistency-only.
Worked example. Declare two entries that both must carry the latest
released tag — the CHANGELOG.md heading and the matching footer
compare-link, which often drift when a release bumps one but
forgets the other — and enable tag-anchor so a release commit's
bump catches a forgotten location:
version_locations=CHANGELOG.md:(?m)^## \[v(\d+\.\d+\.\d+)\]
version_locations=CHANGELOG.md:(?m)^\[v(\d+\.\d+\.\d+)\]: https://github
version_anchor=tag
Once version_locations is declared, wire the gate into the
pre-commit hook so drift is caught before the commit lands rather
than only under make gates: eeco hooks pre-commit on installs a
.git/hooks/pre-commit that runs the default workflow chain
(leak-guard + version-sync) and blocks the commit on a non-zero
exit. See §9 for the override knob and the byte-identical reversal
contract.
• gate runs the project's declared parse/build gate — the ordered
command chain in <workspace>/config.local's gate key — step by
step, with the repository root as the working directory, stopping at
the first failure. The gate key is repeatable: each occurrence adds
one step (a whitespace-split command), and the first occurrence resets
the profile default so the operator-declared chain fully replaces it.
A bare single-command gate is a one-step chain; a project with no
gate (the generic profile, or a lone gate=) is a clean no-op, so the
gate is opt-in per project. Every step's command is checked on PATH
before the first step runs, so a chain that cannot complete exits 2
(blocked); a step that exits non-zero exits 1 and stops the chain.
Run it with eeco run gate, or add gate to pre_commit_workflows
(§9) to run the whole validation chain before each commit.
Worked example. A Go project that wants vet, a static linter, and the
unit tests as one gate, run in order and stopping at the first to
fail:
gate=go vet ./...
gate=staticcheck ./...
gate=go test ./...
• bug-sweep does a static read and maintains an append-only triage
ledger (open findings → resolved log); with --ai it performs the
reasoning pass, otherwise it reports and parks the prompt.
• handover-refresh drafts a dated handover plus a "what's now
dead" diff and queues it for your approval — it never overwrites a
note directly.
• evolve watches for repetition and *proposes* new workflows into
the queue. It never activates one on its own. The
proposal pass is two-stage: a deterministic signal extraction
scans the most recent 40 commit subjects for repeated
conventional-commit types (<type>(<scope>)?!?: …) and surfaces one
workflow candidate per type that appears at least three times (cap
five candidates per run, sorted count-desc then key-asc). Each
candidate becomes its own evolve queue item — the operator
resolves or dismisses one at a time. The gated AI pass becomes
optional enrichment layered on top: --ai (or automation=auto)
opts into it; on no consent or any provider skip eeco still exits 0
when the deterministic pass found at least one candidate, and only
preserves the exit-3 (AI deferred) contract when there is genuinely
nothing to report — no consent and no deterministic candidates.
It also maintains a repetition ledger at
<workspace>/state/evolve-history.json. Each surfaced candidate
writes one record (signal kind + key, count at proposal, the queue
row's kind + title, the proposal timestamp); a re-run suppresses any
candidate whose (signal_kind, signal_key) is already in the
ledger, so a recurring signal is proposed exactly once in its
lifetime even after the queue item is ticked. Each run also
reconciles unresolved records against the queue: a record whose
queue row is now checked flips to resolved: true with a
resolved_at timestamp. Reconciliation runs once per evolve
invocation — the queue is not watched live, so an operator who
ticks a row between runs sees the ledger update on the next
eeco run evolve. The re-propose-on-signal-recurrence knob is a
follow-on slice; today a resolved record still suppresses. A
future slice may add a file-touch signal alongside the commit-type
one (deferred because it needs a new gitx helper).
• memory-drift flags a memory fact whose ref: file has been
committed on a later calendar day than the fact's own created: date
— a sign the fact may now describe code that has moved on. eeco gc
already catches a ref: that no longer exists on disk; memory-drift
catches the complementary case where the file is still there but has
changed since the fact was written. One review item per stale fact is
routed to the queue — eeco flags the drift, you reconcile the fact. It
needs git on PATH (it exits 2, blocked, without it) and exits 0 when
every ref-carrying fact is still current. Run it with `eeco run
memory-drift`, or wire it to run automatically after a merge with the
post-merge hook (§9), where it is in the default workflow list.
• doc-drift flags drift between the release sections in
CHANGELOG.md and the project's git tags. Two cases: a vX.Y.Z git
tag with no ## [vX.Y.Z] CHANGELOG section (a release that was never
documented), and a ## [vX.Y.Z] section with no matching tag (a
release documented but never tagged). The newest section is exempt: a
release commit adds the ## [vX.Y.Z] heading before the vX.Y.Z tag
is pushed, so a section strictly ahead of the latest tag is the
expected release-in-progress state, not drift. One review item per
drift is routed to the queue — eeco flags it, you reconcile the
CHANGELOG.md or the tag. It needs git on PATH (it exits 2,
blocked, without it) and exits 0 when CHANGELOG.md is absent, no
tags exist yet, or every tag and section agree. Run it with `eeco run
doc-drift, or let the post-merge` hook (§9) run it automatically — it
is in that hook's default workflow list alongside memory-drift and
manifest-refresh.
• manifest-refresh rebuilds the per-directory .ai.json manifests
across the knowledge dirs — the project-type directories scaffolded
under <repo>/<owner>/ as siblings of the engine workspace. Each
manifest is a deterministic skeleton — a JSON document `{"dir",
"purpose"?, "items":[{"path","kind"}…]} with kind either "file"`
or "dir", sorted by path; the optional desc/find_when fields are
reserved for a future enrichment pass and stay empty in the
deterministic walk. The .ai.json file is skipped in its own walk, so
a re-run over unchanged dirs reproduces byte-identical output. Writes
land in the gitignored per-user area, never the tracked tree; it needs
no AI and never blocks. Run it on demand with `eeco refresh-manifest
[<dir>]` (the same deterministic rebuild, optionally scoped to one
knowledge dir), or let the post-merge hook (§9) run it.
Run any of them from the TUI or eeco run <name>.
These ten are the complete builtin set. Additional builtin workflows
are tracked as a post-v0.1.0 item.
Scaffolding a workflow with eeco new
eeco new <name> scaffolds a user workflow under
<workspace>/workflows/<name>/ from a per-profile template. The
scaffold layout is picked from the project's detected profile:
• go profile — run PATH-checks go and runs go vet ./... as
the inert default.
• python profile — run PATH-checks python3 and runs
python3 -m compileall -q . as the inert default.
• generic profile (and any profile without a dedicated template:
zig, rust, node) — run is a bare sh stub that prints a
placeholder line and exits 0.
In every case the scaffolded run is a starting point — replace the
default body with the real check this workflow is meant to perform.
The chosen template is internal to eeco; the verb shape `eeco new
<name>` is unchanged.
Toggling a scaffolded workflow on or off
Once scaffolded, a workflow is on by default; eeco runs it
whenever it is asked to (a manual eeco run <name>, a hook chain
that names it, the TUI). To pause a workflow without deleting it:
eeco workflows <name> off # mark <name> as disabled
eeco workflows <name> on # bring <name> back online
eeco workflows # list every scaffolded workflow + state
off plants an empty sentinel marker file at
<workspace>/workflows/<name>/disabled; the loader sees it and
reports the workflow as blocked (exit 2) without running it. on
removes the marker. Repeating the same action is a clean no-op.
Builtin workflows are unaffected by this toggle — their contract is
part of the frozen public surface. To remove a scaffolded workflow
for good, delete its directory.
6. Memory and garbage collection
────────────────────────────────
The store holds one fact per file (flat frontmatter: name, description,
type, dates, optional ref:/expires:, pin:), with a regenerated
MEMORY.md index and [[links]] between facts. Types: user,
feedback, project, reference, finding.
eeco gc (or the Memory screen) keeps the store honest:
• a memory whose ref: path is gone, that has expired, a resolved
finding, or an unused reference past the stale window is archived
to the recoverable attic;
• anything load-bearing (project/feedback/user) is **queued for
your decision**, never silently deleted;
• pin: true exempts a fact permanently.
This is automated and conservative — the algorithm never loses a fact
that still matters; it asks you instead.
Re-running eeco gc (for example through the post-merge hook chain)
no longer files duplicate gc-review items for an unresolved finding:
an open queue row for the same fact + reason is recognised and the
second filing is skipped. Resolving the row (checking its box) lets a
future trigger file again, so the queue is never wedged on a stale
recommendation.
eeco add fact records a fact without hand-authoring the frontmatter:
eeco add fact --description "<summary>" [--type <type>] [--name <slug>] [--ref <path>] [--pin] [--provenance <text>] [--agent <name>] "<body>"
It writes one frontmatter file under <workspace>/memory/ and
regenerates MEMORY.md. --description is the one-line summary that
drives relevance matching and is required. --type is one of user,
feedback, project, reference, finding and defaults to project
— the safe default, since project/user/feedback facts are queued
for review rather than archived. --name is the lower-kebab-case
filename stem; when omitted it is derived from --description. --ref
records a repo-relative path the fact documents (garbage collection
validates it); --pin exempts the fact from collection.
--provenance "<text>" records the snippet that triggered the fact
(source: in frontmatter, capped at 120 characters); --agent <name>
records which assistant filed it (agent:). Both are **required for
--type feedback and --type user** — those types are durable
adaptations to the operator, and an unauthored adaptation is the silent
drift eeco show adaptations exists to surface. Hand-authored facts
remain permissive: an older fact file without the new fields still
loads.
eeco refuses to overwrite an existing fact — pass a different --name,
or edit the file directly. The command needs an initialised workspace;
it writes only inside it (Constraint 1) and never stages or commits
(Constraint 6). Editing a fact later is `$EDITOR
<workspace>/memory/<name>.md; removing one is rm`.
Auditing AI adaptations
A feedback- or user-typed memory fact is a durable adaptation to
the operator — every future assistant briefed by eeco go honours it.
eeco show adaptations lists every adaptation newest-first with its
provenance and on/off state, so the operator can audit *what the AI is
doing automatically now* without grepping the memory directory:
eeco show adaptations
eeco adaptations <name> on|off flips a disabled: flag on one fact
in place and regenerates the MEMORY.md index so the change shows at
once. A disabled fact is preserved on disk but hidden from
eeco go briefs and eeco ask rankings, so re-enabling is one CLI
flip:
eeco adaptations terse-feedback off # mute it without deleting
eeco adaptations terse-feedback on # restore it
eeco gc skips disabled facts so a deliberately muted adaptation
never gets routed for staleness review.
A disabled fact is hidden from the AI-facing surfaces (eeco go,
eeco ask, the TUI relevance ranker) but stays **visible — marked —
on the human audit surfaces**: the regenerated MEMORY.md index lists
it under a ## disabled heading, and the TUI /memory screen tags its
line [off]. This holds for a fact of any type, so a disabled
project or reference fact is never silently lost from view.
eeco show adaptations is the audit surface; the fact file under
<workspace>/memory/<name>.md is the source of truth. Constraints 1 +
6 hold (writes inside the workspace; nothing staged or committed).
7. The queue — the only thing that interrupts you
─────────────────────────────────────────────────
Workflows and GC never act unilaterally on a meaningful change. They
append an item to the queue. View it with eeco queue (or /queue in
the TUI); the status digest shows the open count. The queue file is
yours — you resolve an item by ticking its checkbox ([ ] → [x]) or
deleting the row in <workspace>/state/queue.md. Nothing else nags you;
nothing runs unless you invoke it.
You can also file a queue item yourself — or have an assistant file one
for you — with eeco add task:
eeco add task "wire the man page into brew/scoop"
eeco add task --kind review --detail "check the brew formula" "audit packaging"
The title is the checklist row; --kind is the short queue tag (default
task); --detail is an optional elaboration printed beneath the row.
The item is appended to <workspace>/state/queue.md and shows up
alongside workflow- and GC-filed items in eeco queue (or /queue) and
the status digest's queue count. eeco add task writes only inside the
workspace (Constraint 1) and never stages or commits (Constraint 6); it
requires an initialised workspace. Like eeco add fact, it closes the
read/write loop — an assistant briefed by eeco go can route a decision
back into eeco's one decision channel instead of letting it evaporate.
8. AI configuration
───────────────────
AI is opt-in (--ai, or an automation level that grants standing
consent) and budget-capped. eeco no longer runs an in-binary model
client — it configures the harness that runs the AI. The one inference
path it keeps for its own chores is a CLI provider that shells any
program of your choice (prompt on stdin, reply on stdout); with none
configured, every AI pass is parked, never failed. Configure in
<workspace>/config.local, or set the common keys from the control
center with /settings:
automation = propose # manual | propose | scaffold | auto
ai_budget = 1 # gated passes per invocation; 0 disables AI
# CLI provider — any program, prompt on stdin, reply on stdout:
ai_command = yourcli --print
ai_provider = cli # cli | none; empty/unknown auto-selects
• Auto-select (default). With ai_provider empty or unset — or an
unknown/legacy value such as anthropic — a configured ai_command
picks the CLI provider; otherwise every AI pass is **parked and
queued**, never failed — the deterministic, non-AI result still
stands. ai_provider = none is also accepted; nothing here is ever a
config error.
• ai_model and ai_api_key_env are inert legacy keys: read and passed
through, but ignored by the CLI provider (there is no native API path).
They stay only so an old config.local loads unchanged.
• Only automation=auto is itself standing AI consent; every other
level needs an explicit --ai on the run.
• The same gating — consent, budget cap, and prompt-parking — wraps
every provider call, so a run is always safe and never an uncontrolled
spend. Every attempt is recorded to the AI-call ledger
(state/ai-calls.json): label, provider, resolved model, prompt and
response hashes, and token usage.
/settings (no arguments) shows the current values; `/settings <key>
<value> writes one to config.local`. A change applies the next time
eeco starts; a long-lived session keeps the budget cap it began with.
Cumulative AI usage — eeco stats
eeco stats aggregates that ledger (state/ai-calls.json) into a
one-glance readout of how much AI eeco has actually used on this project:
eeco stats
eeco stats: 12 AI calls (9 ran, 3 parked) from 2026-05-21 to 2026-05-30
tokens: 18432 input · 4096 cached · 6210 output (28738 total)
by provider: cli 9 · none 3
It reports the total number of gated AI calls, how many actually ran
versus were parked, the cumulative token counts, the per-provider call
breakdown, and the recorded date range. Unlike eeco go --metrics,
whose token figures are bytes/4 estimates marked with ≈, these token
totals are the real provider counts the ledger stored, so they carry
no ≈. The command is read-only and makes no AI call; an
uninitialised workspace or an empty ledger reports `no AI calls recorded
yet` and still exits 0. The exact wording and the displayed figures are a
human-readable readout and are not part of the frozen surface.
9. Hooks (opt-in, reversible)
─────────────────────────────
Off by default. Inspect with eeco hooks (or /hooks); toggle with
eeco hooks <name> on|off (or /hooks <name> on|off). Names:
pre-commit, post-merge, session-start, commit-msg,
commit-guard.
• pre-commit — a local .git/hooks/pre-commit (untracked,
repo-scoped) that runs the configured builtin workflows in declared
order and stops at the first non-zero exit, blocking the commit when
any step fails. Defaults to leak-guard (blocks a leaking commit)
followed by version-sync (silent on projects that have not declared
version_locations, so opt-in per project). Override the list with
one or more pre_commit_workflows keys in config.local; the first
occurrence resets the binary default, subsequent occurrences append.
An empty pre_commit_workflows= value clears the list and `eeco
hooks pre-commit on` refuses to install. The hook is installed only
if no pre-commit hook already exists, and removed only when the
on-disk script is byte-identical to what eeco wrote; a foreign or
hand-edited hook is always left untouched. After upgrading the eeco
binary (for example a brew upgrade that moves the binary) or editing
pre_commit_workflows, run eeco hooks pre-commit refresh to rewrite
the installed script with the current binary path and workflow set.
• post-merge — a local .git/hooks/post-merge (untracked,
repo-scoped) that runs the configured drift-detection workflows after
a git merge / git pull — the moment another author's changes land,
the natural time to re-check whether eeco's recorded state has drifted
from the code. Defaults to memory-drift followed by doc-drift (both
silent no-ops on a project with no memory ref: facts and no
CHANGELOG/tags). Unlike pre-commit it does not block: the merge has
already completed, so a finding or a missing tool surfaces as a queue
item, never as a hook failure (each step's exit is swallowed and the
hook does not use set -e). The drift workflows file at most one open
queue item per finding — a repeated run does not pile up duplicates of
a finding still awaiting a decision. Override the list with one or more
post_merge_workflows keys in config.local (same reset-then-append
semantics as pre_commit_workflows; an empty post_merge_workflows=
clears the list and eeco hooks post-merge on refuses to install).
Install / removal / refresh behave exactly as for pre-commit: a foreign
or hand-edited hook is always left untouched.
• session-start — composes up to three blocks at session start
and stays silent when every block is empty:
1. A reading routine — repo-relative paths to read before
substantive work. Auto-detected from a built-in list
(docs/PUBLIC_API.md, docs/ARCHITECTURE.md, CHANGELOG.md,
ARCHITECTURE.md, docs/USAGE.md, README.md); existing
entries surface, missing ones are skipped. The
most-recently-modified match of roadmap*.md is appended as
the live planning surface. Override the doc list with one or
more session_start_docs keys in config.local; change the
roadmap pattern with session_start_roadmap_glob (empty
disables discovery).
2. A mailbox warning — surfaces when Ideas.md at the repo root
has content beyond its empty template (the parser skips the
file header and any HTML comment blocks). Change the filename
with session_start_mailbox in config.local; an empty value
disables the block. The hook does not create the file — it
surfaces only when the operator opted in by creating it.
3. A one-line queue reminder — eeco: N items awaiting a decision
when the queue is non-empty.
4. (opt-in) Pinned memory bodies — the full body of every
pin: true memory fact under <workspace>/memory/, separated by
Markdown dividers. Off by default, so the hook output is
byte-identical until enabled. Opt in via
session_start_pinned_bodies=true in
config.local (workspace default) or the --with-pinned-bodies
flag on eeco hooks session-emit (one invocation only). Useful
when an AI assistant treats the hook output as a system-reminder
so a pinned policy memory (for example a no-AI-attribution rule)
lands in the model's context at session start.
The session-start hook ships with two brand-free delivery channels;
use either alone or compose both:
• JSON settings file — an exact-match-removable entry in an AI
CLI's user-global JSON settings file (the Claude Code shape). Set
the file location with session_settings_path in config.local
or the EECO_SESSION_SETTINGS environment variable. The file is
backed up (inside the workspace) before the edit and re-validated
after; an invalid result is rolled back.
• Marker block in a text/markdown file — eeco maintains a
delimited block of the same content the JSON channel renders, in
one or more files the assistant already reads at session start
(CLAUDE.md for Claude Code, GEMINI.md for Gemini CLI,
AGENTS.md for OpenAI Codex, .cursorrules for Cursor,
~/.config/<tool>/..., etc.). Declare one path per session_files= line in
config.local; each value is either repo-relative (held inside
the repo) or absolute. The block is fenced by HTML comments —
<!-- eeco:session:start --> / <!-- eeco:session:end --> —
and replaces in place on re-on or refresh. The block content
is whatever eeco hooks session-emit prints. eeco never edits
bytes outside the marker pair, and a block whose contents have
been hand-edited since install is left untouched on off. Files
eeco itself created are removed when off would leave them
empty; files that already existed are restored byte-identical to
their pre-on state when the block is at end-of-file.
Neither channel is configured by default: `eeco hooks session-start
on` reports "not configured" until at least one of the two is set.
When the file channel is wired, run eeco hooks session-start refresh
to re-render the block — the JSON channel does not need it (it
carries a command eeco runs at session start, so it is always
fresh).
• commit-msg — a local .git/hooks/commit-msg (untracked,
repo-scoped) that rejects commit messages carrying an AI-attribution
trailer. The policy is universal and lives inside the eeco binary:
the on-disk script is a one-line wrapper that execs
eeco hooks commit-msg-check "$1", so the pattern set refreshes with
every brew upgrade eeco. Matches the trailer-anchored forms
(Co-Authored-By: lines naming claude, anthropic, or
noreply@anthropic) plus the Claude Code robot-emoji (U+1F916)
"Generated-with" signature; deliberately stricter than the broad
repo-tree scan so a
docs commit that *discusses* the policy ("remove Co-Authored-By
trailer") still passes. Exit code 1 prints the matched line and names
--no-verify explicitly as the bypass; conscious overrides remain
available without surprise. Install / removal behave exactly as for
pre-commit and post-merge: foreign hooks are never modified. After a
brew upgrade eeco rewires the eeco binary path, run `eeco hooks
commit-msg refresh` to rewrite the on-disk wrapper without an
off-then-on toggle.
• commit-guard — the harness-layer companion to commit-msg. Where
the git commit-msg hook guards eeco's own repository, the
commit-guard installs a Claude Code PreToolUse hook that denies any
pending git commit carrying AI attribution — in any repository the
assistant drives, and not bypassable by git commit --no-verify (the
hook sits above git). It runs the same attribution detector
leak-guard uses, so the patterns stay one source of truth (the foreign
repo's own attribution_pattern entries are honoured when its config
loads). Default off; enable with eeco hooks commit-guard on, which
installs a PreToolUse group into the JSON settings file named by
session_settings_path (or EECO_SESSION_SETTINGS) — the same file the
session-start JSON channel uses, backed up and re-validated the same way;
it reports "not configured" when neither is set. The runner detects a
real git commit token-by-token (so git status and echo "git commit"
never fire), scans the assembled message (-m/-F/COMMIT_EDITMSG),
the staged diff, and the raw command, and **denies only on a positive
finding — any parse or infrastructure uncertainty degrades open**
(the commit proceeds), so a session is never wedged; the git pre-commit
/ commit-msg hooks and CI remain the hard gates. off removes only
eeco's group (foreign PreToolUse groups are preserved); after a
brew upgrade eeco run eeco hooks commit-guard refresh to rewrite the
binary path.
Every action is recorded in <workspace>/state/hooks.json so it can
be cleanly undone. The git-hook scripts and the JSON-settings writes —
the session-start and commit-guard entries plus any session_files
blocks — are the only touches eeco ever makes outside the gitignored
workspace.
9a. Gates — composed read-only policy checks
────────────────────────────────────────────
eeco gates check-attribution is a CI-facing companion to the
commit-msg hook (§9). The hook blocks at the source on a developer
machine; the gate is the CI backstop that catches a leak when a
developer bypassed the hook (--no-verify) or never installed it. Both
share the same trailer-anchored pattern set so prose discussing the
policy never false-fires.
Scope:
• Tracked-file scan — broad: delegates to the same detector
comment-hygiene uses (line-anchored Co-Authored-By:, the
Generated with/by <ai-ish> co-marketing line, and the robot-emoji
Generated signature). Enumerates the file list from git ls-files
and filters to a built-in text-extension allowlist (.md, .sh,
.go, .zig, .S, .inc, .zon, .yml, .yaml, .txt, .ld,
.json, .toml). Hits print as path:line: excerpt.
• Commit-body scan — strict trailer-anchored: Co-Authored-By:
lines naming claude, anthropic, or noreply@anthropic, plus the
robot-emoji "Generated" signature. Default range is
origin/main..HEAD; falls back to HEAD~10..HEAD with a stderr
notice when origin/main is unresolvable (fresh clone, detached
HEAD, initial commit). Hits print as `commit <short-sha>: line N:
excerpt`.
Flags (all optional):
┌───────────────────┬───────────────────────────────────────────────────────────────────────────┐
│ Flag │ Effect │
├───────────────────┼───────────────────────────────────────────────────────────────────────────┤
│ --paths "<a b c>" │ override the tracked-files filter; pass an explicit space-separated list. │
│ --commits N │ force HEAD~N..HEAD instead of the default range. │
│ --no-commits │ skip the commit-body scan (file-only). │
│ --no-files │ skip the file scan (commits-only). │
│ --exclude <path> │ repeatable; repo-relative path to skip during the file scan. │
└───────────────────┴───────────────────────────────────────────────────────────────────────────┘
Exit codes: 0 clean, 1 on any hit (sets stderr with one line per
finding plus a N finding(s) summary), 2 on usage error. The CI
invocation pattern is one step before git push-eligible builds:
- name: ai-attribution gate
run: eeco gates check-attribution
Use actions/checkout@v5 with fetch-depth: 0 so the commit-body
range resolves; default 1 makes origin/main..HEAD empty and the
fallback notice triggers.
10. Safety guarantees
─────────────────────
• eeco writes only inside the repo's gitignored workspace; a path guard
enforces this and refuses .. traversal.
• It never commits or pushes your project's tracked tree. The optional
private workspace-history repo (see §11a) commits only inside the
gitignored workspace, locally, and is never pushed.
• No AI-attribution string is ever written to a tracked file.
• Everything it does is invoked by you or queued for you.
11. Removing eeco from a project
────────────────────────────────
eeco uninstall writes a single eeco-handoff.md at the repository
root summarising what eeco knew about the project, then prints the
git command to remove the workspace by hand. It never deletes anything
itself, so nothing is lost if you change your mind.
eeco uninstall # bare handoff (just paths + removal command)
eeco uninstall --scope facts # + memory facts
eeco uninstall --scope queue # + open queue items
eeco uninstall --scope everything # + facts + queue + scaffolded workflows + hooks
The handoff file is written at the repository root, alongside your
tracked tree; the file itself is not gitignored, so you choose whether
to keep, commit, or delete it. The workspace is gitignored, so a single
git rm -rf .eeco (substitute your workspace name) is the only on-disk
removal needed once the handoff has been reviewed. Turn opt-in hooks
off first (eeco hooks pre-commit off, eeco hooks session-start off)
so the reversibility ledger can do its work before the workspace goes.
If the private workspace-history repo exists (§11a), eeco uninstall
offers to de-init it — removing only its .git, never your data — after
a confirmation prompt. Pass --yes to skip the prompt, or decline to
keep the history and remove it yourself later. Your workspace data files
are never deleted by eeco uninstall.
11a. Workspace history (the private logbook)
────────────────────────────────────────────
At eeco init, eeco stands up a **private, local git repository inside
its own gitignored workspace directory** (<username>/) to version its
knowledge layer — memory facts, the queue, decisions, manifests — over
time. This repository has no remote and is never pushed, and it
never touches your project's tracked tree: it records only what eeco
already writes inside its own gitignored workspace, so it is invisible to
your project's git.
The workspace_history config key controls it:
• manual (default) — the repo exists; eeco commits only when you run
eeco history snapshot.
• auto — as manual, plus eeco commits automatically after each
mutating verb (add fact/note/task, gc, go --write, run,
new, …); still local-only, no remote.
• off — no private repo at all.
eeco init # creates the private repo (manual)
eeco init --no-track # skip it for this run
eeco history # show the recent commit log
eeco add note "decided X" # mutate the workspace
eeco history snapshot # commit the current state
eeco history snapshot -m "milestone" # …with a message
eeco history compact --dry-run # preview: how many commits would collapse
eeco history compact # squash the whole log into one (confirms first)
eeco history compact --yes # …without the prompt
Under auto the log grows a commit per mutating verb, so it can get long.
eeco history compact squashes the entire history into a single commit
whose contents are the current workspace state — the git pendant to the
docs-compaction protocol. Your workspace files are never touched (only the
commit log is collapsed), and the old commits stay reachable through the git
reflog, so the squash is recoverable; eeco runs no git gc. It is manual
only — there is no auto-trigger — and it confirms before rewriting unless you
pass --yes; --dry-run reports the count and changes nothing.
The repo is created opt-out (--no-track for one run, or
workspace_history=off durably) and removed at eeco uninstall (§11).
Inside it eeco has a free hand — commit, and later squash — because
nothing there is shared or tracked by the host repo.
12. Filing bug reports
──────────────────────
eeco report-bug captures friction with eeco itself into a structured
Markdown record and prints a pre-filled GitHub Issues URL so the
report can reach the project in one click. The command works for
everyone: anyone who installed eeco can file, with or without an
initialised workspace.
eeco report-bug --note "leak-guard tripped on a doc-only commit" \
--cmd "eeco run leak-guard"
Both flags are optional. The record contains the eeco version, a
whitelisted environment snapshot (OS, arch, Go version, SHELL,
TERM, and any EECO_* variables — never the full env), the note,
and the invoking command.
Where the record lands:
• Inside an initialised workspace. Records go to
<workspace>/bug-reports/; override the directory with the
bug_report_dir key in <workspace>/config.local (workspace-
relative — absolute paths and .. traversal are rejected at parse
time).
• Anywhere else (no eeco init yet, or not inside a git repo at
all). Records go to ~/.eeco/bug-reports/ so a fresh-install user
can file a report against eeco itself without having to set anything
up first. The repo's tracked tree is never touched.
Sharing the record:
eeco report-bug prints a pre-filled URL like
https://github.com/ajhahnde/eeco/issues/new?title=...&body=....
Opening it in a browser fills the issue form with the record body
already pasted — clicking Submit files it to the project. The local
file is your durable copy; you can refine it before clicking, or
attach it by hand if the URL ends up too long for the browser. Nothing
is sent automatically — the upstream side of the loop is always a
human click.
Pass --submit to have eeco open that URL for you (the $BROWSER
command if set, otherwise the platform default opener). It is opt-in
and changes nothing else: the form opens pre-filled, you still review
and click Submit, and the command still reports that nothing was sent
automatically — opening a pre-filled form is not the same as sending.
If no browser can be opened, the command prints a short note alongside
the URL and still exits 0; --submit is a convenience, never a failure
point.
13. Briefing an AI assistant
────────────────────────────
eeco go prints a compact, deterministic project brief written for an
AI assistant. It is the fast, cheap way to bring any assistant — not
only the strongest — up to speed on a project: one command returns a
map of the project instead of a scan across many files.
eeco go
The brief has six sections:
• Working with eeco — what eeco is, the read-only commands an
assistant can run, and the rule that decisions go to eeco's queue
rather than silent edits.
• Project — the detected profile, the parse/build gate, and the
top-level layout (taken from the tracked set, so build artifacts and
the workspace stay out).
• Where to look — every memory fact that records a file pointer,
as a topic → file map. This is the assistant's shortcut to the right
file.
• What eeco knows — the load-bearing memory facts (project,
feedback, user).
• Open decisions — the items waiting in eeco's queue.
• Recording back — how the assistant keeps the brief useful:
record durable facts and route decisions through the queue.
eeco go calls no AI provider — the brief is assembled entirely from
what eeco already tracks, so it costs no provider budget and is safe to
run any number of times. The output is deterministic: the same
workspace state produces the same brief.
The brief is Markdown by default — written for an assistant to read.
The --json flag emits the same project state as a JSON object instead,
the machine-readable form for a downstream agent or script that parses
the brief rather than reading it:
eeco go --json
The JSON object has nine top-level keys — project, profile, gate
(an argv array), top_level, initialized, workflows,
where_to_look (description/ref objects), knowledge
(name/description/type objects), and open_decisions. Every array
is always present, an empty list rather than null, so a consumer can
iterate without a nil check. These top-level keys are part of eeco's
frozen public surface. --json prints to stdout and cannot be combined
with --write.
By default the brief is printed to stdout and nothing is written. The
--write flag saves it instead to a stable file inside the workspace —
.eeco/context.md by default — so an assistant's instructions file can
point at a fixed path rather than re-running the command each session:
eeco go --write # writes .eeco/context.md
The destination is workspace-relative; override it with the
context_path key in <workspace>/config.local (absolute paths and
.. traversal are rejected). --write requires an initialised
workspace, and the file it writes is byte-identical to the stdout
brief. The file lives in the gitignored workspace, so it is never
committed; re-run eeco go --write whenever you want it refreshed.
For an assistant on a tight context budget the --brief flag reshapes
the output: the "Working with eeco" preamble and "Recording back" outro
drop out, and the per-section lists (Where to look, What eeco knows,
Open decisions) are capped at five entries each. --brief composes
with --json and --write, so every delivery axis gets the size knob:
eeco go --brief # smaller Markdown brief
eeco go --brief --json # smaller JSON brief
eeco go --brief --write # writes the smaller variant
In --brief --json the nine frozen top-level keys are preserved —
arrays may simply be shorter, never absent or null — so a consumer that
parses the JSON brief sees the same shape, never an unexpected key
disappearance.
To keep the persisted .eeco/context.md under a known size, set the
context_budget key in <workspace>/config.local to a maximum byte
count (roughly four bytes per token is a useful rule of thumb):
context_budget=4000 # cap the --write brief at 4000 bytes
When context_budget is set, eeco go --write trims the saved brief
down a deterministic ladder until it fits: it tries the full brief
first, then the smaller --brief form, then that form with the
per-section lists capped progressively shorter (5, 4, 3, 2, 1, 0). It
writes the largest tier that fits and reports which one — for example
wrote .eeco/context.md (brief, 1840/4000 bytes). A context_budget
of 0 (the default) means no cap. The budget applies only to
--write; bare eeco go and eeco go --brief printed to stdout are
unchanged. If the budget is so small that even the smallest tier
overruns it, eeco still writes that smallest brief and prints a note —
a brief slightly over budget beats no brief at all.
To surface the recent free-form notes from eeco add note (§17)
inside the brief, set the brief_include_notes key in
<workspace>/config.local:
brief_include_notes=true
When enabled, eeco go adds a Recent notes section between
What eeco knows and Open decisions, listing the five newest
notes from <workspace>/notes/ with their UTC timestamp and first
line. The section appears in Markdown output only — eeco go --json
still emits exactly the nine frozen top-level keys
(PUBLIC_API.md (PUBLIC_API.md) §"CLI commands and flags"), since
notes belong to the assistant-prose channel rather than a parsed
brief. The key accepts the standard strconv.ParseBool set —
true/false, 1/0, t/f (in either case); a typo like
yes/no is rejected at parse time so the operator notices.
The default is false, so bare eeco go omits the section; the key
composes with --brief, --write, and --copy exactly like the other
sections.
To wire the brief into a session, either point your assistant's
instructions file at .eeco/context.md, or — if you use the bundled
session-start hook (§9) — add session_start_docs=.eeco/context.md to
config.local so the hook lists it in the reading routine
automatically. Any assistant that can read a file or run a shell
command can consume the brief; eeco assumes no particular tool.
For a chat-only assistant — Gemini web, a Gem, AI Studio, ChatGPT,
or any LLM behind a chat box — the --copy flag writes the brief to
the host operating system's clipboard so you can paste it in one
shot:
eeco go --copy # full Markdown brief on the clipboard
eeco go --copy --brief # smaller brief for a tight context window
eeco go --copy --json # structured payload for a chat that parses JSON
--copy works on macOS (pbcopy), Windows (clip.exe), and Linux
(wl-copy on Wayland, xclip or xsel on X11). When none of those tools
is on PATH eeco exits 2 with an install hint — the same
"blocked: required tool missing" contract every other workflow
follows. --copy does not compose with --write (one delivery axis
per invocation); it does compose with --brief and --json.
Re-run eeco go --copy whenever the project state has moved on.
Measuring the brief — --metrics
The --metrics flag adds a one-line readout to stderr after the
brief, so you can see what the brief actually saved:
eeco go --metrics
eeco go: assembled in 412µs · brief 1840 bytes (≈460 tokens) · distilled ≈980 tokens of project knowledge into ≈460 (≈53% smaller)
It reports three things: how long the brief took to assemble (real
wall-clock), the brief's own size, and how much of the project's
knowledge layer — the on-disk memory facts, the queue, and any notes —
the brief distils. Byte counts are real measurements; token figures are
estimates via the ~4-bytes-per-token heuristic and are marked with a
leading ≈, never presented as exact counts (eeco ships no tokenizer,
by design). The compression percentage is grounded in real on-disk bytes
and is never reported below 0%.
The readout goes to stderr, so it never mixes into the brief itself:
eeco go --metrics > brief.md writes a clean brief to the file and
prints the readout to the terminal. It always describes the **canonical
Markdown brief** — even alongside --json or --copy, where it measures
the Markdown brief the assistant would read — and it never appears in
--json stdout, so the nine frozen top-level keys
(PUBLIC_API.md (PUBLIC_API.md) §"CLI commands and flags") are
untouched. With --write under a context_budget the readout still
describes that canonical brief, not the budget-trimmed file that was
persisted — the metric measures how much eeco's brief compresses the
knowledge layer, a stable property, rather than one write's fitted size.
--metrics is off by default and composes with every other flag.
Briefing assistants other than Claude Code
eeco's delivery channels are brand-free — point them at whichever
assistant you use:
• Gemini CLI (Google's terminal-native assistant) reads
GEMINI.md at session start. Set session_files=GEMINI.md in
<workspace>/config.local, then eeco hooks session-start on.
Same recipe as Claude Code with CLAUDE.md.
• OpenAI Codex reads AGENTS.md. Set session_files=AGENTS.md.
• Cursor reads .cursorrules. Set session_files=.cursorrules.
• Any other assistant whose instructions file lives at a known
path: add that path as a session_files= line and eeco will keep
a marker block in it (§9).
For an assistant with no terminal and no filesystem access (Gemini
web, Gems, AI Studio, ChatGPT, any chat box), eeco go --copy is
the one-shot bridge — render the brief into the clipboard and paste
it into the chat. Re-run when the project state moves on.
Asking a targeted question
Where eeco go is a one-shot overview, eeco ask answers a *specific*
question with a ranked set of pointers — the interactive counterpart to
the static brief:
eeco ask "where is the project brief rendered"
The answer has two sections: Memory — the memory facts whose name,
description, or body overlaps the question, shown as a topic → file map
when the fact carries a ref; and Code — the best-matching lines in
the repository's tracked files, each as a path:line reference with the
matching line. Results are ranked by how many distinct question words
they match, with stable tie-breaks, so the same question over the same
tree always gives the same answer.
eeco ask --limit 3 "how does version-sync detect drift" # cap code hits
eeco ask --json "queue" # machine-readable
--limit N caps the number of code locations (default 10); every
matching memory fact is always shown. --json emits an object with
three frozen top-level keys — question, memory, code — both arrays
always present, never null. Like eeco go, eeco ask calls no AI
provider (relevance is a word-overlap score over what eeco already
tracks), reads anywhere in the repo but writes nothing, and works in any
git repository — the memory section is simply empty when the workspace
is not initialised. Binary and very large files are skipped.
14. Applying an update
──────────────────────
eeco update checks for a newer release and prints the gap, but does
not download anything. The opt-in --apply flag turns it into a
verified self-replace: download the platform release archive, verify
its sha256 against the published SHA256SUMS, verify the cosign
signature on SHA256SUMS, verify the GitHub build-provenance
attestation on the archive, then atomically swap the running binary.
eeco update --apply
Verification gates. All three must pass before any swap. Any one
failure leaves the running binary untouched and leaves the downloaded
files in <workspace>/state/update-<tag>/ for inspection.
• cosign verify-blob against the keyless certificate identity baked
into the release workflow (refs/tags/v… on this repo).
• sha256 of the downloaded archive equal to its line in SHA256SUMS.
• gh attestation verify <archive> --repo ajhahnde/eeco.
Requirements. cosign and gh must be on PATH. The command must run
inside an initialised eeco workspace — <workspace>/state/ holds the
staging directory, the backup, and the swap ledger
(<workspace>/state/binary.json). The running binary must be on a
released tag (v…); an unpinned 0.0.0-dev build refuses with a
reinstall hint.
Package-manager handoff. If the resolved running-binary path lives
under a known package-manager root (Homebrew, Scoop, Linuxbrew),
--apply refuses and points at the package manager's upgrade verb
(brew upgrade eeco or scoop update eeco). Self-replace would fight
the package manager on its next sync; the manager owns its prefix.
Rollback. The previous binary is preserved at
<workspace>/state/update-<tag>/eeco.bak (or eeco.exe.bak on
Windows). To revert by hand:
mv <workspace>/state/update-<tag>/eeco.bak <running-path>
The exact <running-path> is recorded in
<workspace>/state/binary.json under running_path.
What stays the same. Bare eeco update (no flag) is unchanged: it
still lists the gap and exits cleanly, without touching any binary.
Constraint 6 holds — eeco only swaps when the operator passed
--apply.
15. Reading the in-binary guide
───────────────────────────────
eeco guide pages the user manual built into the binary — a verbatim
mirror of this file (docs/USAGE.md) at the tag the binary was built
from. Every install route ships the manual offline; no network access
needed, no doc directory to clone.
eeco guide
The pager selection is $PAGER first (split on whitespace; the first
token must be on PATH), then less -R if less is available, then
a plain stdout dump as the final fallback. When stdout is not a
terminal (piped or redirected) the command always dumps the manual
plainly and exits 0 — the same one-screen path the TUI uses for
eeco with no arguments.
At a terminal the manual is prettified for reading: Markdown tables
become box-drawing grids, headings are styled with a rule, and inline
code / bold / links are rendered rather than shown as raw markup.
NO_COLOR drops the colour but keeps the layout. Piped or redirected
output stays raw Markdown, byte-identical to docs/USAGE.md, so a
script that captures the guide (eeco guide > usage.md) gets the
source unchanged.
Quick recipes.
eeco guide # page interactively
eeco guide | less # explicit pager pipeline
eeco guide > usage.md # capture the embedded snapshot
PAGER='less +/^## 4' eeco guide # jump straight to the commands table
The embedded snapshot is locked to the binary version. Pair it with
eeco update --apply to keep the manual in sync with the running
release. The tracked-tree docs/USAGE.md and the in-binary guide
stay byte-identical in CI — a release commit that forgets to re-sync
the mirror fails the build.
16. Authoring tracked docs
──────────────────────────
eeco docs new <target> seeds a tracked-tree documentation file at
the repository root. Two targets ship today:
┌────────┬──────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Target │ File written │ Purpose │
├────────┼──────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ vision │ VISION.md │ seed a short manifesto: what this project is for, what it gives you, what it deliberately is not, and where the roadmap lives │
│ readme │ README.md │ seed a new-reader-friendly README: tagline, what the project does, a quick-start recipe, and a short "how it works" │
└────────┴──────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
The scaffolder is deterministic — no AI call, no provider spend — and
project-shape-aware: the rendered "See also" only links to companion
docs that already exist (README.md, docs/USAGE.md,
docs/ARCHITECTURE.md). The readme target's "See also" never
self-links, so the link set narrows to docs/USAGE.md and
docs/ARCHITECTURE.md.
eeco docs new vision # write VISION.md (refuse if it exists)
eeco docs new --overwrite vision # replace an existing VISION.md
eeco docs new readme # write README.md (refuse if it exists)
eeco docs new --overwrite readme # replace an existing README.md
Refuse-on-existing is the default. Pass --overwrite to replace a
prior scaffold or hand-edited file. **--overwrite discards the
existing body** — use it deliberately, especially on a populated
README.md. eeco writes the file but never stages or commits it
(Constraint 6); review the result and run `git add <file> && git
commit` yourself.
The seed file carries a one-line HTML comment at the top marking it
as a scaffold and naming the eeco version it was generated against.
The comment is invisible in any rendered Markdown view and can be
deleted in the same edit you fill the body in.
eeco docs refresh <target> re-renders the project-state-derived
region of a previously-scaffolded doc — the same project-shape-aware
"See also" block eeco docs new rendered — leaving operator prose
outside the marker pair untouched. The region is delimited by a pair
of HTML-comment markers:
<!-- eeco:docs:start -->
… rendered content …
<!-- eeco:docs:end -->
Markers inside fenced code blocks are ignored, mirroring `eeco docs
compact`. Unmatched, nested, or out-of-order markers exit 1 with a
parse error naming the offending line; the file is not touched. A
scaffold with no markers is auto-initialised: the freshly
rendered block is appended at EOF, marker-wrapped, with a blank-line
separator. The operator removes the prior in-place block manually.
Where --overwrite on eeco docs new discards the entire body,
refresh is the non-destructive complement.
eeco docs refresh vision # re-render VISION.md's marker block
eeco docs refresh readme # re-render README.md's marker block
eeco docs compact <path> moves resolved regions of a long doc into
a sibling archive file so the working copy stays short, then leaves a
single-line pointer stub in place at each cut. A region is anything
between a pair of HTML-comment markers:
<!-- eeco:archive:start -->
… content to archive …
<!-- eeco:archive:end -->
Markers inside fenced code blocks are ignored, so a doc explaining
the markers (this very paragraph in the original docs/USAGE.md)
does not trigger a self-archive.
eeco docs compact roadmap_v1.x.md
eeco docs compact --dry-run roadmap_v1.x.md
eeco docs compact --archive history.md roadmap_v1.x.md
The default archive destination is <basename>.archive<ext> next to
the source — e.g. roadmap_v1.x.md → roadmap_v1.x.archive.md.
Override the destination with --archive; pass a --dry-run to
preview which line ranges would move without writing. Re-running
after a successful compact is an idempotent no-op (`no archive
markers found`); the archive is appended to on subsequent runs so a
long-lived doc can be compacted in waves.
Unmatched, nested, or out-of-order markers exit 1 with a parse error
naming the offending line; the archive is not touched. Both paths
must resolve inside the repository (absolute paths and .. traversal
are rejected). eeco writes the source and the archive but never
stages or commits either (Constraint 6); review both files and `git
add` / commit when ready.
For a log that grows by prepending a new section each time — a session
journal, a changelog, a steering file — placing markers by hand every
round is fiddly. --keep-last N discovers the archivable regions by
heading instead. Pair it with --heading <prefix>, the heading-line
prefix that opens an archivable section:
eeco docs compact --keep-last 4 --heading "## Snapshot" RESUME.md
This keeps the N most-recent matching sections — newest meaning
topmost in the file — and archives everything older. The # run in
--heading fixes the section level: a section runs from its heading to
the next heading of the same or a higher level, so a live tail such as a
trailing ## Next session or ## Pointers block is never swallowed. A
deeper sub-heading inside a section does not split it, and (as in marker
mode) any heading inside a fenced code block is ignored. Adjacent
archived sections collapse into one pointer stub.
--keep-last and explicit markers are mutually exclusive: running
heading mode on a source that still carries a paired marker region exits
1 rather than mixing the two schemes. --keep-last requires --heading
and vice versa. --dry-run and --archive work the same in both modes;
re-running once only the kept window remains is an idempotent no-op.
17. Keeping notes
─────────────────
A note is a place to scribble — a half-formed idea, a reminder, a
thread to pick up later. It is deliberately neither a memory fact
(frontmatter-strict, matched to AI relevance) nor a queue item (the
append-only decision channel): routing scratch text into either of
those pollutes their contract. Notes get their own surface.
eeco add note "check round-robin fairness in queue promotion"
eeco show notes
eeco add note "<text>" saves the text verbatim to one plain Markdown
file under <workspace>/notes/, named YYYY-MM-DD-HHMMSS-<slug>.md
(the timestamp is UTC; the slug is derived from the text). The
directory is created on first use, so a fresh eeco init is not
required — being inside a git repository is enough. eeco writes only
inside the workspace (Constraint 1) and never stages or commits
(Constraint 6).
eeco show notes lists every note newest-first, one row per note:
the local date and time followed by the note's first line. An empty or
absent notes directory prints a friendly hint and exits 0.
This first slice is append + list only. To edit a note, open it in your
editor ($EDITOR <workspace>/notes/<file>); to remove one, delete the
file (rm <workspace>/notes/<file>).