drove 0.1.2

Versioned, declarative agent workspaces
# Drovefile reference (v3)

`Drovefile` is deterministic Starlark evaluated from the repository root. It can use ordinary Starlark expressions and repository-local `load()` statements, but Drove exposes no network, clock, environment, filesystem, or command-execution functions during evaluation.

Core constructors — `backend`, `workspace`, `pane`, `caller_pane`, `agent`, `task`, `profile` — stay bare. Backend-specific terminology lives under a namespace object: `herdr` for Herdr's flavor, `radiator` for the Radiator hub's. A Drovefile that uses only the core reconciles on every backend with no `unsupported` outcomes; using a flavor construct on a backend that doesn't implement it produces an `unsupported` outcome instead of silently dropping it.

Four resource kinds — `workspace`, `pane`, `agent`, `task` — share one profile-scoped namespace for `after`, `on_start`/`on_stop`, and adoption references. Names must match `[a-z][a-z0-9_-]{0,31}`, except `herdr.tab` names, which are free-form placement labels (a tab is a Herdr display hint, not part of the shared reference namespace).

## `backend` and target instance

```python
backend("herdr")          # core: which backend this project reconciles onto
herdr.session("drove")    # Herdr flavor: which named session
radiator.hub("main")      # Radiator flavor: which named hub
```

A Drovefile may declare both flavor instances; only the active backend's declaration is used. A profile may also name its own session or hub (see `profile` below), overriding the file-level declaration for that profile only. The backend id and target instance resolve in this order, most specific first:

1. CLI: `--backend <id>`, `--target <name>` (`--session` is a Herdr alias of `--target`; `--socket` is an explicit override).
2. Explicit environment: `DROVE_BACKEND`; `DROVE_SESSION` (a Herdr alias of `DROVE_TARGET`) and `DROVE_TARGET` mirror `--session`/`--target`.
3. Profile: `profile(..., session = ..., backend = ...)`.
4. Drovefile: `backend(...)`, `herdr.session(...)`, `radiator.hub(...)`.
5. Ambient host environment: `HERDR_SESSION` or `RADIATOR_HUB` per backend (and the ambient Radiator hub detection); `HERDR_SOCKET_PATH` as before.
6. Built-in: backend `herdr`; Herdr session `default`; Radiator hub `main`.

A Drovefile's declaration is a default that an explicit flag, `DROVE_BACKEND`/`DROVE_SESSION`/`DROVE_TARGET`, or a profile's own `session`/`backend` overrides (D46). Ambient host environment (`HERDR_SESSION`, `RADIATOR_HUB`) ranks below the file: Herdr and Radiator export these into every pane they host, so without this ranking a profile's own `session` would be silently overridden by whichever session the calling terminal happens to sit in. It only takes over when the Drovefile leaves the target unset.

For the Herdr backend, a target name of exactly `default` — from any of levels 1 to 5 above, not only the built-in — resolves the same as leaving it unset: Herdr's own unnamed session lives at `~/.config/herdr/herdr.sock`, not `~/.config/herdr/sessions/default/herdr.sock`, so `--session default`, `DROVE_SESSION=default`, `herdr.session("default")`, and an ambient `HERDR_SESSION=default` all resolve to that bare socket (D51 point 6).

## `profile`

```python
profile(
    name = "default",
    workspaces = [],
    tasks = [],
    extends = None,
    without = [],
    session = None,     # Herdr session name (or Radiator hub name, when backend = "radiator")
    backend = None,     # overrides the file-level backend() for this profile only
)
```

`extends` and `without` accept either a resource value or its name string; the value form is the documented one:

```python
profile("default", workspaces = [control, maintenance, files])
profile("core", extends = default, without = [files])
profile("monitoring", session = "drove-mon", workspaces = [monitor])
```

`extends` must reference an already-declared profile. `profile()` returns the value it registers. Composition is resolved after the whole file evaluates — the sandbox never touches other profiles or the filesystem during Starlark evaluation itself.

