# Board capabilities a supervisor binds to

[`cli.md`](cli.md) is the complete `taskctl` reference. This page is the **subset a
supervisor uses**, with the exact invocation and the JSON keys it reads. The loop decides
*when*; these are the *how*. The loop never calls the board's HTTP API directly — every
operation below, including the live event stream, is a `taskctl` command.

## The invocation rule

```text
taskctl --json --agent <agent> --actor-kind <kind> [--env-file <p>] [--url <u>] <group> <command> …
```

- `--actor-kind` is required. The loop uses the actor kind (`claude`, `codex`,
  `pi`) for reads and claims, and `system` for the workflow writes so an automated
  acceptance is attributable to the board, not a human. The two are not the same
  question: the kind says *which class of writer*, the id (`--agent`) says *who*.
- The token rides in `TASKD_TOKEN` in the child environment, never on argv.
- stdout is `{ok:true,data}` or `{ok:false,error{code,message,http,details,hint}}`;
  exits are 0 ok, 1 runtime, 2 usage, 3 `export md --check` differences.
- **The event-stream commands are the exception**: `events tail` and
  `events follow` write newline-delimited JSON, not the envelope (see §8).

## 1. The version probe — `--version`

| Invocation | Keys read | Consumer |
|---|---|---|
| `taskctl --version --json` | `data.version`, `data.schema_version` | install and verification scripts; a plain `--version` prints the release alone |

## 2. Project rows — `project list`

| Invocation | Keys read | Consumer |
|---|---|---|
| `taskctl --json … project list` | `data.projects[].id`, `.name`, `.meta.review_gate` | the workflow pass's review-card gate |

A project carries no acceptance default and no reviewer: the card states its own mode,
and the review card's assignee selects the reviewing role. `meta.review_gate` names the
one command the reviewer re-runs; it is copied into the review card's description (or
`n/a — none declared`), never validated or executed by the board.

## 3. Candidate scan and atomic claim

| Invocation | Keys read | Consumer |
|---|---|---|
| `issue candidates --assignee <agent> [--include-unassigned] [--stale]` | `data.candidates[]` | poll's scan |
| `issue claim <identifier>` | only `ok` / `error` | poll's claim |

Candidate tuple: `id`, `identifier`, `title`, `status`, `priority`, `project`,
`sort`, `created_at`, `target`, `meta`, `reason`, `review_of`, `review_round`.
`target` is the card's own `meta.target` (not a project directory); `meta` is
projected whole as the escape hatch for a consumer's own keys, so a poll does not
need an `issue get` per candidate. `review_of` is non-null only for a review
card; a dedicated reviewer is given only those.

`issue claim` is the one claim path: it claims a ready `todo` card and performs
the stale re-claim/takeover that the transition table cannot express. A refused
claim never enqueues a handoff — and the handoff is only produced by the resident
pool, so a claim made without a pool starts no worker.

Claim does **not** re-read the dependency graph by default: the candidate scan
already withholds blocked cards, so a caller that trusts its own scan pays
nothing. `issue claim <ref> --check-blockers` opts into the board's re-read and
refuses with `BLOCKED_BY_PREDECESSOR` (409) when a `blocks` predecessor is not
`done`; the error's `details.blockers[]` names them.

## 3a. The bounded question hold — `issue wait`

| Invocation | Keys read | Consumer |
|---|---|---|
| `issue wait <ref> [--question <id>] [--timeout <ms>] [--interval <ms>] [--heartbeat <sec>]` | `data.outcome`, `status`, `claimed_by`, `question_id`, `answer`, `waited_ms` | a worker that asked the director |

The command reads the card and the trail after the question; it never claims,
moves or comments. It returns `answered` (exit 0) only when the card was
**released** (`todo`, or a status change after the question). A `note` — the
Director's or the worker's own — does not end the hold: the settle is the
release, not the author, so the release is the only trustworthy answer.
`escalated` (the card is `awaiting`), `moved` (left the work states, or another
actor owns the claim), `timeout` and `error` are exit 1/3 results, not failures:
the worker re-invokes the bounded wait once if it can, then parks and the loop
resumes the session. The default is 110 s (under a common 120 s tool timeout;
`--timeout 240` is fine when the tool allows it), max 540 s. While waiting it
pulses the caller's own heartbeat (`--heartbeat`, default 240 s), so a
**bounded** hold does not trip `heartbeat_stale`; a card parked after the hold
does pulse stale, and that is the real liveness signal.
`answered` with `status: "todo"` means the director released the claim — the
worker re-claims before writing.

## 4. Board read — `issue list`

| Invocation | Keys read | Consumer |
|---|---|---|
| `issue list --limit <board-limit>` | `data.tasks[]` | the legacy stale-heartbeat patrol, and `taskctl loop doctor` |

