basis-cli 0.7.1

The basis CLI, over the basis SDK and basis-acp: headless runs, an ACP server, and a websocket bridge. Installs as `basis`.
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
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
# basis

> **basis** — the minimal set everything else is built from.
> Nothing in it reduces to anything else; your host supplies the rest.

basis is an agent harness you **embed**. `basis` is the harness itself, as a library: open a
`Workspace`, mint runs from it, read one event stream, plug your own code into the seams. It carries
no protocol, no transport and no terminal code, so an embedding host's dependency graph states what
it uses ([ADR-0011](docs/adr/0011-layered-crates.md)); `basis-tasks` is the durable task layer
over it ([ADR-0022](docs/adr/0022-the-task-layer-is-a-crate.md)), and `basis-acp` and the binary
are thin shells.

```rust
// [dependencies] basis = "0.4"
let workspace = basis::Workspace::open("/repo").await?;
let mut run = workspace.prepare("what does this repo do?")?;
let report = run.execute(basis::CollectingSink::default()).await?;
```

To try it as a command, set a provider key — `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`,
or `OPENROUTER_API_KEY` — and point it at a repository. At a shell a prompt runs and answers, while
leaving a durable handle behind it; any endpoint serving the OpenAI **`chat/completions`** API
works — Ollama, LM Studio, vLLM, llama.cpp, a gateway, a proxy — URL as published, `/v1` handled.
(That is the wire "OpenAI-compatible" means in the wild. OpenAI's own **Responses** wire is what
the `openai` provider already speaks; a proxy that serves it instead is reached by saying
`RuntimeBuilder::with_wire(Wire::Responses)`.)

```sh
cargo install basis-cli          # the binary is `basis`
basis "summarize what changed in the last three commits"
basis spawn -C ../other-repo --deadline 10m --tool-budget 40 "find the slowest test, explain why"

export BASIS_BASE_URL=http://127.0.0.1:3455/v1 BASIS_API_KEY=…
basis spawn --model gpt-5.6 "explain the module layout"

basis spawn --provider ollama --model qwen3 "explain the module layout"   # no key: none needed
```

A server that takes no key — Ollama and LM Studio by name, or any base URL with no key
exported — is spoken to with no `Authorization` header at all; one that wanted a key answers
401 in its own words rather than basis refusing up front.

## Lightweight, as a number

- **`basis`'s direct dependencies are `mentra` plus six utility crates**`async-trait`,
  `serde`, `serde_json`, `serde_yaml_ng`, `thiserror`, `tokio`.
- **MCP compiles out.** `default-features = false` builds a core with no MCP concept at all: no
  `.mcp.json` discovery, no `McpConfig` on a run, no servers registered
  ([ADR-0012]docs/adr/0012-one-contract-many-bindings.md). Custom tools remain, because MCP was
  only ever one of the ways to reach them.
- **~10 MB release binary** (10.7 MB measured at 0.6.0), down from cargo's 24 MB default. Each of the four profile settings that
  gets there is argued in the workspace manifest, including the one deliberately absent:
  `panic = "abort"` turns any panic into a dead process, which is what an embedded harness and a
  long-lived server exist to avoid.
- **~29k lines of Rust** across the four crates, ~40k with tests.

## The SDK

**A workspace opens once and mints runs.** Opening settles everything that belongs to the repository
rather than to the prompt — context documents, the resolved model, skills, templates, hooks, MCP
connections — so `prepare` is *synchronous*, and a twenty-way fan-out reads `AGENTS.md` once
([ADR-0010](docs/adr/0010-the-crate-is-the-workflow-surface.md)). `Workspace` is `Send + Sync`, so
those runs can be spawned tasks.

What belongs to the *process* rather than to the repository — the provider and its credential,
where history is kept, the host's own interceptors, the gate that puts a consequential call to a
run's `Approver` — is a `Runtime` ([ADR-0018](docs/adr/0018-the-runtime-owns-the-process.md)).
`Workspace::open("/repo")` builds a private one bound to that repository and is unchanged by
the split; a host opening N of them builds `Runtime::builder().build()?` once and hands each
workspace an `Arc` of it, so N repositories cost one provider resolution and one history store.

