attini 0.0.1

CLI coding agent that aims to be as autonomous as it can be, without ever leaving your control
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
attini
======

[![attini](https://img.shields.io/crates/v/attini.svg)](https://crates.io/crates/attini)
[![Actions Status](https://github.com/sile/attini/workflows/CI/badge.svg)](https://github.com/sile/attini/actions)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

**WIP**

A CLI coding agent that aims to be as autonomous as it can be, without ever
leaving your control.

attini lives in your workspace and works on its own: it lists and reads files,
searches the codebase, applies patches, and runs commands. Safe, reversible
changes are applied without a prompt; anything consequential — a new file, a
change outside git, a command that isn't explicitly allowed — is shown as a
preview and waits for your approval.

The name comes from the Attini tribe of ants — leaf-cutter ants that do not eat
the leaves they gather, but cultivate a fungus with them. attini follows the
same idea: the agent gathers changes, but nothing becomes real until the human
cultivates it through approval.

## Overview

- Talks directly to the DeepSeek Chat Completions API over HTTP/1.1 + TLS (streaming)
- Built on a Sans I/O design: parsers, data types, and state machines live in the
  core without performing I/O, while async transport, TUI, and filesystem
  integration live in surrounding modules
- The agent can use read-only tools (`list`, `read`, `search`) and a `patch` tool
  (add / update); reversible changes to tracked files are applied directly, while
  consequential writes (new files, non-tracked changes) require a preview and
  approval
- Enforced constraints include workspace-boundary checks, file-size limits, and
  search-result limits

## Design philosophy

attini is a **semi-autonomous coding agent that values controllability and
understandability over autonomy**. It is deliberately explicit and
conservative: it prefers that the human tells it exactly what to do, rather
than letting the agent infer or guess:

- **Context is requested, not discovered.** The agent does not go looking for
  context: nothing is added to the system prompt unless you put it there — the
  prompt itself, or `--stdin` for pasted data. No tool lets the model pull in
  extra context at runtime.
- **State changes are surfaced.** `patch` shows a preview (file list + a
  diff) and waits for approval on any non-tracked write; the model's
  in-flight intent is observable via `attini ask` / `attini status`; and a
  one-line status is printed to stderr so you always know which session/model
  is advancing. Diagnostic output never pollutes stdout.
- **No silent side effects.** Reaching outside the workspace — a read or a
  write — always requires explicit human approval, destructive operations
  require explicit confirmation, and a non-tracked file never silently
  overwrites a tracked one. Where a behaviour is too risky to do safely, attini
  refuses rather than guesses — for example rejecting multiple edits to the
  same path in one `patch`.
- **The model's own space is gated too.** The model can work freely in its own
  scratchpad, but every write there still passes through approval.
- **Tools amplify understanding; they do not replace it.** The agent already
  reads, searches, edits, and runs commands — tools make those faster and
  safer, but they never hand the agent a capability it does not understand.
  attini has no `plan` tool that plans for the model, no `skill_load` that
  injects context mid-run, and no `subagent` that delegates the thinking away.
  When it can already do the work, attini tunes the environment itself (for
  example quieting the default `cargo` output) rather than inventing a magic
  tool.

This is why, for instance, there is no automatic skill or instruction-file
discovery (no `~/.attini/skills`, no `.attini/skills`, no `AGENTS.md` scan) —
context should enter a session only because the human asked for it, in the
prompt. See [Intentionally not supported](#intentionally-not-supported) for the
concrete list of removed and never-added features.

## Intentionally not supported

attini has grown by *removing* whole classes of convenience features rather
than retaining them. That removal is policy, not an accident or unfinished
work. The list below answers "if you look for feature X, is it gone because it
was bad, or just not built yet?" — for these, deliberately:

- **Memory / automatic context persistence.** The three-tier `memories.md`
  loading was removed. If you need persistent context, put it in the prompt.
- **Subagent / delegation.** `subagent_run` was removed: it was synchronous and
  serial, and its only real value (context isolation) is already available by
  running a separate session yourself.
- **`AGENTS.md` / agent instruction files.** Not implemented, and intentionally
  not planned — implicit discovery by file-name convention is exactly what
  attini avoids.
- **Auto skill load / implicit skill discovery.** No `skill_load` tool, no
  scanning of `~/.attini/skills` or `.attini/skills`. Context enters only
  because you asked for it.
- **`--skill` / `--reference` flags.** Removed. The only thing they added over
  a plain prompt was landing text in the system prompt — not a guarantee the
  model obeys (there is none, by LLM nature). Paste via `--stdin` instead.
- **Plan mode (`--plan=on|off`).** Removed. attini already gates consequential
  writes; a separate mode added a second, redundant notion of "how much
  approval" and a state file to keep in sync. Use `attini ask` to inspect a
  session read-only.
- **`--local-only` mode and rule attributes (`readonly` / `network`).**
  Removed, along with the `Mode` axis. Approval is a single, flat thing: a rule
  is allow or deny, or absent (which falls through to a pending approval).
- **Convenience tools that duplicate what the model already has** (`search`
  regex, a `sed`-style replace tool, a cross-file replace tool). Not added.
  `patch` already replaces unique substrings across multiple files, `command`
  already runs `grep`/`sed` under permission control, and literal `search`
  stays literal — a regex engine would add a dependency and a hang risk for
  little measured gain. Adding tools raises the permission/approval surface;
  see the rationale in
  [the extended list]docs/design/intentionally-not-supported.md.

The common thread: implicit, convention-based context or delegation that
attini cannot see or control. attini's answer to each is "be explicit" — put
it in the prompt, or run the other session yourself. "Unsupported" here means
"ask for it explicitly and it works as asked", not "the feature is missing and
should be added".

## Requirements

- Rust 1.93+ (`edition = 2024`)
- `DEEPSEEK_API_KEY` environment variable
- Optional `DEEPSEEK_BASE_URL` for OpenAI-compatible endpoints (local LLMs, etc.)

## Build / Test

```sh
cargo build
cargo test
```

## Usage

Set the environment variable first:

```sh
export DEEPSEEK_API_KEY=sk-...

# Optional: point at a local OpenAI-compatible server
# export DEEPSEEK_BASE_URL=http://127.0.0.1:8888/v1
# export DEEPSEEK_API_KEY=local
```

### Tell CLI (`attini tell`)

```sh
attini tell [--system-prompt TEXT] [--max-tokens N] [--temperature N] [--command-timeout N] [--stdin] "<PROMPT>"

attini approve [-s NAME] [--grant oneshot|session|workspace] [--command-timeout N]
```

`--stdin` reads standard input (until EOF) and appends it to the prompt as a
clearly marked `--- stdin ---` block, so small pasted fragments need no temp
file. When stdin is a terminal it prints a note and reads interactively until
EOF (Ctrl+D); Ctrl+C cancels. It caps input at 1 MiB and warns when stdin is
empty.

Extra positional tokens are now rejected as a usage error (`attini tell hello
world` fails instead of silently dropping `world`), so multi-word prompts must
be quoted or passed via `--stdin`.

`--max-tokens N` caps the completion-token budget for every model call in the
run. When omitted the model's own default is used.

`--temperature N` / `-t N` sets the sampling temperature for model calls.
Default is 0 (deterministic), which DeepSeek recommends for coding/math. It
can also be set via `ATTINI_TEMPERATURE`.

`--system-prompt TEXT` prepends a system message to the conversation. It can
also be set via `ATTINI_SYSTEM_PROMPT`; precedence is CLI flag, then env var,
then none.

`--command-timeout N` caps how long a single `command` tool call may run, in
seconds (default 180). The child runs in its own process group and is killed
with SIGTERM (then SIGKILL after a one-second grace) on expiry; the tool
result then reports `termination_reason: "timeout"`. `0` disables the cap. It
can also be set via `ATTINI_COMMAND_TIMEOUT_SECONDS`; precedence is CLI flag,
then env var, then the 180-second default.

DeepSeek thinking mode is always **disabled**: attini sends
`{"thinking":{"type":"disabled"}}` on every request and offers no way to
enable chain-of-thought. The human is the final gate, so a private exploration
is mostly wasted, and it was the largest source of context bloat — see
`docs/design/thinking-mode.md`. With thinking off no `reasoning_content` is
produced or replayed, and `temperature` is always effective.

### Approving (`attini approve`)

```sh
attini approve [-s NAME] [--grant oneshot|session|workspace]
```

`approve` resumes a **stopped** session, which is the same human act — "yes, go
on" — however the stop happened:

- **Pending tool call** (the loop suspended for approval): the call is approved
  and executed, then the turn continues. A pending call is a `patch`, a
  `command`, or a `read`/`list`/`search` that targeted a path **outside the
  workspace**. The read case is a **one-shot** grant by default: that single
  call is allowed through, nothing is written to `permissions.jsonl`, and a
  later read of the same path asks again. To make it stick, use
  `--grant session|workspace` (which appends a `read` rule —
  workspace-relative inside the workspace, absolute outside it), or add a
  `read` rule to `permissions.jsonl` by hand.
- **Transport failure** (a model call failed at the connection level — reset,
  timeout, DNS — before any assistant output was recorded): the *identical*
  request is re-issued. Nothing is appended, so a transient outage can be
  retried with the same command. (A definitive HTTP/API rejection is **not**
  offered this retry; only transport-level faults are.)
- **No pending call** (the loop hit `DEFAULT_MAX_TURNS`): a fixed continuation
  message is appended and the turn continues, so the session — its context and
  the model's understanding — carries over.

The message printed when the turn cap is reached names `approve` directly, so
the two paths stay connected:

```
tell loop exceeded max_turns=20; to continue this session run:
  `attini approve -s main` (or give a new instruction with `attini tell -s main "..."`)
```

If you actually want to change direction, use `attini tell` with a new prompt
instead. The exit code stays the generic `1`; attini does not assign a distinct
code to "hit the turn cap". If machine-readable distinction is ever needed, it
should be carried by a structured message rather than more exit codes.

Approving is a **dedicated subcommand**, not a `--approve` flag on `tell`. The
reason is typo safety: `tell` keeps an optional positional `<PROMPT>`, so a
mistyped flag like `attini tell --approv` is silently absorbed as the prompt and
starts an unintended model turn. `approve` has no positional, so `attini approve
--approv` fails cleanly as an unknown flag. (`attini agent --approve` was
removed, not deprecated.)

`--grant SCOPE` folds a persistent auto-approve rule into the approval, so you
do not have to edit `permissions.jsonl` by hand afterward:

| `SCOPE` | Effect |
|---|---|
| `oneshot` | approve only, persist nothing (the default) |
| `session` | approve, then append a rule to the session `permissions.jsonl` |
| `workspace` | approve, then append it to the workspace-wide `permissions.jsonl` |

What gets persisted depends on the pending call's **kind**: a `command` stores
its args-prefix (`{"type":"command","allow":true,"args_prefix":[...]}`), a
`read` stores its path (`{"type":"read","path":...}` — read rules are
allow-only, so `allow` is omitted), and a `patch` stores its path as a `write`
rule (`{"type":"write","allow":true,"path":...}`). Inside the workspace that
path is workspace-relative, the shape rules are written in; outside it, the
absolute path is kept. `SCOPE` keeps the same meaning throughout — how long the
grant lives — so one flag covers all three kinds.

Approval and grant are independent: the approval always stands, and a grant that
cannot be written (already granted, a conflicting deny rule, or an I/O error) is
reported as a one-line warning rather than rolling the approval back. A grant
that cannot be *formed* — a command's argv yields no prefix, a read has no
resolvable path, a patch touches more than one distinct path, or several calls
are pending — is rejected up front. The argv-prefix is truncated the same way as
the printed suggestion (first two elements, e.g. `cargo test`), so the two never
disagree.

A patch that touches a non-git-tracked path (including any non-scratchpad write
in a non-git workspace) is still *writable*, but it is parked for approval with a
`NOTE:` line explaining that `git checkout` cannot undo it. `approve --grant` is
the way to auto-approve such a write without hand-editing `permissions.jsonl`.
The only hard refusal left is an `Add` into a gitignored region, which must be
lifted by a hand-written `write` rule (see the permissions section), not by
`--grant`.

When an `attini tell` invocation starts, a one-line diagnostic is printed to
stderr (never stdout, so streamed content and `| jq`/redirects stay clean):

```
[tell] model=deepseek-flash session=main ctx=20736
```

`model=`/`session=` show which session/model is about to advance, and `ctx=` is
the **current** conversation size (the last recorded `prompt_tokens`, not the
cumulative billed total) — so you can see how close the session is to
compaction before it runs. `ATTINI_STATUS_LINE=0` disables the line.

### Inspecting sessions

attini keeps no abstraction over session data. A session is a directory under
`.attini/<NAME>/` holding `conversation.jsonl` (append-only JSONL), `pending.json`
(when a turn stopped for approval), `ask.json`, a `scratchpad/`, and a `LOCK`.
You can list, read, or delete sessions with ordinary shell tools:

```sh
ls .attini/                       # list sessions
cat .attini/main/conversation.jsonl   # the raw record log
tail -f .attini/main/conversation.jsonl
rm -rf .attini/main/              # delete a session (also clears its scratchpad)
```

A few read-only helpers remain, for cases where parsing the log by hand is
tedious. None of them acquire the session `LOCK` or write to the conversation log:

```sh
attini status   -s NAME [--json]   # lock + summary + pending calls + aggregate metrics
attini logstats -s NAME [--json]   # record-kind histogram, bytes by tool/command family
attini ask      -s NAME [QUESTION] # ask the model to summarise the current state
```

There is no manual `prune`: `conversation.jsonl` is append-only, but once it grows
past 100 MB the next compaction pass drops the records before the midpoint at a safe
boundary (never splitting an `assistant -> tool` pair), roughly halving the file.

Permissions live in plain `permissions.jsonl` files -- **JSONL**: one rule per
line, `#` comments allowed, edited by hand. Each rule has `type` (`command`,
`read`, or `write`); `command` and `write` rules carry `allow`
(`true`/`false`), while `read` rules are allow-only and omit it. The layer a
rule belongs to is the file it lives in: `.attini/permissions.jsonl`
(workspace) or `.attini/<NAME>/permissions.jsonl` (session). Evaluation is
last-match-wins over `workspace ++ session`, so a session rule overrides a
workspace one.

```jsonl
# allow cargo test
{"type":"command","allow":true,"args_prefix":["cargo","test"]}
# deny destructive rm
{"type":"command","allow":false,"args_prefix":["rm"]}
# read outside the workspace (read rules are allow-only)
{"type":"read","path":"../docs/"}
# let the model write under src/ without prompting
{"type":"write","allow":true,"path":"src"}
# never touch generated output
{"type":"write","allow":false,"path":"dist"}
# allow writing a file outside the workspace (absolute path)
{"type":"write","allow":true,"path":"/home/me/other-repo/notes.md"}
```

A `write` rule governs `patch` edit targets before the git-tracking heuristic: a
winning `allow:true` rule permits a write even to an untracked file (or one
outside the workspace); a winning `allow:false` rule refuses one even to a
tracked file. A write outside the workspace with no matching rule is parked for
one-shot approval, mirroring `read`. See the `patch` row in *Agent tools* below.

A `read` rule **widens** the roots a `read`/`list`/`search` may reach; it is not
a gate. There is no `read` deny, and `allow:false` on a `read` rule is a load
error, because a read deny cannot be enforced (the model can always read through
the `command` tool). Keep a file you do not want read out of the workspace rather
than writing a deny rule.

Rules are added by hand, or through `attini approve --grant` (see the Approving
section above), which folds a persistent rule into the approval you were already
giving.

### Model selection

- Default model: `deepseek-flash` (override with `--model`)

## Agent tools

| Tool | Description | Constraints |
| --- | --- | --- |
| `list` | List files and directories under a workspace-relative path | `max_entries` limit (default 200) |
| `read` | Read a UTF-8 text file | Up to 1 MiB; optional `line_range` |
| `search` | Literal substring search (no regex) | `max_results` limit (default 50) |
| `patch` | Batch of add / unique-replacement edits | Edits limited to git-tracked files, or covered by a `write` `allow:true` rule, are auto-applied; any add, non-tracked edit, or target outside the workspace not covered by a rule needs approval. `before` must match exactly once |

`patch` first presents a preview (file names + a diff body) and is applied only
after approval, unless every edit is auto-approvable (git-tracked, or allowed by
a `write` rule).

**Tool call batching:** the model may emit several tool calls in one turn. When a
turn contains an approval-gated call (a `command`, or a `patch` on a non-tracked
path), any tool call ordered *after* it in the same turn is left unanswered and
cancelled on the next resume by the orphan-repair pass, so the model has to
reissue it. attini therefore instructs the model to place an approval-gated call
last in the turn (or emit it alone); read-only calls may be freely batched and may
precede an approval-gated call.

For longer tasks the model may keep its own working notes under the session's
scratchpad directory (`.attini/{NAME}/scratchpad/`) using `patch`; those files are
not tracked by git and never appear in `git diff`. Because they are non-tracked,
`patch` writes there are still shown for approval (they are not auto-applied).

**Lifecycle:** scratchpad files are not auto-cleaned during a session — there is no
time- or size-based cleanup. They persist until you delete the session directory
yourself (`rm -rf .attini/<NAME>/`).

## Current ask (read-only)

`attini ask -s NAME [QUESTION]` asks the model to summarise the current state of a
session without touching the conversation log: what is in progress, any pending tool
call, and (when a `QUESTION` is supplied) a direct answer to that question. Records
since the last compaction summary are used by default; `--all` uses the whole
conversation and `--limit N` keeps only the most recent N records.
`--max-tokens N` caps the summariser response size. It is purely
observational — adjust the course by running `attini tell -s NAME "<new
instruction>"` (or a fresh session) and letting the model revise its approach
naturally.

Each `ask` caches the last two question/answer pairs in `.attini/<NAME>/ask.json`
and feeds them back (as a non-authoritative hint) on the next `ask`, so a
follow-up question can build on an earlier answer. The cache is keyed to the
records actually observed: changing `--all`/`--limit`, or the session advancing,
produces a different fingerprint and resets the cache (a short `(prior ask context
reset: ...)` note is printed).

```sh
attini ask -s main
attini ask -s main "What is the model currently working on?"
```

## Environment variables

| Variable | Description |
| --- | --- |
| `DEEPSEEK_API_KEY` | API key (required). Use any non-empty value for local servers that ignore auth. |
| `DEEPSEEK_BASE_URL` | OpenAI-compatible API base URL (optional). Default: `https://api.deepseek.com`. Trailing slash is stripped; `/chat/completions` is appended. Example: `http://host:8888/v1`. |
| `ATTINI_SESSION_NAME` | Default session name when `-s/--session` (or a positional `<SESSION>`) is omitted. Precedence: CLI flag, then this env var, then `main`. |
| `ATTINI_MODEL_NAME` | Default model name when `--model` is omitted. Precedence: CLI flag, then this env var, then the built-in default. |
| `ATTINI_MAX_TOKENS` | Default completion-token cap when `--max-tokens` is omitted. Precedence: CLI flag, then this env var, then the model's own default (no cap). |
| `ATTINI_TEMPERATURE` | Default sampling temperature when `--temperature` is omitted. Precedence: CLI flag, then this env var, then 0 (deterministic). |
| `ATTINI_SYSTEM_PROMPT` | Default system prompt when `--system-prompt` is omitted. Precedence: CLI flag, then this env var, then none. |
| `ATTINI_COMMAND_TIMEOUT_SECONDS` | Default `command` tool timeout in seconds when `--command-timeout` is omitted. Precedence: CLI flag, then this env var, then 180. `0` disables the cap. |
| `ATTINI_STATUS_LINE` | Set to `0` to suppress the one-line status that `attini tell` prints to stderr at invocation start. Unset (or any other value) keeps it on. |