1 # AGENTS.md
2
3 This file provides guidance to AI coding agents when working with code in this repository.
4
5 ## What this is
6
7 `sigit` ("siGit Code") is a single Rust binary: a local-first AI coding agent that runs LLM
8 inference on-device (via the `onde` crate / GGUF models) or against a hosted/OpenAI-compatible
9 endpoint. It exposes itself two ways from the *same* binary, chosen at startup by whether stdin
10 is a TTY:
11
12 - **ACP mode** (stdin not a TTY): speaks the Agent Client Protocol over stdio for editor
13 integration (Zed, VS Code ACP Client). Cross-platform.
14 - **Interactive terminal mode** (stdin is a TTY): a full-screen ratatui chat UI. **Unix-only** —
15 it relies on fd redirection to keep logs out of the TUI, so Windows gets ACP mode only.
16
17 Before the TTY/ACP split, `main` also dispatches the account subcommands `sigit login`,
18 `sigit logout`, `sigit whoami` (see `src/main.rs` `main()`).
19
20 ## Working in this repo
21
22 **IMPORTANT — branch naming:** Prefix every working branch with `feature/` (new functionality)
23 or `fix/` (bug fixes) — never a tool- or agent-name prefix like `claude/`. Name the branch after
24 the *changes it contains*, as a short, descriptive, kebab-case slug that is self-explanatory
25 from the name alone (e.g. `feature/tool-permission-system`, `fix/glob-mtime-sort`), never after
26 a task, ticket, or session id (not `feature/task-q003hm`).
27
28 **IMPORTANT — pull request target:** Always open pull requests against the `development` branch,
29 never `main`. `main` is release-only; `development` is where day-to-day work integrates.
30
31 **IMPORTANT — branch off `development`:** Start every working branch from the latest
32 `origin/development`. Exception: when new work *depends on* a feature branch that has not merged
33 yet (e.g. it builds on tools or APIs that branch introduces), it may be stacked on top of that
34 branch instead. When stacking: merge the base PR into `development` first, then rebase the
35 stacked branch onto `development` before opening its pull request, so each PR shows only its own
36 commits.
37
38 **IMPORTANT — run CI before pushing:** Run the full CI gate locally and confirm it is green
39 *before* pushing a branch or opening a pull request — never push work that fails these:
40
41 ```sh
42 cargo fmt -- --check # formatting
43 cargo clippy --tests -- -D warnings # lint (warnings are errors)
44 cargo test --locked # tests
45 ```
46
47 ## Agent assets layout
48
49 `.agents/` is the canonical home for agent assets; every other agent path in
50 the repo is a symlink into it, never a copy:
51
52 - `.agents/AGENTS.md` — this file, the project instructions. The root
53 `AGENTS.md` and `CLAUDE.md` are symlinks to it, so agents.md-standard tools,
54 Claude Code, and sigit itself (via `src/instructions.rs`, which prefers
55 `AGENTS.md` over `CLAUDE.md` in the same directory and therefore reads it
56 once) all load the same content. Edit this file; never replace the root
57 symlinks with real files.
58 - `.agents/skills/` — all project skills. `.claude/skills` is a symlink to it,
59 which is how both Claude Code and sigit itself (via `src/skills.rs`) discover
60 them. Add new skills under `.agents/skills/<name>/SKILL.md`, never as real
61 files under `.claude/`.
62
63 ## Build / test / lint
64
65 ```sh
66 cargo build # debug build
67 cargo build --release # release binary at target/release/sigit
68 cargo run # launches interactive TUI (stdin is a TTY)
69 cargo test # CI runs: cargo test --locked --target <target>
70 cargo clippy --tests -- -D warnings # CI gate: clippy is -D warnings on all 4 targets
71 cargo fmt -- --check # CI gate (edition 2024)
72 ```
73
74 CI (`.github/workflows/ci.yml`) runs fmt + clippy + test across four targets:
75 `aarch64-apple-darwin`, `x86_64-apple-darwin`, `x86_64-unknown-linux-gnu`,
76 `x86_64-pc-windows-msvc`. Clippy is `-D warnings`, so warnings fail the build.
77
78 Run a single test: `cargo test <test_name>`.
79
80 ## Critical platform constraint: `#[cfg(unix)]` dead code
81
82 The interactive client is `#[cfg(unix)]`-only. The `InferenceBackend` seam (`backend.rs`) and
83 provider resolution (`provider.rs`) are consumed by both the interactive client and the ACP
84 server, but several of their items are reached only through the Unix-only interactive paths, so
85 the dead-code lint is suppressed *on non-Unix targets only*.
86
87 Consequence: code can pass clippy on macOS/Linux but fail on the Windows target (or vice versa).
88 When touching `backend.rs`, `provider.rs`, or the interactive path, keep the `cfg` gates intact —
89 don't "fix" an unused-warning by deleting code that's live on Unix.
90
91 ## Architecture
92
93 The agent loop is backend-agnostic. The flow: a turn (messages + tool specs) goes to an
94 `InferenceBackend`, which returns assistant text and/or tool calls; the loop executes tools and
95 feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete backend.
96
97 - **`src/main.rs`** — entry point, mode dispatch, the full ACP `Agent` impl (session lifecycle:
98 new/load/fork/prompt/cancel, config options, slash-command advertisement), and the `SYSTEM_PROMPT`
99 (note: it bakes in smbCloud-specific context the agent should use when the repo is clearly
100 smbCloud, and stay general otherwise).
101 - **`src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`,
102 `ToolResult`, `TurnResult`). Two impls: `LocalBackend` (on-device via `onde::ChatEngine`) and
103 `OpenAiBackend` (any OpenAI-compatible HTTP endpoint).
104 - **`src/provider.rs`** — decides *which* backend serves inference. Resolution order, first match
105 wins: (1) override via `OPENAI_BASE_URL`+`OPENAI_API_KEY` or active profile in
106 `~/.config/sigit/providers.toml`; (2) siGit Code Cloud when logged in; (3) on-device.
107 - **`src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`,
108 `list_directory`, `search_files`, `glob`, `read_website`, `create_file`, `edit_file`,
109 `multi_edit`, `delete_file`, `run_command`, `write_todos`, `remember`. Add a tool in both the
110 spec list (`all_tools`) and the execute `match` (`execute_tool`). `run_command` also enforces
111 commit attribution: when a command creates a new commit that lacks the
112 `Co-Authored-By: siGit Code` trailer (`COMMIT_CO_AUTHOR_TRAILER`), it amends the trailer in —
113 unless the commit already exists on a remote, which is never rewritten. Also owns the `task`
114 tool: a nested agent loop in a fresh conversation, offered only when `subagent_available()`
115 (a subagent factory is registered — see `register_subagent_factory_for` in `main.rs`; on-device
116 registers a `None`-returning factory since onde has a single shared history). A subagent's
117 toolset is a hard-gated read-only allow-list (`SUBAGENT_TOOL_NAMES`) that is never expanded by a
118 configurable subagent type (see `src/subagents.rs`) — only ever narrowed — so a `.sigit/agents/*.md`
119 file can't grant itself `edit_file`/`run_command` and bypass the permission system. Also owns
120 `web_search`: a thin native wrapper around the official MCP server's Brave-Search-backed
121 `web_search` tool (`mcp__sigit__web_search`, implemented server-side in `sigit-si`'s
122 `WebSearchService` + `Mcp::Tools::WebSearch`), offered only when that MCP tool was actually
123 discovered (i.e. the user is signed in to siGit Code Cloud — see `web_search_available`).
124 Wrapping it natively rather than leaving it as a raw `mcp__*` tool matters for two reasons:
125 `permissions::classify` treats every `mcp__*` tool as mutating (an "ask" prompt on every call),
126 while `web_search` is read-only like `read_website`; and the raw delegate name is filtered out
127 of the assembled tool list (`is_web_search_delegate`) so the model sees one clean option, not
128 two names for the same tool. Execution still forwards verbatim to `mcp::call_tool` — no second
129 HTTP/JSON-RPC implementation.
130 - **`src/skills.rs`** — [Agent Skills](https://agentskills.io) support. Discovers skill
131 folders (each with a `SKILL.md`: YAML frontmatter `name` + `description`, then Markdown
132 instructions) from `.sigit/skills/` and `.claude/skills/` in the cwd, `$SIGIT_CONFIG_DIR/skills/`,
133 and `~/.claude/skills/`. Progressive disclosure: the discovery list (name + description) is
134 baked into the dynamically-built `skill` tool's description, and activating a skill (the model
135 calls `skill` with a name) loads the full `SKILL.md` body. The `skill` tool is appended in the
136 `*_as_specs`/`build_tool_specs` layer (not in `all_tools()`) so its description can be dynamic,
137 and only when at least one skill exists.
138 - **`src/commands.rs`** — user-defined slash commands. Discovers Markdown files (each an
139 optional YAML frontmatter block — `description`, `argument-hint` — followed by a prompt-template
140 body) from `.sigit/commands/` and `.claude/commands/` in the cwd, `$SIGIT_CONFIG_DIR/commands/`,
141 and `~/.claude/commands/`. A subdirectory namespaces the command with `:`
142 (`.sigit/commands/git/commit.md` → `/git:commit`). Unlike skills there's no tool-call
143 indirection: invoking one works exactly like the built-in `/init` — `commands::render`
144 substitutes `$ARGUMENTS`/`$1..$9` in the body against whatever followed the command on the
145 input line, and the result is fed to the model as a normal turn through the ordinary tools and
146 permission checks. Resolution happens where each surface (`main.rs`/`chat.rs`) already
147 special-cases `/init`: an unrecognized slash command is tried against `commands::resolve_command`
148 before falling back to "unknown command". A custom command sharing a name with a built-in is
149 unreachable (built-ins match first in `parse_slash`) and is skipped when advertised to ACP
150 clients, with a warning logged.
151 - **`src/subagents.rs`** — configurable subagent types for the `task` tool. Discovers Markdown
152 files (YAML frontmatter `name` + `description`, optional comma-separated `tools:` allow-list,
153 then a Markdown body that becomes the subagent's system prompt) from `.sigit/agents/` and
154 `.claude/agents/` in the cwd, `$SIGIT_CONFIG_DIR/agents/`, and `~/.claude/agents/`. Passing a
155 type's `name` as `task`'s `subagent_type` argument swaps in that system prompt and, if `tools:`
156 is set, narrows the offered toolset to its *intersection* with `SUBAGENT_TOOL_NAMES` — the
157 security-relevant narrowing logic lives in `tools.rs` next to that constant, not here; this
158 module only discovers and parses files. `SubagentFactory` (in `tools.rs`) takes the resolved
159 system prompt per call rather than baking one in at registration, so a single registered
160 factory serves both the default research subagent and every configured type.
161 - **`src/frontmatter.rs`** — shared "YAML frontmatter + Markdown body" parsing used by both
162 `src/skills.rs` (`SKILL.md`) and `src/commands.rs` (`.sigit/commands/*.md`).
163 - **`src/mcp.rs`** — [Model Context Protocol](https://modelcontextprotocol.io) *client*. Two
164 transports: **Streamable HTTP** (one JSON-RPC POST endpoint, `url` in `mcp.toml`; replies are
165 `application/json` or SSE) and **stdio** (`command` + optional `args`/`[server.env]` in
166 `mcp.toml`; sigit spawns the server and speaks newline-delimited JSON-RPC over its
167 stdin/stdout, stderr inherited into sigit's log). `url` and `command` are mutually exclusive —
168 both or neither is a config error, logged and skipped. Both transports run the same
169 `initialize`/`tools/list` handshake and forward `tools/call`. Discovery is best-effort at
170 startup (`mcp::init`, called from both branches of `main()`) and cached in a process-global so
171 the synchronous spec builders (`mcp::tool_specs`) and the async dispatch (`mcp::call_tool`) can
172 both read it; `/reload` does *not* re-run it, so config changes need a restart. stdio children
173 live for the process; a dead child fails calls with an in-band error string (no auto-restart).
174 Tools are namespaced `mcp__<server>__<tool>`, appended in the `*_as_specs`/`build_tool_specs`
175 layer and routed in `tools::execute_tool` via `mcp::is_mcp_tool`. The official server
176 (`<cloud>/mcp`, default `https://sigit.si/api/v1/mcp`) is baked in (always HTTP) and authed
177 with the cloud session token; extra servers live in `mcp.toml` (global
178 `$SIGIT_CONFIG_DIR/mcp.toml` and project-local `.sigit/mcp.toml`). The stdio path is covered by
179 `tests/mcp_stdio.rs`, driven by the test-only `src/bin/mcp_stdio_stub.rs` helper binary
180 (excluded from the published crate via `exclude` in `Cargo.toml`).
181 - **`src/permissions.rs`** — tool permission policy. Every tool call passes through
182 `decision_for` before executing: read-only tools always run; mutating tools (and all
183 `mcp__*`/unknown tools) are governed by, in order: per-session plan mode (`/plan` — deny all
184 mutating tools with a present-a-plan message), session "always allow" grants, per-tool
185 overrides and the default mode from `[permissions]` in `settings.toml` (`allow`/`ask`/`deny`,
186 default `ask`; `SIGIT_PERMISSIONS` env overrides the default). On `ask`, the ACP path sends
187 `session/request_permission` (allow once / allow for session / deny) and the TUI pauses the
188 inference task on a y/a/n prompt. Note: ACP turn-affecting handlers run in `cx.spawn`ed tasks
189 serialized by `SiGitAgent::turn_lock` so the dispatch loop can route the client's permission
190 answer mid-turn — don't move them back inline, and don't await client requests from inline
191 handlers (deadlock).
192 - **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
193 Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
194 walking from the session cwd up to the repo root (nearest ancestor with `.git`, never above it),
195 plus a global file under `$SIGIT_CONFIG_DIR`. Files are ordered outermost-first so the deepest
196 (most specific) wins. The combined block is injected via `session_context_message` in `main.rs`
197 — pushed as a system message at every ACP session entry point (new/load/fork + model switch)
198 and appended to the system prompt on the cloud and TUI-startup paths.
199 - **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
200 `tokio::select!` to multiplex terminal events with streaming tokens.
201 - **`src/setup.rs`** — model cache location, local model discovery, selected-model persistence.
202 Must run (`setup_shared_model_cache`) *before* anything touches `ChatEngine`/`hf-hub`, since
203 those read env vars once at init.
204 - **`src/account.rs`** — siGit Code Cloud auth (`/login`, `/logout`, `/whoami`); authenticates
205 against the account API and stores a session token. Performs no console I/O.
206 - **`src/credentials.rs`** — local session-token store (TOML, `0600` on Unix).
207 - **`src/models.rs`** — model-picker types shared across platforms.
208
209 Slash commands (`/help`, `/models`, `/skills`, `/agents`, `/commands`, `/mcp`, `/login`, `/logout`,
210 `/whoami`, `/reload`, `/plan`, `/permissions`, `/init`, `/clear`, `/status`) are advertised via
211 `advertise_commands` in `main.rs` and handled in both the TUI and ACP sessions. `/init` is
212 special: instead of replying directly it substitutes `instructions::INIT_PROMPT` for the user
213 text and runs a normal agent turn that explores the repo and writes (or improves) `AGENTS.md`
214 through the ordinary tools and permission checks. User-defined commands (see `src/commands.rs`)
215 get the same treatment via the `SlashCommand::Unknown` fallback path, so anyone can add their own
216 `/name` commands without touching the built-in command list.
217
218 ## Model cache (macOS)
219
220 On macOS the HF model cache lives in an App Group container shared with the siGit desktop app:
221 `~/Library/Group Containers/group.com.ondeinference.apps/models/`. Other platforms fall back to
222 `~/.cache/huggingface/`. The CLI reuses a model the desktop app already downloaded. First run
223 downloads a GGUF model (~1–2 GB) from Hugging Face.
224
225 ## Logging
226
227 In TTY (interactive) mode, *all* output — `log`, `tracing`, stray `println!` — is redirected to
228 `$TMPDIR/sigit.log` so the ratatui surface stays clean; the TUI holds a separate fd to the real
229 terminal. In ACP mode, stdout is reserved for protocol JSON and logs go to stderr. Control
230 verbosity with `RUST_LOG`.
231
232 ## Relevant env vars
233
234 `OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
235 default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
236 `SIGIT_MODEL`, `SIGIT_MCP` (`off` disables MCP), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in
237 server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
238 mutating tools; the escape hatch for clients without permission-request support),
239 `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`.
240
241 ## Releasing
242
243 Version lives in `Cargo.toml`. The binary is published to five registries via separate workflows
244 (`release-crates`, `release-github`, `release-homebrew`, `release-npm`, `release-pypi`); the
245 `npm/` and `pypi/` dirs hold the wrapper-package templates. Update `CHANGELOG.md` for releases.