Keep the run and send again for a conversation — `run.send("and which of those is riskiest?", sink,
AllowAll)` — because the session survives the turn, and `run.agent_id()` is the handle
`Workspace::resume` takes in a later process.

Bounds are builders on `RunSpec` — one shape, however the run is made — and every bound ends the
run *gracefully*: the stream closes the way it
always does and whatever the model committed is kept. `report.stopped_by` is
`Some(Bound::Deadline | Bound::ToolBudget | Bound::TokenBudget)` when a bound ended the run rather
than the work ([ADR-0014](docs/adr/0014-watch-retired-runs-are-boundable.md)).
[docs/embedding.md](docs/embedding.md) is the full reference for the SDK sections that follow.

### Answers you can branch on

A run that answers in prose composes with nothing, because the next step has to parse English to
find out what happened. `output::<T>()` asks for a declared shape instead: the model is handed one
terminal tool whose input *is* the answer.

```rust
// findings_spec(): a name, a description, and a JSON Schema you write yourself
let output = run.output::<Findings, _, _>(SUBMIT, findings_spec(), sink, AllowAll).await?;

for finding in output.value.findings.iter().filter(|f| f.blocking) { … }
```

The schema is yours to write rather than derived from the type, because its field descriptions are a
*prompt*: they are what the model reads to decide what belongs in each field. One caveat worth
reading twice: by default a typed turn **shapes** rather than works, holding the answering tool
alone, so it answers from what earlier turns gathered. Ask it to read *and* answer and it returns a
well-formed answer from a model that opened nothing, reported as a success. Either read on an
ordinary turn and shape on the next, or hand the turn its tools back with `with_tools()`.

### One allowance across many runs

Dividing a limit across a fan-out starves the runs with something to say; granting it per run
multiplies the bill by N. A `BudgetPool` is the single figure in between:

```rust
let pool = basis::BudgetPool::new(500_000);
let mut reviewer = workspace.prepare(pool.spec("review the tests"))?;
```

It is soft, and honestly so: usage is known only once a round has streamed, so a job lands at up to
the limit plus one in-flight round per concurrent run. A turn drawing on a spent pool is refused with
`RunError::BudgetExhausted` *before* its prompt is sent — a decision with its own name, so a fan-out
stops minting on it instead of retrying it like a provider error.

Work a run delegates through `spawn` is inside the **bound** and, since mentra `5f303b8` and
basis `e22aa63`, inside the **tally** too: the subagent runs on the parent's accounting handle,
the child's usage is relayed onto the parent's stream, and `RunReport::usage` agrees with the
figure the bound stops on. [docs/REDESIGN.md](docs/REDESIGN.md) records the gap as closed.

Who that subagent *is* is a host decision since 0.7: `RuntimeBuilder::with_child_policy` maps
each delegation (its prompt, the parent, the workspace) to a `ChildSpec` overriding the child's
roster, model, or system prompt — cheap read-only triage beside a full-strength fixer is the
shape it exists for (`examples/child_policy.rs`). Unset, every child stays an exact clone of its
parent as it always has, and the approver's preview gains a `child` key only when a policy
actually overrode something, so existing remembered rules keep matching.

### One stream for many runs

Each run wants a sink of its own; a host wants one view of all of them without losing which run said
what. `EventFanIn` mints one tagged sink per run and merges them:

```rust
let fan = basis::EventFanIn::new();
let (a, b) = (fan.sink("tests"), fan.sink("docs"));
let mut merged = fan.into_events();   // minting closes here
while let Some(tagged) = merged.recv().await { … }
```

The tag rides outside `Event`, so the versioned wire schema stays what its number promises. The
stream ends when the last sink is dropped — and a finished run hands its sink back inside its report,
so a report held past the join is a branch of the stream held open. That is the one sharp edge in the
design: hold the answers, drop the reports.

### Structured concurrency

