attini 0.0.1

CLI coding agent that aims to be as autonomous as it can be, without ever leaving your control
Documentation
# Dedicated `attini approve` command and one-shot `--grant`

**Status:** Implemented. `attini approve` exists, the `attini agent --approve` flag was
removed, and `--grant oneshot|session|workspace` folds a persistent rule into the
approval. The sections below are the design record; see the git history / README for the
shipped form. The *rationale* (why a subcommand rather than a flag) is kept because it is
still worth reading before anyone proposes re-adding `--approve`.

The standalone `attini grant` / `attini grant-read` subcommands described below were later
**removed**: with the JSONL format, rules are plain lines edited by hand, and the only
write path is `attini approve --grant`. `--grant` covers **commands** (persists an
args-prefix), **reads** (persists a path), and **patches** (persists a `write` path); the
pending call's kind decides what is written and `SCOPE` only decides the lifetime. The
read side is described in `docs/design/read-approval.md`, the patch side in
`docs/design/patch-grant-scope.md`.

## The observation

Approving a pending tool call is one of the two ways to advance an `attini agent`
session, yet it is expressed as a *flag* on `agent`:

```sh
attini agent --approve
```

Two problems:

1. **Typos fall through to the prompt.** `--approve` is easy to mistype (`--aprove`,
   `--approve=`). With the current `noargs` setup a mistyped flag is not a hard error:
   because `agent` has an *optional positional* `<PROMPT>`, the unknown token is absorbed
   as the prompt instead of being rejected. Verified live:

   ```
   $ attini agent -s approv-check --approv
   [agent] model=deepseek-flash session=approv-check ctx=0
   ... the model runs a full investigation turn, treating "--approv" as the prompt ...
   ```

   So the typo does not merely fall back to *nothing* — it silently starts a **new model
   turn with the typo as the prompt**, spending tokens and running tools the human never
   asked for. (A second token surfaces only later, as an `unexpected argument` error:
   `attini agent -s s --approv hello` rejects `hello`, because `--approv` was consumed as
   the prompt first.) This is the strongest argument for the change: the confusing path
   only exists because `agent` must keep an optional `<PROMPT>`, and `--approve` rides
   along on that command.
2. **It is conceptually a command, not a modifier.** "Approve the pending call" is a
   self-contained action on a session, in the same family as `attini status` /
   `attini ask`. Modelling it as a subcommand gives it its own `--help`, its own
   name in the top-level command list, and shell-completion/tab-completion ergonomics.
   Decisively: an `approve` subcommand has **no positional**, so `attini approve --approv`
   is a clean unknown-flag error — the fall-through in (1) cannot happen there.

## Proposed shape

Promote approval to a first-class subcommand:

```sh
attini approve [-s NAME]        # approve the session's pending tool call(s)
```

- Symmetric with the rest of the CLI: the session is named with the same `-s` /
  `ATTINI_SESSION_NAME` fallback used everywhere else.
- It is a *separate token*, so a typo (`attini aproove`) fails as an unknown subcommand
  instead of silently doing something else.
- Keeps `agent` for "advance with a new prompt" only, which sharpens both commands.

### Interaction with `agent`

- **`attini agent --approve` is removed, not deprecated.** Keeping it would leave the
  fall-through in (1) reachable through the flag the change is meant to retire, so the
  two would contradict each other. The `Continuation::Approve` path in `src/tell_cli.rs`
  stays — the new subcommand just constructs it (see `try_run_approve` in `src/main.rs`).
- `agent` keeps its optional `<PROMPT>` (it is the normal "advance with a new prompt"
  path), so `attini agent --approv` still becomes a weird prompt. That is fine: once
  `--approve` is gone, no one expecting to approve reaches it, so the leftover token is
  just a mistyped prompt, not a mistaken approval.
- `--approve` forbids combining with a prompt. A dedicated command makes that rule
  structural instead of validated: `approve` has no positional, so there is nothing to
  reject. (The old `--skill` combination rule is moot: `--skill` itself has since been
  removed — context now enters only through the prompt or `--stdin`.)
- Consider a matching `attini reject` for symmetry even though `--reject` was removed
  (a fresh prompt already covers "reject + redirect"). Reject-by-flag is gone, but a
  read-only `approve` command does not force a `reject` command to exist.

## Second idea: one-shot `--grant SCOPE` on approve

Today, avoiding a future approval means finding the argv-prefix and running a *separate*
`attini grant ...` invocation. The agent even prints a suggestion for it
(`emit_suggested_rule` in `src/tell_cli.rs`):

```
[command] approval required: cargo test
suggested rule (persist separately after approve):
  attini grant cargo test                # session-local
  attini grant cargo test --workspace    # workspace-wide
```

That is two commands and a copy-paste. Fold the grant into the approval:

```sh
attini approve --grant <SCOPE>
```

where `SCOPE` is one of:

