# qctl agent instructions
`qctl` maintains one in-repo YAML work queue. The file is the ledger.
Session todos, chat recap, and GitHub issues are not.
## Invocation
```sh
mise run q status
mise run q check
mise run q instructions
```
Never `mise run q --`. The `--` in `#USAGE mount` is mise's
completion bootstrap.
## Commands
| `init` | — | Write a tasks.yaml for a prefix. Does not copy a schema file |
| `status` | — | Print the active task and the priority-ordered queue |
| `check` | — | Validate schema, graph, and git trailers that closed still-queued ids |
| `add` | — | Add a task to the queue, or to the horizon |
| `start` | — | Make one unblocked queued task active (moves it to queue head) |
| `archive` | — | Move a queued task to the archive |
| `park` | — | Demote a queued task onto the horizon |
| `promote` | — | Move a horizon row onto the queue |
| `edit` | — | Rewrite fields or queue position of an existing row |
| `show` | — | Print one queued, archived, or horizon task |
| `fmt` | — | Rewrite a ledger into the style it declares. Upgrades schema 3 to 4 |
| `close-from-git` | — | Archive queued ids that commit-body trailers closed |
| `hook` | — | Install git hooks for this ledger |
| `schema` | — | Write schema/tasks.schema.json from the ledger types |
| `instructions` | — | Print the installed-version operator contract |
Use the consumer's mise-provisioned task when it exists. Call `qctl` directly
only when the repository has no mounted task. Do not invent aliases for
rejected commands.
## Sources of truth
- `tasks.yaml` is the durable queue, archive, and horizon.
- The schema is authored as Rust types (schemars). The generated JSON Schema
is a qctl publish artifact. Consumers set `$schema` to a **pinned** URL
and do not vendor `tasks.schema.json`.
- `qctl check` validates from the schema embedded in the binary, plus graph
rules JSON Schema cannot express, plus git trailers. A body line
`Closes PREFIX-NNN` or `Completes: PREFIX-NNN` that names a still-queued
id is a check failure. A git scan that cannot run is a check failure, not
a skip. `--no-git` is for a scratch ledger that is not in the current
repository. `qctl close-from-git`
archives those ids (evidence is the commit SHA). `qctl hook install`
prints the Lefthook `pre-push` command (`mise run q close-from-git`) when
`lefthook.yml` exists; it does not edit that file, and exits non-zero until
Lefthook already runs `close-from-git`. Otherwise it writes a
git `pre-push` that runs
`qctl close-from-git --pre-push`. Neither amends. `Closes #12` is
GitHub's, not ours.
- `qctl instructions` and `--help` are the installed-version contract.
## Output
Every command returns one typed report. Human output is pretty by default.
`--color never` and `--no-color` render the same document without ANSI.
`--format json` serializes the report directly as one JSON line on stdout;
failures remain machine-readable on stdout and exit non-zero. JSON is never
coloured, whatever `CLICOLOR_FORCE` or `FORCE_COLOR` say, and a parse error
under `--format json` is a JSON error even when the bad argument comes first.
`--quiet` suppresses successful human output only, never JSON or failures. `-f` remains
`--file`; format has no `-f` shorthand. `--color` has no `-c` shorthand;
`fmt -c` is `--check`.
`status --format json` includes the complete ledger state. The JSON from
`check --format json` carries `problems` as an array and exits non-zero when it
is not empty.
## Three lists
| `queue` | Short-term work. File order is priority. Exactly one `active`, or `null`. |
| `archive` | Finished or dropped. Newest `completed` first. IDs never reused. `notes` stay. |
| `horizon` | Mapped but not startable: research, evaluations, deferred. File order is not priority. `active` must never name a horizon id. |
Horizon rows require `kind` (`research` / `evaluation` / `deferred`) and
`open` (the missing start condition or the question). Promote to `queue`
only when `open` is resolved and the row has `acceptance` and `blocked_by`.
IDs are `{prefix}-NNN` (three to six digits), never reused, and never encode
priority. Queue, horizon, and archive partition one task corpus: every id from
`{prefix}-001` through the highest id exists exactly once across those lists. A
move changes status without creating a second copy; `qctl check` reports every
gap and every id claimed by more than one status.
`schema_version` is `4`. `notes` is a list of strings on every row. A
schema 3 ledger still has scalar notes: run `qctl fmt` once to split those
on blank-line paragraphs and set version 4. Other verbs refuse version 3
and name that command. A ledger on an earlier version is not rewritten.
Acceptance is closure truth stated in advance. Each line names an observable,
demonstrably true end condition and reads as the resulting state, for example:
`The generated schema names every row field.` Do not put commands to run,
implementation instructions, or a sequence of steps in acceptance. A `plan`
owns the procedure; `notes` carry context a reader cannot reconstruct.
`notes` is an ordered list. Every repeated `-n/--note` is one item, so separate
facts instead of building one unbounded paragraph. Intentional line breaks stay
line breaks; blank lines stay paragraph separators. Qctl writes those as `|-`
literal block items. YAML-sensitive single-line prose, such as text containing
`: `, uses `>-` so it stays readable without quotes or escape sequences. A
block gets an indentation digit (`|2-`) only when its first line starts with a
space. Short safe notes stay plain. Qctl never inserts mechanical line breaks mid-sentence.
## Style
A ledger declares how it is written, under `style`. Every option is optional
and defaults to what qctl already wrote, so a file with no `style` block is
valid.
| `timezone` | `+00:00` | The offset `completed` is written in. Stamps carry no offset of their own. |
| `section_order` | `[queue, archive, horizon]` | The order the three lists appear in. |
| `indent` | `2` | How far a row sits under its list's key. |
| `archive_order` | `newest_first` | Whether `fmt` sorts the archive or leaves it as written. |
| `row_spacing` | `as_written` | Whether `fmt` keeps exactly one blank line between rows (`blank_line`) or leaves the spacing as written. |
| `normalize_on_write` | `false` | Whether every verb normalizes the whole file, or only the lines it changes. |
`qctl fmt` writes a ledger in its declared style. `qctl fmt --check` writes
nothing, names the lines that differ, and exits non-zero — that is the one
for a hook.
`fmt` does not guess. On a current-version file it changes what an option
names, sorts the archive when asked, and removes whitespace nobody chose. It
never adds a comment or a key, and it never rewrites a value. It adds a blank
line only between rows, when `row_spacing: blank_line` asks for one, and never
after a row whose last value is a `|+` or `>+` block, which would absorb it.
Schema 3 is the exception: scalar `notes` become a list, then the style
rules run.
Changing `timezone` does not move the stamps already written: nothing records
which zone an old stamp was taken in. Change it deliberately.
## Workflow
1. `qctl status` then `qctl check` before mutation.
2. `qctl add` writes a queue row and prints the new id. Repeatable `-n/--note`,
`-b/--blocked-by`, `-l/--plan` and `-L/--link` fill the fields a hand edit
used to. `-A/--after ID` / `-B/--before ID` place it; the tail is the
default. A blocker that would not sit earlier than the new row is
refused. `-H/--horizon -k KIND -O OPEN` writes a horizon row
instead. `--plan` must name a file next to the ledger, the same
rule `check` uses. A tail add to an empty queue is allowed even
when `active` is set; it does not invent a `--before`.
3. `qctl start ID` requires the id to be queued and unblocked; it becomes
`queue[0]` and `active`.
4. `qctl archive ID -e EVIDENCE` moves a queued row to archive.
`qctl close-from-git` archives every queued id a commit-body trailer
closed. Default scan is the same history `check` uses. `-r/--range
main..HEAD` narrows it. `-u/--pre-push` reads the hook stdin. If it
wrote the ledger, it exits non-zero so the file can be committed;
it does not amend.
5. `qctl hook install` prefers Lefthook: it prints `mise run q close-from-git`
for `pre-push.commands` and does not edit `lefthook.yml`. Until that command
is in Lefthook, install exits non-zero. Without Lefthook it writes
`.git/hooks/pre-push`. `-F/--force` overwrites the git hook only.
6. `qctl park ID -k KIND -O OPEN` demotes a queued row onto the horizon,
dropping `acceptance` and `blocked_by`. It refuses if another queued
row still names that id as a blocker. Parking `active` nulls `active`.
`qctl promote ID -a 'The decision record names the selected storage.'`
moves a horizon row onto the queue tail, dropping kind and open. It does
not become active. `--blocked-by` must name a queued id. `add --horizon`
creates an unfleshed horizon row; `park` no longer creates. Both write a
`horizon:` key if the ledger omitted it. `fmt` still never inserts a key.
7. `qctl edit ID` rewrites named fields on a queued, horizon, or archived
row. Scalars replace (`-t/-s/-o`, `-k/-O`, `-P/-l`, `-U/--unset
patch|plan`). Lists append (`-a/-n/-L/-b/-e`). Repeatable
`-x/--remove field:index|exact-text` drops notes, acceptance, links,
blockers, or evidence; the field names are `note`, `acceptance`, `link`,
`blocked-by`, and `evidence`. `-R/--reorder-notes 3,1,2` permutes notes.
`-p/--position front|back|before:ID|after:ID` moves a queued row without
stealing `active`; every dependency must still point backward. Archive
rows accept only note, evidence, and link operations. `id` and `completed`
are never editable. A missing id fails.
YAML: quote list items that start with `#` or contain `: `.
`add` / `start` / `archive` / `park` / `promote` / `edit` change only the
lines they must, so a comment, a blank line, a folded scalar and an inline
list all survive a verb. Do not splice a new `- id:` into the file: `add`
creates rows; `add --horizon` creates unfleshed horizon rows; `park`
demotes. Prefer the verb over a hand edit: `archive` also takes the
archived id out of every `blocked_by` that named it, which a hand edit
forgets. `edit` is the verb for notes, acceptance, and the other fields
on an existing row.
## Stop conditions
Stop and ask when:
- a row has no clear short-term start condition — put it on `horizon`;
- `active` would name a horizon id;
- a blocker is not an earlier queued id;
- installed help differs from remembered syntax.