The patrol reads `identifier`, `status`, `heartbeat_at`, `claimed_by_actor`,
`assignee`. Truncation is inferred when `tasks.length >= board-limit`.

The same read takes scope filters without a second request: `--parent <ref>`,
`--kind`, `--label` (repeatable), `--target`, `--meta k=v` (repeatable) and
`--assignee <name>`. They are applied in SQL before `--limit`, so a narrow read
never truncates the card it was looking for. `--meta flow=<x>` is the generic
way to scope on a team's own key.

## 5. SLA rows — `sla list` / `sla get`

The board computes the rows; the loop only delivers them.

| Invocation | Keys read | Consumer |
|---|---|---|
| `sla list [--period <sec>] [--leader <id>] --limit <n> --trail-limit <n>` | `data.config.period_seconds`, `.peer_liveness_k`, `.peer_period_min`; `data.alerts[]`; `data.board_truncated`, `.trails_truncated`, `.warnings[]` | the SLA half of the patrol |
| `sla get <ref> [--period <sec>] [--leader <id>]` | `data.alerts[]` | the pre-emit re-read before a notification |

An alert carries `card_id`, `sla_row`, `severity`, `elapsed_working_min`,
`threshold_min`, `dedup_key`, `action`, `target`, `notify`. `--period` is passed
only when the operator stated it; otherwise the board's configured period wins.

`inspector_liveness` is appended **locally** from the peer marker — the board
cannot see the other agent kind's file.

## 6. Workflow plan — `issue reconcile --workflow`

| Invocation | Keys read | Consumer |
|---|---|---|
| `issue reconcile --workflow [--dry-run] [--assignee <a>] [--review-aging-ms <n>] [--max-actions <n>] --board-limit <n>` | `data.scanned`; `data.workflow.actions[]`, `.alerts[]`, `.changes[]`, `.summary` | the workflow pass |

The plan is snake_case (`workflowToWire`). Action `type`s the loop executes:

| `type` | Meaning |
|---|---|
| `create_review` | create the review card (`original_ref`, `project_id`, `reviewer`, `round`, `idem`, `target`, `title`, `description`, `acceptance[]`). `acceptance[]` is the original's frozen baseline followed by the generic review items (4 for a card, 6 for an epic), and becomes the review card's own frozen baseline |
| `auto_accept` | close a no-review original (`original_ref`, `accepted_by`, `round`) |
| `approve` | apply an APPROVE verdict to an `auto` original and close its review card (`close_review` follows last) |
| `record_verdict` | record an APPROVE verdict on a `human` original (`review_verdict` / `review_verdict_round`) and close its review card, without moving the original; a person closes it later |
| `reject_task` / `reject_epic` / `reject_task_finish` | apply a REJECT verdict; the epic form also creates a fix child. `must_fix[]` is the reviewer's numbered list (or one pointer item when the findings are prose); the loop appends each item to the original's frozen baseline after the rework move, so the next round's report template and the next review card both carry it |
| `close_review` / `cancel_review` | close the review card last |

Alert `type`s include `original_unseen`, `verdict_unreadable`, `verdict_incomplete`,
`verdict_unsupported` (a `met` with no resolvable anchor, or an APPROVE with no `command`
anchor — reason `evidence_missing` / `no_command_evidence`), `acceptance_incomplete`,
`acceptance_unverified`, `review_aging`, `actions_capped`, `dispatch_capped`,
`review_awaiting_human_acceptance` (a finished review waiting on a person to close a `human`
card — log-only), `self_review_not_independent`, `acceptance_blocked` (the board refused an
`approve`/`auto_accept` because the delivery report cannot close the card; the loop reopened it
for a new round and **did** notify, once per round — the executor raises it, not the planner).
Each carries a `dedup_key`. The planner-side acceptance/verdict alerts are log-only: the
planner declines to plan an action the write gate would refuse, rather than producing one
failure per tick. An epic's skip reasons are `rollup_unverified` (no current-round rollup) and
`rollup_stale` (the rollup no longer matches the children); both appear on
`auto_accept_skipped` / `verdict_incomplete`.

An **epic's** only delivery report is its rollup: `report.is_rollup` is `true` on the wire,
its acceptance items carry `ref: <child identifier>`, and `done` / an `APPROVE` refuse a
missing or stale one (`ROLLUP_REQUIRED` / `ROLLUP_STALE`). The loop never writes a non-rollup
report on an epic. An epic is otherwise a card like any other: its default `acceptance_mode`
is `auto`, so a finished container is dispatched a `create_review` in the same tick that
rolls it up; its review reads the children and the rollup, an `APPROVE` yields the ordinary
`approve` action, and a `REJECT` yields `reject_epic`.

## 7. Workflow writes

All writes use `--actor-kind system --agent Orchestrator` and are idempotent by their key.