In process, concurrency is the host's tokio: a fan-out is a `tokio::task::JoinSet`, a stop
button is the `CancellationToken` a `TurnOptions` hands back, and what keeps an unattended branch
finite is the bounds — deadline, tool budget, token budget — not a scheduler of basis's own
(`examples/review_workflow.rs` runs that shape live). The four ADR-0017 rules — `spawn` returns a
handle immediately, `wait` observes a terminal state without rerunning anything, `cancel` flows
downward to attached descendants, detached work is a new root — are the CLI's durable-task
contract below ([ADR-0017](docs/adr/0017-structured-agent-concurrency.md)), where a handle is
something any process can name and the wait rules earn their keep.
Stopping one turn is two signals:
`TurnOptions::cancellable()` abandons the turn and rolls it back, which is a client's stop button;
`TurnOptions::stoppable()` ends it at the next round boundary, keeping what the model committed.

## The seams

**`Approver`** answers *may this happen*. `AllowAll` (what a run with no approver gets) and `DenyAll`
ship in `basis`; everything between them — allow edits but deny the network, ask over Slack with a
timeout, escalate after the third refusal — is an impl, and a refusal names its reason, since that
reason is what the model reads as the call's result.

**Interception** answers *may this happen, in this form*, and is one contract with two bindings
([ADR-0012](docs/adr/0012-one-contract-many-bindings.md)): a repository declares a subprocess in
`.basis/hooks.json`, and an embedding host writes `Runtime::builder().with_interceptor(…)` — host
scope is runtime scope ([ADR-0018](docs/adr/0018-the-runtime-owns-the-process.md)) — so its own
compiled code gets the say, which is what you want when the guard needs a vault handle, a token
you just minted, or a regex that lives in a config struct. `intercept(&HookRequest)` answers
`HookOutcome::Allow`, `Deny`, or `Modify`, and `basis::async_trait` is re-exported so writing the
impl costs your manifest nothing.

Both bindings speak the same allow/deny/modify vocabulary and are folded by one chain: interceptors
first (registration order), then global hooks, then workspace hooks — the further a participant is
from the workspace's own data, the earlier it speaks. First refusal wins, so your compiled guard can
refuse before a repository's program is spawned at all, and a participant that errors or panics
**denies**.

Both are asked again *after* a call — `review(&HookRequest)` in process, `"event":
"post_tool_use"` in the file — because whether a command printed a credential is not knowable
from its arguments. There the answers are keep, `Replace` with a different result, or `Deny`,
which shows the model the reason instead. Nothing there can un-run anything: the event stream
already carried what the tool really returned, so what this decides is the model's view and not
the record.

**Sinks** are the third seam: anything `FnMut(Event) -> io::Result<()>` is one, beside
`CollectingSink`, `NullSink`, `JsonlWriter`, and the tagged sinks above. **Where history goes** is
yours too — `RuntimeBuilder::with_store_dir` names a directory, `with_ephemeral_history` uses an
in-memory store that survives nothing. Unset, mentra keys a database by the *process's* current
directory.

## What the workspace contributes

The core has no opinions. Task-specific behavior enters through data — the prompt, the workspace, and
config — never through code in `basis`:

- **`AGENTS.md`** — a global config directory, then each ancestor outermost-inward, then the workspace
  root; later files are more specific, and all are named in `run_started`. `CLAUDE.md` is read where a
  directory has no `AGENTS.md`, so a repository that carries only the older name is not a repository
  with no instructions; a directory holding both contributes the `AGENTS.md`.
- **Skills** — four roots, most specific first: `.basis/skills/` and `.agents/skills/` in the
  workspace, then `skills/` in the global config directory and `~/.agents/skills/`. The `.agents`
  pair is the directory other harnesses read, so a skill written once is found here too. Loaded by
  name on demand, so only descriptions cost context, and a nearer root shadows a name rather than a
  whole directory.
- **Prompt templates**`.basis/templates/*.md` with `$ARGUMENTS` and `$1`, `$2`…; a nested path is a
  namespace (`git/commit.md``git:commit`). ACP clients get them as commands.
- **The model choice**`.basis/config.json`: `provider`, `model`, `effort`, schema-versioned, with
  `${VAR}` expansion, layered over a `config.json` in the global config directory. Without it, no
  `--model` means whatever the provider lists newest *today*, which is not a thing a repository
  chose. A flag still wins; the file still beats the environment. `base_url` is honored only from
  your own global file, and a workspace file that sets it is refused by name: a file a repository
  ships must not be able to point the model's traffic — and the key on it — somewhere you did not.