| `SCOPE` | Meaning | Equivalent to |
|---|---|---|
| `oneshot` | approve this call only, persist nothing | default (current `--approve`) |
| `session` | approve and append the args-prefix rule to the session `permissions.jsonl` | `approve` + `attini grant <prefix>` |
| `workspace` | approve and append to the workspace `permissions.jsonl` | `approve` + `attini grant <prefix> --workspace` |

Settled semantics:

- **`approve` and `grant` are independent (option B).** Approving is a one-shot action
  on the current pending call; granting is a persistent state change. They are kept
  separate so each has a single, clear outcome. The grant runs *after* the approve; it
  is best-effort.
- **Grant failure is a warning, not a rollback.** If the grant cannot be written —
  `AlreadyGranted`, `ExistingDenyConflict`, `ExistingAttributeOnlyConflict`, or any I/O
  error — the approval still stands and the command still runs. The failure is reported
  as a one-line warning to stderr afterward. The human asked to approve; that succeeded;
  the convenience grant is a bonus that may or may not apply.
- **A grant that cannot be formed is an error.** If the pending call is not a `command`,
  or the argv cannot be truncated to a prefix at all, `--grant` is rejected up front
  (before approving) rather than silently degrading to a plain approve. Silent no-ops
  are against attini's philosophy.
- **Applies to commands, reads, and patches.** The persisted shape is decided by the
  pending call's kind (argv prefix, read path, or `write` path). A patch pending arises
  from an `Add` or a non-tracked `Update` that reached a prompt; a gitignored-parent
  rejection is not a pending and is lifted by hand-editing a `write` rule instead. See
  `docs/design/patch-grant-scope.md`.
- **Which prefix gets granted.** Reuse the same truncation `emit_suggested_rule` uses
  (first two argv elements, e.g. `cargo test`) so `--grant session` does not bake in
  every flag. The human should see the exact prefix that will be written, exactly as the
  existing suggestion does today (verified live: `ls -la .` suggests `attini
  grant ls -la`). Because the prefix comes from the same helper as the suggestion, the
  two can never disagree.
- **Multiple pending calls.** `pending.json` is an array and approve handles all of them.
  For `--grant`, if more than one command is pending, `--grant` is an error (ambiguous
  which prefix to persist); `--grant oneshot` is unaffected because it persists nothing.
  This keeps `--grant` a deliberate, single-target action.
- **Reuse the existing machinery.** `permissions::grant(scope, args_prefix)` and
  `GrantScope::{Session, Workspace}` already exist (`src/permissions.rs`); the command
  just calls them after a successful approve, inheriting the existing
  `AlreadyGranted` / `ExistingDenyConflict` handling.

## Why it is only *deferred*

1. **A dedicated subcommand is a small UX win but a visible CLI change.** It adds a
   top-level command, changes help output, and needs a migration note for anyone using
   `--approve` in scripts. The typo-safety benefit is real but modest.
2. **`--grant` has unresolved corners** (multi-pending ambiguity, patch vs. command,
   approving-all while granting-one). Each is solvable, but the design should be settled
   deliberately rather than smuggled in.
3. **No observed pain yet.** The current `--approve` + printed `attini grant ...`
   suggestion works; the friction is an ergonomic annoyance, not a bug. Per attini's
   philosophy (add surface only when the need is demonstrated), this can wait until the
   two-command shuffle is actually felt.

## If revived (implementation sketch)

1. `src/main.rs`: add `try_run_approve` (parse `-s/--session`, `--grant <SCOPE>`, reject
   unknown scopes), dispatch it before/alongside `agent`. Build `Continuation::Approve`
   and call `tell_cli::run` exactly as `try_run_tell` does today.
2. `src/main.rs`: remove the `--approve` flag from `try_run_tell` (and its prompt
   validation, which becomes unnecessary).
3. `src/tell_cli.rs`: after the `Continuation::Approve` branch completes, if a grant was
   requested, read the approved command's argv from the pending record, run it through
   the same truncation `emit_suggested_rule` uses, and call `permissions::grant`. Report
   a grant failure as a warning and keep the approval.
4. Update `README.md` (usage lines, the approve paragraph, the grant paragraph) and the
   `emit_suggested_rule` output to mention the shorter `attini approve --grant session`.
5. Tests: `attini approve --approv` fails as an unknown flag (no positional to absorb it);
   `--grant oneshot` persists nothing; `--grant session` appends the truncated prefix;
   `--grant` on a pending patch errors up front; `--grant` with multiple pending commands
   errors; a grant conflict warns but the approved command still ran.

## Alternative: keep the flag, add the sugar

If a new top-level command feels like too much surface, the minimal half is to keep
`attini agent --approve` and only add `--grant SCOPE` to it. That removes the
copy-paste of the `attini grant` suggestion while leaving the typo-safety and
conceptual-cleanliness arguments unaddressed.