| Capability | Invocation | Keys read |
|---|---|---|
| create a review card | `issue create --project <p> --kind task --title … --description … --assignee <reviewer> --assignee-kind agent --label review --meta flow=B --review-of <original> --review-round <n> --target <t> --idem <key> --acceptance <text>…` | `data.task.identifier` |
| create a rework child | `issue create --project <p> --kind task --title … --description … [--assignee-id <id>] --acceptance-mode off --meta rework_of=<original> --meta rework_round=<n> --target … --idem …` | `data.task.identifier` |
| list relations | `relation list <ref>` | `data.children[].target` |
| link a parent | `relation add <ref> --type parent --target <child>` | `ok` only |
| merge metadata | `issue meta <ref> --meta k=v …` | `ok` only |
| move | `issue move <ref> <status>` | `ok` only |
| comment | `comment add <ref> --kind note\|change --body <text>` | `ok` only |

Both forms merge into the existing object. `issue meta` is a read-modify-write guarded by
`--if-version` that preserves the existing value types; `issue update --meta` stringifies new
values and sends no CAS, so a concurrent write can be lost. Use `issue meta` for machine keys.
The acceptance baseline is a column, immune to `--meta`, and a **changed** `meta.acceptance` /
`meta.acceptance_legacy` is refused (`VALIDATION_FAILED`) — append with
`issue update --acceptance`, prune only as a human with `--drop-acceptance --reason`.

The review card names the reviewer role its reconcile plan carried — the role catalog's
`default_reviewer` unless the plan says otherwise — so it is a catalog role like any other
assignee and needs no `--force-create`. The rework child is assigned with `--assignee-id`,
which is exempt from the catalog guard.

## 7a. Local role catalog — `role sync`

| Capability | Invocation | Keys read |
|---|---|---|
| publish the discovered roles | `role sync --file -` (payload on stdin) | `data.changed`, `data.roles` |
| read the assignee range | `role list --json` | `data.roles[].agent`, `.id`, `.lanes` |

The payload is the discovery result the poll already holds
(`{schema:"meerkat-role-catalog/1", source, primary_kind, updated_at, roles:[…]}`); `taskctl`
writes `<config root>/roles.json` atomically. This is **local state, never board state**: no
route, no table, no event. Identical content is a no-op (the stored `updated_at` is kept), so
the poll can call it every tick; a failure is logged and never blocks a claim.

## Questions — the `awaiting` protocol

A worker that cannot decide asks the ladder and **holds** with `issue wait`; only
when the ladder escalates to a human does the card go `awaiting` and the worker
park:

```bash
taskctl issue move <ref> awaiting --awaiting-reason E2 --question "what you need to know" <attribution>
```

The move and the `question` comment are one transaction; the loop never writes a decision
kind. The card's `awaiting_reason` stays the category (`E1`–`E5`); the question is the payload
(`waitingPayload`, the newest `question`). Entry, exits and the resume rule:
[`status-lifecycle.md` §1](status-lifecycle.md#1-the-vocabulary).

Who answers what no role may decide: `--opened-by human` means the interactive session that
opened the work may ask its own operator in that conversation; the default (`supervisor`, for
systemd/launchd/cron) leaves the card `awaiting` for the human's comment or sends it to the
director lane.

### The worker's file surface

The loop itself never downloads an attachment. The handoff tells a worker/reviewer to keep
everything it produces — including downloaded attachments — under `.meerkat/<identifier>/`:

```bash
taskctl attachment list <ref> --json                 # id + filename
taskctl attachment download <attachment-id> --out .meerkat/<identifier>/<filename>
```

`download` is the byte half of `add`; it writes to an existing parent directory and returns
`data.{attachment_id,out,size,content_type}`. `card.md` in that directory is the loop's export
and is never edited by the worker.

## 8. Live stream — `events follow` (hub) / `events tail` (direct)

| Invocation | Output | Consumer |
|---|---|---|
| `events follow [--after <rev>] [--idle <ms>]` | NDJSON lines from the local event hub | the event subscriber |
| `events tail [--after <rev>] [--idle <ms>]` | NDJSON lines from a direct board SSE | diagnostics only; not a background consumer |
| `events hub` | the foreground hub process | started on demand by `follow`/`watch` |

The hub is per user and per board, binds loopback, publishes an owner-only pointer, and owns
the single board connection (and its reconnect ladder). `events follow` reconnects to the hub
and replays from its bounded ring when the cursor is still retained; an older cursor starts
live. The loop reads `type` on every line and `event.id` as the board revision, passing the
maximum back as `--after` to resume; `open` means the stream answered. The line shapes, the
exit semantics and the 15 s heartbeat / 45 s idle defaults are in
[`cli.md#events`](cli.md#events).
