# The JSON API (/docs/scruff/json)



```sh
scruff --json          # a snapshot. Same bytes as `scruff list --json`
scruff watch --json    # the changes, one NDJSON object per line
scruff overlap --json  # what the other lanes changed, as one document
```

## The envelope [#the-envelope]

```json
{ "scruff": "…", "schema": 2, "warnings": [], "lanes": [ … ] }
```

`scruff` is the version, `schema` the payload version. Check `schema` rather
than sniffing for fields.

Each lane:

| Field                | Meaning                                                                                                                                                                                                  |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`               | the lane's name                                                                                                                                                                                          |
| `repo` / `main`      | the repo's remote slug, and the main checkout's path                                                                                                                                                     |
| `branch`             | the full branch name                                                                                                                                                                                     |
| `path`               | the checkout path on disk. **Empty once parked**                                                                                                                                                         |
| `parent`             | the checkout of the pane that spawned it: its own main checkout for an ordinary lane, another **lane's** checkout for one made by `scruff child` or opened from inside a lane's pane. `""` if unrecorded |
| `chat`               | the checkout whose conversation `scruff <name>` opens. Equal to `path` when the lane holds a chat of its own, the **parent's** path when it doesn't, `""` when scruff could not tell                     |
| `agent`              | the client: `claude`, `codex`, `opencode`, `pi`                                                                                                                                                          |
| `state`              | `live`, `parked` or `stray`. A closed set                                                                                                                                                                |
| `occupied` / `dirty` | nullable. **`null` means undetermined, not false**                                                                                                                                                       |
| `occupied_by`        | the evidence behind `occupied: true`: `[{pid, command, path, via}]`, `via` being `lsof` or `leases`. Absent entirely when nothing holds the lane                                                         |
| `landed`             | `{verdict, via, confidence}`. See [the four verdicts](/docs/scruff/landing#the-four-verdicts)                                                                                                            |
| `post_merge_ahead`   | `{commits, pr, diverged}`: work done after the PR merged                                                                                                                                                 |
| `last_commit`        | the most recent commit's subject                                                                                                                                                                         |

## The six traps [#the-six-traps]

<Callout type="warn" title="null is not false">
  `occupied` and `dirty` are **three-state**. `null` means scruff could not
  determine it: no `lsof`, no forge, a cache miss. Reading it as falsy is how you
  tell somebody a lane is clean when scruff had no idea, and it is the single most
  common way to get this payload wrong.
</Callout>

* **`state` and `landed.verdict` are closed sets.** Additions are a minor
  version, removals a major one, and an unknown value must resolve to the safe
  direction: an unrecognised `verdict` or `via` is **not landed**.
* **`landed.verdict: "fresh"` is not `yes`.** It means the branch has never
  carried a commit of its own. Render it as *nothing yet*, never as *merged*.
* **`warnings` is the only place a degraded run explains itself.** Under
  `--json` the human-readable notes are suppressed, so a consumer that ignores
  `warnings` will silently report a partial sweep as a complete one.
* **`occupied` says a process is standing there, not that a pane is.** A dev
  server or an orphaned daemon holds a lane exactly as hard as a live agent.
  Read `occupied_by` before telling somebody to go and close a window that may
  not exist.
* **`chat`, not `parent`, says whether a lane has a pane of its own.** A lane
  made by `scruff child` and a lane opened from *inside* another lane's pane
  record the same `parent`, and only the second has a window and a conversation,
  so a picker that hides lanes on `parent` hides a running agent. `chat` is
  measured: equal to `path` when the lane holds its own chat, the parent's path
  when it doesn't. `""` means scruff could not tell (a client whose transcripts
  it cannot cheaply probe) and must be read as **show it**, the same safe
  direction `null` occupancy takes.
* **`post_merge_ahead.pr` is a plain int**, and absent is `0`, never `null`.
  `diverged` is what separates "committed after the merge" (reshippable, `+N`)
  from "this tip never built on what merged" (stale or sideways, `~N`).

<Callout type="warn" title="A listing is not read-only">
  `scruff` and `scruff --json` also sweep landed parked branches on the way past.
  Harmless, and the invariants still hold, but don't poll it thinking you are only
  looking.
</Callout>

## The stream [#the-stream]

```sh
scruff watch --json
```

One JSON object per line. A `hello` header first, then one `sync` per lane
already alive, then `ready`, then the changes as they happen:

```json
{"kind":"hello","seq":0,"scruff":"…","schema":2,"capabilities":["registry"]}
{"kind":"sync","seq":1,"ts":"…","source":"registry","lane":{…}}
{"kind":"ready","seq":2}
{"kind":"created","seq":3,"ts":"…","source":"registry","lane":{…}}
```

`kind` is `sync`, `ready`, `created`, `parked`, `resumed`, `reaped`, `changed`
or `warning`, and it is a closed set on the same terms as the others: &#x2A;*treat an
unknown kind as noise, not as an error.** `seq` is monotonic across the whole
stream, `hello` included, so a consumer fanning this out over its own transport
can detect a dropped line without scruff knowing anything about that transport.
`lane` carries the exact same shape `--json` uses, so one schema serves the
snapshot and the stream alike.

### Landedness is not on the stream [#landedness-is-not-on-the-stream]

`landed` and `post_merge_ahead` change at the forge, and nothing local fires
when a PR merges, so they are deliberately not events: a consumer that wants
them &#x2A;*polls `--json`** at a cadence it can afford. Everything else is a
registry mutation and arrives at once (`parked` has a local rescan timer behind
it, because an unlanded pane closing never touches the registry).

If that ever changes you will not have to guess: every event carries `source`
(`"registry"` today, `"forge"` reserved) and `hello` carries `capabilities`, so
you can ask the stream what it can ever send.

## The overlap report [#the-overlap-report]

```sh
scruff overlap --json                   # from a lane: your files against every other lane's
scruff overlap --json --path doc.md     # one file
scruff overlap --pair a b --json        # two lanes, from anywhere
```

```json
{
  "scruff": "…", "schema": 2,
  "repo": "hausfold/haus", "main": "/Users/j/code/workshop/haus",
  "mode": "lane",
  "reader": { "name": "cuddly-sparrow", "branch": "worktree-cuddly-sparrow", "path": "…", "state": "live", "intent": "AGENTS.md: the overlap row" },
  "lanes": [ { "name": "tidy-raccoon", "branch": "…", "path": "…", "state": "live", "intent": "bar: the draft PR colour" } ],
  "findings": [ { "a": "cuddly-sparrow", "b": "tidy-raccoon", "path": "AGENTS.md", "level": "hunk", "from": 212, "to": 240, "whole_file": false } ],
  "merge_tree": [ { "a": "cuddly-sparrow", "b": "origin/main", "paths": ["flake.lock"] } ],
  "order": [ { "a": "cuddly-sparrow", "b": "tidy-raccoon", "first": "tidy-raccoon", "reason": "already pushed" } ],
  "exit": 4,
  "warnings": []
}
```

| Field                    | Meaning                                                                                                                                                                                                                                                           |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode`                   | `lane` from inside a lane, `matrix` from the main checkout or with `--pair`                                                                                                                                                                                       |
| `reader`                 | the lane asking. `null` in `matrix` mode                                                                                                                                                                                                                          |
| `lanes`                  | the lanes measured, the reader itself excluded. A lane with nothing left to land is **not here**                                                                                                                                                                  |
| `intent`                 | that lane's last commit subject, `""` while it has no commits of its own                                                                                                                                                                                          |
| `findings[].level`       | `hunk`: the same region · `file`: the same file only                                                                                                                                                                                                              |
| `findings[].from` / `to` | the shared region in the merge base's line numbers. `null` for `file`, and for a `whole_file` add/add or delete/edit                                                                                                                                              |
| `merge_tree`             | what a real three-way merge of the two committed tips leaves conflicted. `b` is a lane, or the ref main was read at                                                                                                                                               |
| `order`                  | in `lane` mode, the proposed landing order for each lane in your way, and why. `reason` is prose for a PR body, not an enum: `already pushed`, `bigger: N files vs M`, or the alphabetical tiebreak spelled out. Always `[]` in `matrix` mode and under `--brief` |
| `exit`                   | the code the process ends on: `0`, `3` or `4`. A finding, never an error                                                                                                                                                                                          |

The same rules as the listing: closed sets stay closed, unknown keys are yours
to ignore, and `warnings` is where a degraded run explains itself.

## What is frozen [#what-is-frozen]

Four things are versioned and breaking-change-gated: the **registry schema*&#x2A;,
this **`--json` output**, the **hook protocol**, and the **exit codes**. So are
the user-facing command names and flags. Changing one is a semver major
conversation, not a refactor.

Field **additions** are non-breaking, which means the other half of the deal is
yours: &#x2A;*ignore unknown keys.**

## The registry is local, and stays local [#the-registry-is-local-and-stays-local]

Two things follow, and both bite a consumer rather than scruff. The rows hold
**absolute paths on one machine**, so a lane is not meaningful on a different
box. And mutation takes an exclusive lock, so two writers meet **exit 5** rather
than losing an update.

Pointing `SCRUFF_STATE` at a network share does not make scruff distributed, it
makes the lock a lie.