- **Declared tools**`.basis/tools.json`: a name, a description, a JSON schema and an argv array,
  and the model gets a tool that pipes its input to that program's stdin and reads stdout back. No
  shell anywhere on the path, so nothing has to be quoted or encoded around quoting. Every one is
  consequential — the format cannot say "read-only" — and the approver is shown the command, not
  just the name.
- **MCP servers**`.mcp.json`, the same shape other agents read, with `${VAR}` expansion. An ACP
  client can send servers on `session/new`; both sets are honored.
- **Hooks**`.basis/hooks.json`: commands that take JSON on stdin and answer `allow`, `deny` with a
  reason the model sees, or `modify` with a replacement input. An entry that says `"event":
  "post_tool_use"` is asked after the call instead, is shown what the tool returned, and may
  `replace` it. Any language; one that breaks denies.
- **Memories**`.md` files with `name`/`description`/`type` frontmatter, indexed into the system
  prompt at open: name, one line, path — the body stays on disk for the model to `read` when the
  description warrants it. Two roots: `memory/` in the global config directory, and `memory/` beside
  the runtime's store when history has a named directory; a workspace memory shadows a global one by
  name. No database and no memory tool — recall is `read`, search is `grep`, writing one is `write`  and zero memories cost zero context.

[docs/conventions.md](docs/conventions.md) is the one-page reference: every file, every directory,
every `BASIS_*` variable, and which wins when two say the same thing.

- **Command targets** — a host can register executors by name, and a command can say which one
  it wants: `!@mac xcodebuild -list` where `!cargo test` runs where basis is running. For the
  container-on-a-Mac case. basis routes; the host writes the executor; nothing about it is
  confinement ([ADR-0021]docs/adr/0021-a-command-names-where-it-runs.md,
  [docs/targets.md]docs/targets.md).

Details of each, and of the one `spawn` tool carrying both commands and delegation
([ADR-0016](docs/adr/0016-one-delegation-surface.md)), are in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).

## The same core, two shells

`basis serve --acp` speaks the [Agent Client Protocol](https://agentclientprotocol.com) (JSON-RPC 2.0
over stdio) over `basis`'s event stream, so any ACP client drives it with no basis-specific client
code. An ACP session *is* a mentra agent, so `session/load` resumes a conversation from a previous
process and basis stores no mapping of its own
([ADR-0007](docs/adr/0007-acp-sessions-and-the-dispatch-loop.md)); permission requests become
`session/request_permission`, so approval is the client's UI, not basis's. A client can change the
model and the reasoning effort per session through `session/set_config_option`, and send an image
alongside its text. `basis serve --bridge` puts
the same server behind a websocket for a browser client, binding loopback and serving nothing until
an `--allow-origin` is named — a websocket handshake is exempt from the same-origin policy.

The CLI is one small grammar ([ADR-0015](docs/adr/0015-cli-grammar.md)). A positional argument that
names no subcommand is a prompt; bare `basis` prints usage rather than starting a server:

```
basis "<prompt>"                     # shorthand: exactly `basis spawn "<prompt>"`
basis "/<template> <args>"           # a prompt that is a `.basis/templates` command
basis spawn "<prompt>"               # at a shell: run it here and print the answer
basis spawn "<prompt>" --resumable   # mint the agent, print its handle, drive nothing
basis spawn "<prompt>" --await       # inside a task: wait for the terminal result
basis spawn "<prompt>" --continue    # a new task on the conversation last worked in here
basis spawn "<prompt>" --session <TASK>  # the same, on the one that handle names
basis list                           # this workspace's tasks, last worked in first
basis send <ID> "<message>"          # enqueue a later turn and print its message ID
basis send <ID> "<message>" --await  # enqueue, then await that message's reply
basis ask <ID> "<question>"          # send with the correlated reply wait implied
basis wait <ID>                      # repeatably observe the task's terminal result
basis wait <ID> --message <MID>      # await/retry one message's correlated reply
basis cancel <ID>                    # request cancellation (attached descendants too)
basis watch <ID>                     # observe bounded/replayable progress
basis inbox [ID]                     # list bounded message/reply summaries
basis serve --acp                    # ACP server on stdio — what an editor spawns
basis serve --bridge                 # the same ACP server on a websocket, for a browser
basis fingerprint                    # the workspace's hash, for a loop you write yourself
```