A profile that leaves `session`/`backend` unset inherits the file-level `backend(...)` / `herdr.session(...)` / `radiator.hub(...)`; a child profile created with `extends` inherits its parent's `session`/`backend` unless it sets its own. This lets one Drovefile declare several profiles that each live in a different Herdr session — see `profile("monitoring", ...)` in this repository's own `Drovefile` for a profile that runs in a second session, `drove-mon`, entirely separate from `default`'s session.

### The positional profile, and no-profile rules

The profile name is a positional argument, both on the bare command and on every subcommand: `drove monitoring`, `drove plan monitoring`, `drove status monitoring`. `--profile NAME` is an alias with no deprecation warning; giving both is fine as long as they agree, and an error if they don't.

With no profile named, either positionally or with `--profile`, Drove picks one:

1. The profile named `default`, if the Drovefile declares one.
2. Else, the file's only profile, if it declares exactly one.
3. Otherwise, exit 2 and list every declared profile.

Naming a profile the file doesn't declare is also exit 2, with the same list.

### `drove ls`

`drove ls` prints every declared profile with its resolved backend, target name, and whether that target answers a `ping` on its resolved socket:

```
default: backend=herdr target=drove reachable=true
monitoring: backend=herdr target=drove-mon reachable=false
```

`--json` gives the same rows as a JSON array (`profile`, `backend`, `target`, `reachable`).

## Workspaces, panes, and groups

```python
control = workspace("control", panes = [
    herdr.tab("coordinator", split = herdr.DOWN, ratios = [0.5], panes = [
        caller_pane("controller"),
        pane("eventlog", serve = ["eventlog-view.sh", "-f"]),
    ]),
    herdr.tab("monitor", panes = [pane("agentmon", serve = ["htop"])]),
])
```

`workspace(name, panes = [...])` is the core signature; `label`, `cwd`, `env`, and `was` (below) are also core. The `panes` list accepts bare `pane(...)` values and **groups**. A group is a flavor value that carries its own panes plus a placement; the compiler flattens each group into the core pane list and writes the placement onto every pane it contains. A bare pane in the list — one not wrapped in a group — is meant to carry no placement.

**Known limitation (D38).** The compiler currently wraps a bare pane in an implicit single-pane Herdr group instead, so it always carries `Placement::Herdr { tab: <pane name> }` — even under `backend("radiator")`. `drove render` shows the Herdr placement, and reconciliation on Radiator reports the resulting action as `unsupported`. No example or test uses a bare pane today, so declare panes only inside `herdr.tab(...)` groups until this is fixed.

`herdr.tab(name, panes, split = herdr.RIGHT, ratios = [])` is the first group. `herdr.RIGHT` and `herdr.DOWN` are the split constants. A tab lists its panes and a split direction; there is no binary split tree on the surface. A backend without tabs and splits flattens the layout and reports `unsupported` rather than dropping it silently.

## Panes

```python
pane(
    name = "server",
    label = None,           # defaults to `name`
    cwd = "server",
    env = {"RUST_LOG": "info"},
    serve = ["cargo", "run"],
    ready = None,            # output("text"), port(n), or cmd([...])
    after = [],               # panes/tasks that must be ready first
    agent = None,
    on_start = None,
    on_stop = None,
    was = None,
)
```

`serve` is the long-running process; a pane without `serve` is a plain terminal. `serve = any_of([argv1], [argv2])` tries each candidate argv in order and records the first whose executable is on `PATH`.

`caller_pane(name, ...)` declares the pane Drove never creates, moves, or replaces — the invoking terminal. It takes the same fields as `pane` except `adopt`. At most one `caller_pane` per profile.

Readiness gates `after`: `output("watching")` matches pane output, `port(8080)` probes a TCP port, `cmd(["curl", "-f", "..."])` runs a command. The reconciler is planned to re-check readiness on every reconcile, not just at start; this PR only compiles readiness into the IR.

