Closes the cluster of audit findings where gnoma's incognito promise
('no persistence, no learning, local-only routing') silently broke
because state was duplicated across the CLI flag, the firewall's
IncognitoMode, the router's localOnly flag, and the TUI's local
m.incognito field. Wave 2 makes security.IncognitoMode the canonical
source of truth.
W2-1 Router.Select rejects forced non-local arms when localOnly is on
rather than short-circuiting and silently routing to cloud. Main
fails fast when --incognito + --provider <cloud> are combined; the
TUI toggle (Ctrl+X, /incognito, config panel) refuses with an
actionable message when a non-local arm is pinned. Factored the
three duplicated toggle sites into Model.attemptIncognitoToggle.
W2-2 persist.Store.Save consults an IncognitoGate (local interface,
*security.IncognitoMode satisfies it). nil gate = always persist
(legacy behaviour for tests); non-nil gate is consulted on every
Save so TUI runtime toggles take effect without reconstructing the
store. File mode 0o600, dir mode 0o700.
W2-3 tui.New seeds m.incognito from cfg.Firewall.Incognito().Active().
Fixes the Ctrl+X-on-launch-with-incognito case where the first
toggle silently turned the firewall OFF because the local flag
started false out of sync with the firewall.
W2-4 saveQuality gates on both *incognito (defensive, covers the
window before fwRef.Set fires) and fw.Incognito().ShouldLearn() (so
TUI Ctrl+X suppresses the snapshot on exit). Quality restore skipped
under --incognito. Quality file written 0o600 in dir 0o700.
engine.reportOutcome and elf.Manager.ReportResult both gate on
fw.Incognito().ShouldLearn() — bandit signal no longer leaks out of
incognito sessions.
W2-5 session files written 0o600 in dirs 0o700 (was 0o644 / 0o755).
W2-6 IncognitoMode.LocalOnly dropped — dead field with no readers;
routing local-only state lives on the router, not the firewall.
Also wires rtr.SetLocalOnly(true) when --incognito at launch — main
previously activated the firewall's flag but never told the router to
filter, so even without the forced-arm bug, launching with
--incognito alone gave you 'incognito badge but full arm pool'.
gnoma
A provider-agnostic agentic coding assistant built in Go. gnoma routes tasks to the best available LLM — cloud or local — through a multi-armed bandit router, while tools, hooks, skills, MCP servers, and plugins keep it extensible. Named after the northern pygmy-owl (Glaucidium gnoma); agents are called elfs (elf owl).
Quickstart
# Install
go install somegit.dev/Owlibou/gnoma/cmd/gnoma@latest
# Or build from source
git clone https://somegit.dev/Owlibou/gnoma && cd gnoma
make build # binary at ./bin/gnoma
# Set at least one provider key
export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, MISTRAL_API_KEY, GEMINI_API_KEY
# Run
gnoma # interactive TUI
echo "list files" | gnoma # pipe mode
gnoma --provider ollama # use a local model
Build
make build # ./bin/gnoma
make install # $GOPATH/bin/gnoma
Providers
Anthropic
export ANTHROPIC_API_KEY=sk-ant-...
./bin/gnoma --provider anthropic
./bin/gnoma --provider anthropic --model claude-opus-4-5-20251001
Integration tests hit the real API — keep a key in env:
go test -tags integration ./internal/provider/...
OpenAI
export OPENAI_API_KEY=sk-proj-...
./bin/gnoma --provider openai
./bin/gnoma --provider openai --model gpt-4o
Mistral
export MISTRAL_API_KEY=...
./bin/gnoma --provider mistral
Google (Gemini)
export GEMINI_API_KEY=AIza...
./bin/gnoma --provider google
./bin/gnoma --provider google --model gemini-2.0-flash
Ollama (local)
Start Ollama and pull a model, then:
./bin/gnoma --provider ollama --model gemma4:latest
./bin/gnoma --provider ollama --model qwen3:8b # default if --model omitted
Default endpoint: http://localhost:11434/v1. Override via config or env:
# .gnoma/config.toml
[provider]
default = "ollama"
model = "gemma4:latest"
[provider.endpoints]
ollama = "http://myhost:11434/v1"
llama.cpp (local)
Start the llama.cpp server:
llama-server --model /path/to/model.gguf --port 8080 --ctx-size 8192
Then:
./bin/gnoma --provider llamacpp
# model name is taken from the server's /v1/models response
Default endpoint: http://localhost:8080/v1. Override:
[provider.endpoints]
llamacpp = "http://localhost:9090/v1"
Extensibility (M8)
gnoma supports hooks, skills, MCP servers, and plugins.
MCP Servers
Connect any MCP-compatible tool server:
[[mcp_servers]]
name = "git"
command = "mcp-server-git"
args = ["--repo", "."]
timeout = "30s"
# Replace a built-in tool with an MCP tool
[mcp_servers.replace_default]
exec = "bash" # MCP tool "exec" replaces gnoma's built-in "bash"
MCP tools appear as mcp__{server}__{tool} (e.g., mcp__git__status), or under the built-in name when using replace_default.
Skills
Drop markdown files into .gnoma/skills/ or ~/.config/gnoma/skills/:
/skillname # invoke a skill
/skills # list available skills
Hooks
Run shell commands on tool events:
[[hooks]]
name = "block-rm-rf"
event = "pre_tool_use"
type = "command"
exec = "bash-safety-check.sh"
tool_pattern = "bash*"
Plugins
Bundle skills, hooks, and MCP configs into installable plugins:
gnoma plugin install ./my-plugin # install from directory
gnoma plugin list # list installed plugins
Plugins are pinned by SHA-256 of their plugin.json on first load
(Trust-On-First-Use). A manifest that changes between runs is refused with a
clear error and a re-enrollment hint. See docs/plugins-trust.md
and ADR-003.
Session Persistence
Conversations are auto-saved to .gnoma/sessions/ after each completed turn. On a crash you lose at most the current in-flight turn; all previously completed turns are safe.
Resume a session
gnoma --resume # interactive session picker (↑↓ navigate, Enter load, Esc cancel)
gnoma --resume <id> # restore directly by ID
gnoma -r # shorthand
Inside the TUI:
/resume # open picker
/resume <id> # restore by ID
Incognito mode
gnoma --incognito # no session saved, no quality scores updated
Toggle at runtime with Ctrl+X.
Config
[session]
max_keep = 20 # how many sessions to retain per project (default: 20)
Sessions are stored per-project under .gnoma/sessions/<id>/. Quality scores (EMA routing data) are stored globally at ~/.config/gnoma/quality.json.
Config
Config is read in priority order:
~/.config/gnoma/config.toml— global.gnoma/config.toml— project-local (next togo.mod/.git)- Environment variables
Example .gnoma/config.toml:
[provider]
default = "anthropic"
model = "claude-sonnet-4-6"
[provider.api_keys]
anthropic = "${ANTHROPIC_API_KEY}"
[provider.endpoints]
ollama = "http://localhost:11434/v1"
llamacpp = "http://localhost:8080/v1"
[permission]
mode = "auto" # auto | accept_edits | bypass | deny | plan
Environment variable overrides: GNOMA_PROVIDER, GNOMA_MODEL.
Testing
make test # unit tests
make test-integration # integration tests (require real API keys)
make cover # coverage report → coverage.html
make lint # golangci-lint
make check # fmt + vet + lint + test
Integration tests are gated behind //go:build integration and skipped by default.