Handles are durable: an agent is a checkpoint on disk under one global data directory
(`BASIS_DATA_DIR`, else `XDG_DATA_HOME`, else the platform data home), so `wait`/`watch`/`cancel`/`inbox`
still answer after the submitting process exits — there is no resident process of any kind
([ADR-0019](docs/adr/0019-the-filesystem-is-the-coordination-surface.md)). The liveness contract is
plain: **an agent advances only while a process is attached to it.** Which process that is follows
from where the command ran ([ADR-0020](docs/adr/0020-spawn-routing-is-decided-by-the-environment.md)):
at a shell, `basis spawn` is that process, so it drives the agent and prints the answer, and the
handle stays durable behind it. What that terminal sees is split by stream: **stdout is the
assistant's answer, streamed as it is produced; stderr is the work behind it** — the model, each
tool call and how it ended, and the one `next:` line at the end. So `basis "summarize this" >
notes.md` leaves a file holding the summary, and `2> progress.log` keeps the rest of it — with
one compact `12.3k in · 1.2k out` at the end, the same counts `--json` carries as `usage`.
Closing the terminal costs you nothing: `basis list` reads the same task directories back, last
worked in first, and `--continue` starts a *new* task on the conversation at the top of that list —
a new handle over the same history, because a settled task accepts no messages at all, and this
run's bounds and model are this run's. *Worked in* means a turn ran in the task or you sent it a
message; reading a run back — `watch`, or `wait` on one that has settled — does not move it, so the
list holds still while you read it. `--session <TASK>` names one instead, refusing a task something
is already driving. A prompt whose first token is `/name` is one of the workspace's
[`.basis/templates`](docs/ARCHITECTURE.md) commands, rendered with the rest of the line as its
arguments — the same names an ACP client offers; a first token with a second slash is a path and
passes through, and `basis spawn -` sends a literal one on stdin.
Inside another task (`BASIS_TASK_ID` set) it prints the handle of a
*resumable* agent instead, because a parent turn that blocks on its child is how a wait-for cycle
starts — `--await` is the parent's explicit opt-in, and `--resumable` is the shell's opt-out.
`basis wait <ID>` attaches to a resumable agent and produces the result, and
backgrounding is the OS's job — `basis wait <ID> &`, `nohup`, tmux, `systemd-run`, CI. Cancellation
is honored at turn boundaries (a hung tool call is ended by the deadline), and a crash mid-turn
loses the in-flight round: re-driving it may repeat tool side effects, because a checkpoint
restores state, never effects. Four rules are the load-bearing part — the durable-task contract
every handle obeys across processes (in process, concurrency is the host's tokio; see
"Structured concurrency" above):

- **Ownership is a tree.** An attached child inherits its parent's cancellation and the narrower
  deadline. A successful parent keeps attached children in scope until they settle; a failed or
  cancelled one requests downward cancellation and publishes its terminal state only after they do.
  `--detached` starts an independent root. An agent's own commands inherit `BASIS_TASK_ID`, so
  `!basis spawn "…"` from inside a task attaches a child to it.
- **Waits cannot deadlock.** Self, ancestor, and same-tree peer waits are rejected outright. A
  wait between independent trees is a process observing a file; a cycle is two observers, and each
  ends at its own finite deadline with exit `3` and a durable retry handle.
- **The inbox is bounded.** At most 16 messages over a task's lifetime; bodies and replies are
  summaries capped at 4 KiB with truncation metadata. A worker past its own turn accepts no new
  messages or children.
- **Waiting is not owning.** If a wait times out the task continues, and `basis wait <ID> --message
  <MID>` retries the same durable reply without rerunning it. Local tasks carry a finite 30-minute
  default deadline, since the submitter exits, and it binds an agent nobody attached to: the first
  attach after it lapses settles the task as failed with `stopped_by: deadline` instead of starting
  a run whose time is already spent.