`on_start` and `on_stop` are argv hooks Drove runs once per actual start or stop, in the repository root, with `DROVE_RESOURCE` and (when known) `DROVE_BACKEND_ID` in the environment. `task()` hooks run around `run`: `on_start` fires once `run` has executed, whether or not it succeeded. Pane hooks fire once pane reconciliation against a live backend exists; today `on_start` on a pane is parsed and carried into the IR but not yet run, and `on_stop` on a pane runs only from `drove down`. Every hook argv is approval-gated the same way a task's `run` is (`drove run --yes` / `drove up --yes` / `drove down --yes` to approve on the spot).

## Renaming without loss: `was`

A pane or workspace may declare `was = "old-name"`:

```python
pane(name = "review", was = "shell")
workspace(name = "control", was = "coordination")
```

When the backend holds a live resource whose ownership token equals `old-name` and no live resource is named `new-name`, the plan renames it in place instead of detaching the old resource and creating a new one — the same identity, carried forward, with no `Detach`. After one successful apply the declaration is inert; a `was` that matches nothing live is a `drove lint` warning, not an error.

## Editing a running layout

`drove plan`/`drove status`/`drove up` never report an edit as applied unless a backend verb can actually do it in place. No Herdr verb re-`cd`s a live shell or re-injects its environment, so:

- Changing a pane's `cwd` or `env` closes and re-splits the pane — `[destructive]` — even for a `serve` pane; no `run_command` verb accepts a working directory either.
- Changing a workspace's `cwd` or `env` renames the workspace and cascades the same close-and-split to every pane in it that has no `cwd` of its own (a pane that declares its own `cwd` keeps it and is left alone).
- Changing `on_start` alone needs no backend action; it's recorded and takes effect the next time the pane is created.
- Changing only `label` renames the pane or workspace in place.
- Changing `serve`, `ready`, or `agent` restarts the command in place (`RestartCommand`) rather than recreating the pane.
- Any other field change (`after`, `adopt`, `on_stop`) is a `Conflict`: remove and re-add the pane instead.

Reordering panes within a tab, or changing its `split` direction, while keeping the same panes is also a `Conflict` — there is no verb to re-lay-out a tab in place, and silently reassigning ratios to the new order would apply them to the wrong physical pane. Adding or removing a pane still goes through the existing `RenameTab` + `SetRatio` pair.

A `serve` pane is also checked against what the backend actually reports running, independent of whether anything in the Drovefile changed: if the live command doesn't match the declared `serve` argv (after trimming whitespace and unwrapping a `sh -c "..."`/`bash -c "..."`/`zsh -c "..."` wrapper), or nothing is running at all, `plan`/`status`/`up` reports `RestartCommand` with a reason starting `drifted: `.

## Agents

An agent is a property of its pane:

```python
pane(
    name = "review",
    agent = agent(
        kind = "claude",
        args = ["--model", "sonnet"],
        prompt = "Read .context/handoffs/review.md and do only that.",
    ),
)
```

`prompt` is either an inline string (capped at 2 KB) or `file("repo/relative/path")`, resolved into the prompt text at compile time. The available `kind` and argument values are defined by the installed backend.

## Tasks

Tasks are one-shot, convergent setup actions:

```python
task(
    name = "install-hooks",
    run = ["./scripts/install-hooks"],
    check = ["./scripts/install-hooks", "--check"],
    inputs = ["scripts/install-hooks"],
    after = [],
    auto = True,
    on_start = None,
    on_stop = None,
)
```

If `check` succeeds, `drove up` skips `run` (early cutoff). `auto = True` (the default) runs the task during `drove up` once its `after` set is ready; `auto = False` requires an explicit `drove run <name>`. Task dependencies (`after`) form a directed acyclic graph together with pane `after` references, since both live in the same namespace.

Running `run` is approval-gated on the digest of its argv: the first time it needs to run, `drove up`/`drove run` reports it as blocked until re-run with `--yes` (or the same digest is approved again after the task's declared `run` changes).

## Migration shims

Every v2 form below still compiles for one release. Each one warns with the exact v3 rewrite, and `drove render` prints the file back in v3 form:

| v2 form | v3 rewrite | Warns |
| --- | --- | --- |
| `pane(name, adopt = "caller")` | `caller_pane(name, ...)` | `pane("name", adopt = "caller") is a v2 form; rewrite as caller_pane("name", ...)` |
| `tab(name, ...)` | `herdr.tab(name, ...)` | `tab("name") is a v2 form; rewrite as herdr.tab("name", ...)` |
| `workspace(name, tabs = [...])` | `workspace(name, panes = [herdr.tab(...)])` | `workspace("name", tabs = [...]) is a v2 form; rewrite as workspace("name", panes = [herdr.tab(...)])` |
| `split = "right"` / `"down"` | `split = herdr.RIGHT` / `herdr.DOWN` | `split = "right" is a v2 form; rewrite as split = herdr.RIGHT` (and the `down` equivalent) |

See `docs/upgrading-v3.md` for the full v2 → v3 migration.

## Commands

```sh
drove        [PROFILE] [--profile NAME] [--yes] [--allow-replace] [--workspace NAME] [--no-focus] [--json]
drove status [PROFILE] [--profile NAME] [--json]
drove plan   [PROFILE] [--profile NAME] [--json]
drove up     [PROFILE] [--profile NAME] [--yes] [--allow-replace] [--workspace NAME] [--no-focus] [--json]
drove render [PROFILE] [--profile NAME] [--json]
drove run    [PROFILE] [NAME] [--yes]
drove down   [PROFILE] [--profile NAME] [--purge] [--yes] [--json]
drove lint   [PROFILE] [--profile NAME] [--json]
drove ls     [--json]
```

`drove` with no subcommand is `drove up`; both take the profile positionally (see "The positional profile" above).

`drove render` prints the compiled intermediate representation (IR schema version 3): a flat, deterministically ordered list of typed resources, each carrying a content digest, plus a topology digest per placement group (`was` renames and moving a pane between tabs change the topology digest, never the content one). Given a v2 Drovefile, it also prints every deprecation warning and the file's v3 form. It performs no backend I/O.

`drove lint` warns on a `was` that matches nothing live and on a task with no `check`; it always exits `0`.

`drove status`/`drove plan` never say a resource is in sync when applying the plan would still change it. Beyond the create/detach/topology reasons `drove render`'s digest already implies, the reason text on an action names why that verb, and not a lighter one, had to be used — see "Editing a running layout" above for the full set (destructive `cwd`/`env` recreation, `Conflict` on a reordered or unsupported change, `RestartCommand` on drift).

`drove up` takes you from nothing to a running, focused session in one step:

1. Resolve the backend and target (session or hub).
2. If the target isn't reachable and the backend is Herdr, start that session's server headlessly (`herdr server --session NAME`) and wait for its socket; if it can't come up, exit 1 with the exact `herdr --session NAME` command to run yourself. On Radiator, an unreachable hub just fails with the hub name — there's no headless-start verb.
3. Apply the plan: every workspace, placement, pane and agent action, plus every `auto = True` task in `after` order (`RunTask` actions), behind the same `--yes` gate destructive actions already use. A `Conflict` still exits 2 without applying anything. Ownership of each resource is recorded, and state saved, right after that resource's action succeeds — not after the whole plan finishes — so a failure partway through never loses track of what already landed. A failed action doesn't stop the rest of the plan: independent actions still run, but anything that depends on the failed one (a placement in a workspace whose create failed, a pane in a placement whose create failed, a task whose `after` names one that failed) is skipped and reported as `skipped: ID: depends on PARENT`, without ever running. Each failure prints as `failed: ID: ERROR`. If anything failed, `up` exits 1 after printing these lines and saving state; the next `up` plans only what's still missing.
4. Focus the profile's first declared workspace (or the one named by `--workspace NAME`). If your terminal is outside Herdr (`HERDR_ENV` unset) and stdout is a TTY, `up` then execs `herdr session attach NAME` so you land in the session. `--no-focus` skips both steps; `--json` implies `--no-focus`.
5. Print one summary line: `profile NAME: N created, M changed, K tasks run, in sync`, or `..., partial failure` when anything in step 3 failed, or, when nothing needed applying, `profile NAME: already running, brought to front`. `--json` adds `"failed"` and `"skipped"` arrays to the report, and its `"status"` is `"partial_failure"` rather than `"in_sync"` when anything failed.

A journal entry an apply began but never finished (the process was killed mid-run) shows up as drift: `drove status` lists it as `interrupted ACTION (DIGEST)` (`--json` under `"interrupted"`), and `drove run NAME` prints `previous run of NAME did not finish; rerunning` before it runs that task again.

`drove run NAME` runs one task and its `after` prerequisites, and nothing else declared in the profile; with no `NAME`, it lists every declared task and its last recorded outcome. `drove down` selects the resources local state records as owned by this profile, runs each one's `on_stop` hook, then stops tracking it (`--purge` also passes each resource's stored backend id to the backend's `close_pane`); it never touches a pane local state doesn't record as owned by this profile. Before any of that, it prunes the managed set against a live snapshot of the target the same way `up` does: a resource the session has already lost is detached without ever reaching the backend and printed as `pruned ID (not in session)`, and a `close_pane` failure for one that's still there no longer aborts the rest of the teardown — it's printed as a warning and the resource is detached anyway; when the target can't be reached at all, `down` prints `warning: session not reachable; detaching without closing panes` and detaches everything with no backend calls. On the Herdr backend, once the detach is saved, `down` also stops and deletes the resolved target session, but only when it's a named one — `--session`/`--target`, `DROVE_SESSION`/`DROVE_TARGET`, the profile's own `session = ...`, or `HERDR_SESSION` — and not `default`, which stays untouched as your persistent session. It prints `stopped session NAME` (or `deleted session NAME` if the session was already stopped), and `--json` adds `"pruned"`, `"close_failed"`, and `"session": {"name", "stopped", "deleted"}` to the report.

Local state records more than a backend id string, because an id alone can't be trusted to always name the same resource. Herdr restarts its id counter from `1` every time a session's server starts, so a fresh session and an old, stopped one can easily hand out the same `w1`. Two signals guard against this. First, each profile's state records which backend and session it was last saved against; loading it against a different one (a different `--session`, or the same repo pointed at a different Herdr session) treats the profile as empty for this run, prints `state recorded for OLD; starting fresh for NEW`, and overwrites the stamp on the next save — a legacy state file with no stamp yet is simply stamped, its resources untouched. Second, within one session, Drove also records the workspace/tab label and pane `cwd` it applied each resource with; before trusting a recorded id, it checks that the *live* resource at that id still carries the same label (or `cwd`, for a pane) — a `Drove`-recorded value that no longer matches what's live means the id was handed to a different resource, not that this one was renamed by hand, because the only way the recorded label ever changes is Drove itself writing it at apply time. A resource that fails this check is dropped and reported as `id reused` (in place of `not in session`) exactly like a missing id: it's planned as a fresh create rather than reconciled in place, which is always safe — worst case, a resource a person genuinely renamed outside Drove gets recreated once, rather than another session's resource being silently rewritten. A record saved before this existed (`label`/`cwd` both absent) has nothing to compare against and is left alone. This label check has one exemption: a workspace or tab that is still empty on the live side — its only tab (for a workspace) or its only pane (for a tab) is idle, nothing else — is not dropped even when its label no longer matches. A Herdr session that just restarted resets both its id counter and its resources to a bare root workspace holding one idle default tab, so the first `w1`/`t1` it hands back after a restart carries whatever ids Drove last recorded, under Herdr's own default label, not another session's content; treating that as `id reused` would prune it, plan a brand-new workspace under a fresh id, and leave the empty original behind unrenamed. That bare resource is still exactly what D48/D51's label rename claims, so it's kept and renamed in place instead.

Use `--backend ID`, `--target NAME`, `--file PATH`, `--socket PATH`, or `--session NAME` when discovery defaults are not appropriate.