0.7 change note, for an upgrade from 0.6: conversations are plain files now, not SQLite. mentra
0.21's file-backed store lands and basis links no database at all — the directory
`with_store_dir` names (the CLI's `<data-dir>/workspaces/<key>/store`) holds `agents/`,
`rules.json` and `runs.jsonl` instead of a `runtime.sqlite`, readable with ordinary tools
(ADR-0023). The old database is **not migrated**, in either direction: continuing a pre-0.7
conversation needs basis 0.6, and a store directory still holding one is refused by name —
with the ways forward in the message — rather than quietly shadowed by an empty file store.
New work proceeds by pointing `BASIS_DATA_DIR` (or `with_store_dir`) somewhere fresh, or by
moving the old store directory aside. One new knob: `RuntimeBuilder::with_child_policy` decides
per delegation who the spawned child is (roster, model, system prompt); unset is the exact
clone-of-the-parent every earlier basis spawned.

0.6 change note, for an upgrade from 0.4.x: the API got smaller and the knobs got real. `RunConfig`
is gone — open a `Workspace` and mint a `RunSpec` (`basis::run(path, prompt)` covers the one-shot);
the free `prepare`/`resume` went with it. `Supervisor` is gone — in-process fan-out is a
`tokio::task::JoinSet` plus the `CancellationToken` a `TurnOptions` hands back. The three bounds
live in one `Bounds` value (`spec.bounds.deadline`; the `with_*` sugar is unchanged). `Event`,
`RunOutcome`, `RunError`, `McpServer` and `McpError` are `#[non_exhaustive]`. A tool call's
side-effect level rides mentra's `PermissionRequested.classification`; `SideEffectLevels` is
deleted. `.mcp.json` `type: "http"` connects (Streamable HTTP). Memory is a directory convention
(`memory/` roots of frontmatter Markdown, indexed into the prompt) and mentra's memory engine is
off. New knobs: `ToolRoster`, `with_provider_instance`, `TurnOptions::with_round_strategy`,
`MemoryConfig`, `with_delegation_depth`, and basis-acp's `SessionTemplate` + `Discovery`. The
mentra floor is 0.20.0. Old CLI journals and task records still load; the wire schema number is
unchanged.

E2 change note, for an upgrade: the hidden per-workspace daemon and its registry are gone, and
nothing moves with them. `BASIS_REGISTRY_DIR` is removed, and whatever the registry held — under it,
under `BASIS_CONFIG_DIR/agents`, under `XDG_RUNTIME_DIR`, or in the temp directory — is **not
migrated**, conversations included, because the daemon kept mentra's store beside its registry in a
directory the platform is entitled to erase. Pre-E2 task handles therefore do not resolve.
`BASIS_DATA_DIR` is the override that replaces it (`BASIS_CONFIG_DIR` still names the *config*
directory and is unchanged), and conversations now live at `<data-dir>/workspaces/<key>/store` — a
data home rather than a runtime directory, which is what makes "resume it tomorrow" mean anything.
`basis spawn` reports `resumable` rather than `running` for an agent with no attached process, and
the durable `orphaned` terminal state is retired: nothing restarts out from under a task anymore.

`--json` gives one bounded JSON object per lifecycle command; at a shell `basis spawn --json
"<prompt>"` streams the attended JSONL event stream, first line always `run_started` with
the schema version, last always `run_finished` carrying `stopped_by` when a bound ended the run.
`basis run` is a compatibility alias. Exit codes are contract, so a caller branches without parsing:

| Code | Meaning |
|---|---|
| `0` | the run finished |
| `1` | the run failed, or basis could not start it |
| `2` | the invocation was wrong |
| `3` | a bound tripped (`--deadline`, `--tool-budget`, `--token-budget`); committed work was kept |

`3` is deliberately not `1`: "the model ran out of the time you gave it" and "the provider refused
the request" call for different reactions. basis ships no scheduler either — an interval belongs to
cron, systemd, CI, or a tokio task in your own binary — but it ships the piece that is easy to get
wrong. `basis fingerprint`, and `Workspace::fingerprint()` in process, digests `git ls-files` (path,
length, mtime, plus `HEAD`) and reports *changed* in every uncertain case: a false "changed" costs
tokens, a false "unchanged" silently stops the loop. The loop itself is composition, written out in
[docs/ARCHITECTURE.md §8](docs/ARCHITECTURE.md).

## Security posture

basis claims no sandbox. A run holds whatever authority the user account that started it holds, and
nothing inside the process narrows that ([ADR-0013](docs/adr/0013-the-host-owns-the-boundary.md)).
What is in-process is **hygiene**: the agent is scoped to the workspace, and `.git/hooks` and
`.git/config` are denied to the *file tools*, because a file written there runs on the next commit.
A shell redirect walks straight past both, since nothing parses shell.

Commands are on by default, because a harness that cannot run the test suite does little real
work. `--no-shell` shuts them off; file writes still land, so a run that must change nothing wants
`--approve never`. Both narrow what *this run* does rather than confining the process. `--approve`
is `always` (the CLI default), `never`, or `prompt`; read-only calls are never queued, but neither a
command nor a delegation is a read, so both `spawn` modes reach an approver. `prompt` needs someone
to ask, and asks at the terminal of whichever process is driving the agent: it is the default over
ACP and works wherever a run is attached to a terminal, while `--resumable` work **rejects** it
rather than silently allowing. Task state lives in a user-private (0700) data
directory and never records a credential — an agent's executor is whichever process attached to
it, holding that shell's environment, so there is nothing on disk to leak and no daemon holding a
key on your behalf; the bridge's `Origin` allowlist starts empty. The boundary, where you want
one, is the OS's — [docs/containerization.md](docs/containerization.md) has the read-only-root
pattern, what it protects, and what it does not. A command routed to a registered target
(`!@mac …`) is no different: it runs with whatever authority the executor the *host* wrote
holds, basis never calls a target "the host", and [docs/targets.md](docs/targets.md) says so
in its own words.

## Examples

`cargo run -p basis --example <name> -- …`, with a provider key set.
[`embed.rs`](basis/examples/embed.rs) reacts to events as they arrive,
[`conversation.rs`](basis/examples/conversation.rs) takes two turns on one session,
[`watch.rs`](basis/examples/watch.rs) is the recurring-run loop, and
[`reviewed_shell.rs`](basis/examples/reviewed_shell.rs) is an `Approver` reviewing the agent's
commands with a cheap typed turn of its own. [`review_workflow.rs`](basis/examples/review_workflow.rs)
composes the lot: one workspace, one budget, typed findings, one merged stream, a folded verdict.

## Status

This README describes only what is built, and all of the above is: P0–P4 and the SDK-first redesign
through Phase E — the ACP server with modes, session listing and history replay; conversation and
resume; durable `spawn`/`send`/`ask`/`wait`/`cancel`/`watch`/`inbox` over the filesystem; MCP from
`.mcp.json` and from the client; templates as commands; hooks and interceptors; the websocket
bridge; branching; compaction, whose defaults are basis's own — every tool result the model was
shown stays in front of it — and whose knobs are `WorkspaceBuilder::with_compaction`; and the SDK
proper — and since 0.7, conversations as plain files, a child a delegating parent can shape, and
`basis-tasks` for a Rust host that wants durable handles. Named honestly, still open: the packages
convention and provider OAuth; surfacing `team_*`, which stays hidden until a concrete use case
asks for it; and **nobody has driven this from Zed or JetBrains yet** — it is verified against the
protocol and its official client library, not against the ecosystem. The four published crates are
on crates.io at one version, as is mentra. CI runs fmt, clippy at
`-D warnings`, and the full suite on Linux, macOS and Windows, plus MSRV (1.88, edition 2024).

Every addition faces one check: does it make embedding cheaper for a Rust host, is it a convention
other agents already speak, or is it a seam? If none of the three, it is the host's code, the
client's UI, or the OS's job — hence no TUI, no scheduler, no container, no workflow DSL.

Docs: [PROPOSAL.md](docs/PROPOSAL.md) (why) · [ARCHITECTURE.md](docs/ARCHITECTURE.md) (how, with §8
for `--effort`, custom endpoints, and the hooks `shell`→`spawn` migration) ·
[conventions.md](docs/conventions.md) (every file and variable basis reads, in precedence order) ·
[embedding.md](docs/embedding.md) (the SDK in detail) · [targets.md](docs/targets.md) (running a
command somewhere else) · [REDESIGN.md](docs/REDESIGN.md) (ledger) ·
[adr/](docs/adr/) (23 locked decisions) · [proposals/](docs/proposals/) (deferred ideas).

## License

MIT. See [LICENSE](LICENSE).