Metadata-Version: 2.4
Name: maity
Version: 0.1.0a2
Summary: Agent-side command for Maity: join a project, resume, wait for events, report, ask a human.
License-Expression: MIT
Project-URL: Homepage, https://github.com/oliver-zehentleitner/maity
Project-URL: Documentation, https://github.com/oliver-zehentleitner/maity/blob/main/cli/README.md
Project-URL: Issues, https://github.com/oliver-zehentleitner/maity/issues
Project-URL: Changelog, https://github.com/oliver-zehentleitner/maity/blob/main/CHANGELOG.md
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python :: 3
Classifier: Environment :: Console
Classifier: Operating System :: POSIX
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Provides-Extra: dev
Requires-Dist: ruff; extra == "dev"
Requires-Dist: black; extra == "dev"
Requires-Dist: mypy; extra == "dev"
Requires-Dist: pytest; extra == "dev"

# maity — the agent-side command

`maity` is how a coding agent talks to a Maity hub: join a project, resume
its identity in a new session, wait for events, claim and checkpoint cards,
report finished work with facts read from git, put a question into a
human's inbox, answer requests, message other members, follow the release,
and reach every other endpoint with a signed request.

It is optional. An agent with nothing installed can join through the join
page the hub serves ([`docs/join-page.md`](https://github.com/oliver-zehentleitner/maity/blob/main/docs/join-page.md)). The CLI
is the shorter path and the only one that signs requests, so the secret
never leaves the machine. Standard library only, Python 3.11 or newer, no
third-party dependency. The calls behind every command are in
[`docs/api.md`](https://github.com/oliver-zehentleitner/maity/blob/main/docs/api.md) §13.

## Install

```sh
pipx install maity
maity --version
```

`pip install --user maity` works too. To run the repository's current
main instead of the latest release:
`pipx install "git+https://github.com/oliver-zehentleitner/maity#subdirectory=cli"`.

## The first ten minutes

Run everything from the clone the agent works in. The state belongs to
that clone, not to the home directory, so several agent profiles can share
one machine and one home.

```sh
cd ~/projects/my-clone
maity join https://<hub>/j/<token> --code <code> --harness "Claude Code" --model <model id>
maity resume                                   # at the start of every session
maity wait                                     # exit 0 + event JSON, 3 timeout, 6 superseded
maity claim tsk-0004
maity checkpoint tsk-0004 -m "lease column added" --next "renew it on every call"
maity ask "Tag 0.2.0 now?" --to human --class consent --option yes --option no
maity say trust:admin "The exchange needs a lease first" --type blocker --task tsk-0004
maity report --task tsk-0004 --summary "Adds the lease to claims"
maity help                                     # the hub's /api/v1/help
```

The join URL has one of two shapes, and the CLI derives the exchange from
it: `https://<hub>/join/<token>` (or the older `/j/<token>`) posts to
`/api/v1/join/<token>` on the hub,
and a join-host URL with a single path segment
(`https://join.<domain>/<token>`) posts to that same URL. After the
exchange, the hub URL the answer names (`Maity-Hub`) is kept only if it
is HTTPS (plain HTTP only on loopback) and on the join URL's host or its
parent domain; otherwise `join` exits 1 and keeps nothing, and the human
creates a new join line. `maity runner pair` checks the same way.

Join's first resume and every text `maity resume` show the project's
expected repository URL when configured. Join compares the clone's
`upstream` remote (or `origin` when there is no upstream) with it, treating
HTTPS, SSH, scp-style SSH and a trailing `.git` as the same address. A
mismatch warns on stderr and keeps the join; a fork's origin is fine when
upstream names the project. `--no-resume` skips this first-session check.
If an admin has no briefing or assigned/held cards, resume checks one card
from the board: an empty board prompts the admin to create cards or ask
the human, instead of waiting for work to appear. In an accepted or archived
project it asks the human about reopening first. A failed board read is
not treated as an empty board. `--json` stays the hub's unmodified packet.

A run against the test hub of this package (`tests/fakehub.py`), with a
resume packet shaped like a real hub's:

```text
$ maity join http://127.0.0.1:33187/j/9fK2tok --code Q7MD-3XPL-8ZWE --harness "Claude Code" --model claude-opus-5-5
joined maity as agt-b; credential crd-b-0003 stored in /home/me/projects/my-clone/.git/maity/credential (mode 0600, not printed)
So that a new session resumes on its own, add this to AGENTS.md (or join with --pointer to have it written):
<!-- maity:begin -->
…
<!-- maity:end -->
Your briefing, as the humans of this project wrote it for you:
> You own the hub's credential exchange. Start with tsk-0004;
> ask me before you change the database schema.

You are agent-b (Agent B), member id agt-b.
Role: member, reporting to agt-a
Areas you own: hub/
Your human: maintainer
Project: Maity (maity), state build

Rules (version 7), read them before you work:
> # Rules for Maity
> …

Keep the Why: Load the keep-the-why skill and read context/index.md before working.

Assigned to you, not claimed yet:
  tsk-0004 [open] Hub: credential exchange

Members: agent-a (admin, online), maintainer (owner, away)

Next: `maity claim tsk-0004`, the card assigned to you.
How to work as a member, and the rules that matter: https://github.com/oliver-zehentleitner/maity/blob/main/docs/agents.md
Run `maity resume` at the start of every session; `maity --help` lists the rest.
$ maity ask "Tag 0.2.0 now?" --to human --class consent --option yes --option no
asked req-0012 (consent, open)
$ maity wait --timeout 5
{
  "seq": 4232,
  "kind": "request_answered",
  …
}
$ maity wait --timeout 1; echo $?
3
```

`join` exchanges the code, stores the credential without printing it,
prints a short pointer block for `AGENTS.md` (`--pointer` writes it there,
and into `CLAUDE.md` where one exists that does not import `AGENTS.md`),
then opens the first session and prints the onboarding. It does not write
into the repository unasked: `AGENTS.md` is usually tracked, and a block
written by the join would end up in the next commit. `--harness`,
`--harness-version` and `--model` are remembered for later `resume` calls.
An agent that joined through the join page with `curl` runs
`maity resume --hub <url>` once. `--join-file FILE` (or `-` for stdin)
reads the URL and the code as two lines, so neither is on a command
line; `--no-resume` stores the credential and opens no session; and
`--client NAME` says what made the join (the runner sends
`maity-runner/<version>`).

`join` and `resume` print the resume packet as text, in this order:

1. its memory: the soul, then team and community entries, each quoted
   with who wrote it (see [Memory](#memory))
2. a pause, if a human set one
3. the check-in target and instruction, when the hub supplies them, then
   the personal briefing or the project default (marked with its author)
4. who it is, the project and its state
5. the rules (in full when they changed since the last session)
6. the cards it holds and those assigned to it
7. answered and open requests, and the events it missed
8. the members
9. the next step

The check-in block is display only: send the message yourself. The CLI
never sends one automatically. Older hubs without the block keep the
existing output; `--json` still prints the unmodified packet.

Almost every string in the packet was written by someone, and none of it
may pass for the command's own output:

- terminal escapes, other control characters and bidi overrides are shown
  as visible escapes (`\x1b`, `\u202e`)
- text that can span lines (memory, check-in instructions, the briefing,
  messages, answers, notes) is printed behind `> `, every line
- every other field is kept on one line, its line breaks shown as `\n`

A message longer than 40 lines or 4000 characters, and an events section
beyond 64 KiB, are cut with a pointer to the full text. `--json` prints
the packet itself, complete.

## Credentials and state

```text
<git common dir>/maity/            mode 0700  (.git/maity/ in a normal clone)
    credential                     mode 0600  the session credential, nothing else
    state.json                     mode 0600  hub, project, identity, session, cursor
    env-secret                     mode 0600  this workspace's environment secret
~/.local/state/maity/env-secret    mode 0600  this home's environment secret
```

- One state per clone, shared by that clone's worktrees. Outside a clone,
  `maity join` uses `~/.local/state/maity/<hub host>/<handle>` (handle
  from the join page; `$XDG_STATE_HOME` if set) and prints the
  `export MAITY_STATE_DIR=…` line every later command needs. Every other
  command outside a clone refuses unless `MAITY_STATE_DIR` names a
  directory.
- The credential sits inside the Git directory, so Git never tracks it
  and `git clean -xdf` does not delete it. It is stored byte for byte,
  with no newline: the same file the page-driven join writes with
  `curl -o`.
- No command prints the credential or anything else from its file. Every
  command that reads it refuses a credential file, or a state directory,
  that group or others can reach, like a loose ssh key. Only `maity join`
  changes a mode: an empty directory of yours, with no credential yet, is
  set to 0700, with one line saying so. The directory is checked before the
  join's single-use code is spent; a credential the directory still refuses
  afterwards goes to a new private directory, and the CLI says which.
- A request whose body or query contains anything shaped like a
  credential (`mty1.<id>.<secret>`) is refused before it is sent. The hub
  would revoke a real one.
- `resume` declares where the session runs as two ids
  ([`docs/api.md`](https://github.com/oliver-zehentleitner/maity/blob/main/docs/api.md) §5.2): `h-`/`w-` plus 12 hex
  characters of an HMAC of the hub's URL, keyed with a random secret of
  this home and of this state directory. No path leaves the machine, the
  same place gives unrelated ids on two hubs, and the ids are never
  printed. The hub only compares them, to tell the humans when two
  members share a home or a workspace. A secret that cannot be read, or
  one others can reach, leaves the ids out with a note; a hub that does
  not take them yet is resumed without them.
- A session a runner started has `MAITY_SUPERVISOR=maity-runner/<version>`
  in its environment, and `resume` sends it as `supervisor`
  ([`docs/api.md`](https://github.com/oliver-zehentleitner/maity/blob/main/docs/api.md) §15.11). The hub then answers a
  supervised session: no `maity wait`, end the turn after a checkpoint,
  and wake-ups only for what concerns the agent. Any other value is a
  usage error. A hub that does not take the field yet is resumed without
  it, with a warning.
- The join code in the pasted line is single-use and short-lived. The
  session credential is long-lived, bound to this agent profile and
  project, and revoked in the dashboard.

Why it is laid out this way, and what was rejected:
[`context/credentials.md`](https://github.com/oliver-zehentleitner/maity/blob/main/cli/context/credentials.md).

## Signing

Every request is signed as `docs/api.md` §2.2 specifies. The headers are
`Authorization: Maity-HMAC <credential id>`, `Maity-Timestamp`,
`Maity-Nonce` and `Maity-Signature`. The signature is an HMAC-SHA256 over
the canonical request, keyed with the SHA-256 of the secret. Every `POST`
and `PATCH`, except the resume, carries an `Idempotency-Key`. A
`timestamp_skew` answer is corrected once from the hub's time. A redirect
is refused instead of followed, so the signed headers never reach another
host. Plain HTTP is refused except to a hub on loopback. That holds even
when the hub runs with `MAITY_ALLOW_PLAIN_HTTP`, and the CLI has no flag
to allow it.

git and gh run with prompts switched off (`GIT_TERMINAL_PROMPT=0`,
`GCM_INTERACTIVE=never`, `GH_PROMPT_DISABLED=1`), stdin closed and a
30-second timeout. A remote that asks for a password is an error, never a
hanging command.

## `report`

`report` states facts that nobody typed:

- the head commit, from git
- the remote head, from `git ls-remote` on the branch's upstream
- the pull request, from `gh`

It refuses with exit 4, before anything is sent, on any of these:

- uncommitted changes to tracked files
- a detached HEAD
- a branch without upstream
- a branch the remote no longer has, unless gh finds its pull request
  merged with the local head (deleted after the merge): then that head
  is reported as the remote head
- a remote head that differs from the local one
- a pull request whose head differs from the local one

`pr_status` is `found`, `none`, or `unknown`. `unknown` comes with a reason:
gh is not installed, or the remote is not on a GitHub host gh knows. A gh
that is installed but fails is an error, not "no pull request". There is
no override: [`context/report.md`](https://github.com/oliver-zehentleitner/maity/blob/main/cli/context/report.md).

A card whose result is not code (a review, an investigation, a draft) is
reported with `--no-code`. The summary carries the result, and no git
fact is read or sent, so nothing above is checked or refused:

```sh
maity report --task tsk-0007 --summary "Reviewed #12: two findings, see the PR." --no-code
```

`checkpoint` takes `--no-code` on such a card as well, and then sends no
branch or head.

In a project that requires Keep the Why (`docs/api.md` §6.23), a code
report names the entry that keeps its reasoning, or says why there is
none:

```sh
maity report --task tsk-0004 --summary "Adds the lease to claims" --why hub/context/claims.md#4351c5fc-…
maity report --task tsk-0009 --summary "Fixes a typo in the join page" --no-why "a correction, no decision"
```

`--why` takes `<path>#<id>`: the `context/` file relative to the
repository root, ending in `.md`, and the entry's `Id`. `--no-why` takes
a reason of at least 10 characters. Whether the project requires it comes
from the last `maity resume`. When it does, a code report with neither is
refused before anything is read or sent (exit 4). `--no-code` takes
neither.

While the project's setup cards (§6.21) are not done, the resume lists
them in a `Setup waits` block after `Check in`, one line per card with
its id and status, and who holds it when it is claimed. It is a hint:
nothing is refused while it is shown.

The resume shows a `Keep the Why` block there: either the time this
instance confirmed, or the request to confirm with
`maity ack keep-the-why` by the deadline, 30 minutes after the
instance's first resume. At the deadline the hub sends one reminder,
and 30 minutes after that, still without a confirmation, it tells the
owner. `maity ack keep-the-why` sends `POST /me/keep-the-why/ack`. It
prints when the confirmation was recorded, and a second call answers
the first record. In a project that does not require it, it says so and
exits 0. The CLI never confirms on its own.

## Claims, messages, answers, releases

Each of these makes one signed call. Writes carry an `Idempotency-Key`.
Every command prints one status line, or the hub's answer as JSON with
`--json`. A refusal by the hub is exit 1 with the hub's error code in the
message. A repeated `claim` of a card the agent already holds (say, after a
dropped connection) succeeds and says so.

| Command | Call (`docs/api.md`) |
|---|---|
| `maity claim <card> [--reason R]` | `POST …/tasks/{card}/claim` (§6.5); `--reason` in `freeze` and `fixes-only` |
| `maity unclaim <card>` | `POST …/tasks/{card}/release` (§6.5): the card is open again |
| `maity checkpoint <card> -m LAST --next NEXT` | `POST …/tasks/{card}/checkpoint` (§6.4), with `branch` and `head` from git; `--no-code` sends neither |
| `maity say <to> "…" --type T [--task ID]` | `POST …/events` (§8.4); `<to>` is `all`, `role:<role>`, `trust:owner`, `trust:admin`, a member id or a handle, current or former, or `<handle>#<instance>` (resolved to the id, see below); `T` is `interface_change`, `blocker`, `go_request`, `review_result`, `handover` or `notice` |
| text from stdin or a file | the text argument `-` reads standard input: `say <to> -`, `say --chat C -`, `ask -`, `report --summary -`, `checkpoint -m -` or `--next -` (one per command). `--body-file FILE` for `say` and `ask`, `--summary-file` for `report`, `--last-file` and `--next-file` for `checkpoint`. Use it for Markdown with `code` spans or `$(…)`, with a quoted heredoc (`- <<'EOF'`): inline in double quotes, the shell runs them first. Inline text with a gap where a word was (two spaces, a space before punctuation) or an odd number of backticks gets a warning; it is still sent |
| `maity say human "…"` | `POST …/conversations/{own member id}/messages` (§9): the chat with the human who owns the agent |
| `maity say --chat <conversation> "…"` | `POST …/conversations/{id}/messages` (§9): a group chat, or the agent's own member id (the same as `say human`); no `--type`, no `--task` |
| `maity say … --attach FILE` | each file first `POST …/attachments` (§8.7), at most 10; then the message names them. Any form of `say`. See [Chats and files](#chats-and-files) |
| `@handle` in a message, note or report | a mention (§8.6). When two members share a short handle, the hub refuses it (`ambiguous_mention`); the CLI prints the global names it could mean, so you write `@owner/handle`. Where the hub sends a member's global name (the member list behind `maity messages`, placements, notes, check-in), a shared short handle is shown as `owner/handle` (§6.3); your own is built from the packet. Peers in the resume packet and chat participants show a namesake's id until the hub sends `name` there; a human keeps its handle when only agents share it |
| `maity answer <request> [--reply ID] [--text T] [--on-behalf-of MEMBER]` | `POST …/requests/{id}/answer` (§7); `--on-behalf-of` (id or handle) is a proxy relaying its owner's answer, sent as `on_behalf_of` (§2.3) |
| `maity ask "…" --to owner --class decision\|consent --action "METHOD PATH[?QUERY]" [--action-body FILE\|-]` | `POST …/requests` with `action: {method, path, query, body}` (§2.3, §7): a proxy asking its owner's go for one call. The query is put in the order `maity api --query` sends it, the body is the file's text as it is; then `maity api METHOD PATH --json FILE --on-request <request>` |
| a handle as an argument | `say <to>`, `ask --to`, `takeover --to`, `answer --on-behalf-of` and the placement commands read `GET …/members` and send the member id (§6.24): a current handle, a former one (`aliases`), or a global name `owner/handle` with either part former. Several candidates are refused with their names, unless exactly one is a human whose current handle it is. A name nobody has goes to the hub as given, except for `--on-behalf-of`, which refuses it. The placement commands resolve the name against the member list before they look at the placements, so two members sharing a name are refused and never settled by which one happens to be placed; only a name no member has is matched against the placements' own handles. `maity resume` lists a member's former handles after its presence |
| `maity release list` | `GET …/tasks?kind=release&status=all`, every page |
| `maity release show [card]` | `GET …/tasks/{card}`; without a card, the release in flight |
| `maity release tick <item> [--release card] [--undo]` | `POST …/tasks/{card}/checklist/{item}` (§6.7) |

`release` is the releases group, so giving a claim back is `unclaim`.

**When a human pauses the agent**, `claim` and new cards are refused
with `paused` (exit 1). The line names who paused it, since when and
why, and lists what still works. `maity wait` returns the
`session_paused` event about the agent itself as JSON on stdout, as
usual, and adds a plain line on stderr: "you were paused by … : <reason>"
or "your pause was lifted". A pause of another member gets no line.
Why these names: [`context/commands.md`](https://github.com/oliver-zehentleitner/maity/blob/main/cli/context/commands.md).

A run against the test hub of this package (`tests/fakehub.py`):

```text
$ maity claim tsk-0004
claimed tsk-0004; lease until 2026-10-22T19:00:00Z
$ maity checkpoint tsk-0004 -m "lease column added" --next "renew it on every call"
checkpoint on tsk-0004 at e9a39c60a63a on agent-b/lease
$ maity say trust:admin "The exchange needs a lease first" --type blocker --task tsk-0004
sent blocker to trust:admin (seq 4302)
$ maity release show
tsk-0010  0.1.0  in_progress  ready: no
  blocking: pr_open pr=3
  blocking: checklist_open item=chk-2
  [x] chk-1  CHANGELOG has a section for this version
  [ ] chk-2  live version equals the tag
$ maity release tick chk-2
ticked chk-2 on tsk-0010
```

## Availability

```sh
maity limit --until 2h --reason "provider quota"   # or 1h30m, 2d12h, 2026-10-22T17:00:00Z
```

Run `maity limit` before the session stops for a provider limit: the hub
then knows you are out until a time and acts on your availability policy
(wait, or a substitute covers your work, [`docs/api.md`](https://github.com/oliver-zehentleitner/maity/blob/main/docs/api.md)
§6.12). The next `maity resume` clears it. `--until` is a duration from
now (`<n><unit>` groups in the order `d`, `h`, `m`, `s`, each once) or an
RFC 3339 time with an offset; a time without one is refused rather than
read as local time, and nothing more than 7 days ahead is sent. The CLI
sends UTC and prints the `until` the hub stored.

`maity resume` prints an `Availability` block after any pause notice
when you cover someone (with the ids of the cards and requests handed to
you) or a peer is not available, and marks a handed-over card or request
with `(cover for @<handle>)`.

## Placements and runners

An agent whose trust or role holds `agents:propose` can start other members on a
runner, and stop or restart them (`docs/api.md` §15.6):

```sh
maity runners                                   # attached runners: presence, load, tools
maity placement list
maity placement start agent-f --runner rnr-0002 --tool codex --mode supervised \
  --reason "a second pair of hands on web/"     # under consent: prints the request id
maity placement stop agent-f --grace 60
maity placement restart agent-f
```

An agent is named by its handle or member id, and a placement by its id.
An exact id wins. A name that matches more than one member or placement is
refused rather than guessed, because handles are unique only per owner.
New profiles are created by a human in the dashboard, never from the
CLI.

## Profile

```sh
maity profile avatar ./me.png    # PNG, JPEG or WebP, at most 1 MiB
maity profile avatar --remove
```

The avatar belongs to your agent profile and shows in every project it is
a member of ([`docs/api.md`](https://github.com/oliver-zehentleitner/maity/blob/main/docs/api.md) §6.10). The file goes to
`PUT /me/avatar` as it is, signed, with the type read from its first
bytes; a name ending in `.png` does not make a file one. Anything else,
or a file over 1 MiB, is refused here with exit 4 before any call. The
hub re-encodes the image as a square PNG of at most 256 pixels without
metadata and answers with the new `avatar_url`, which is printed
(`--json` prints the whole answer).

## The change ticker

```sh
maity ticker                                  # one page, newest first
maity ticker --mine --type merge --since 1d   # for you, merges, the last day
maity ticker follow --area web/               # or mute, unfollow, unmute; --type too
maity ticker post "rc.2 on staging" --type note --area hub/   # owner and admin
```

The ticker announces merges, decisions, deploys and releases, one line
each ([`docs/api.md`](https://github.com/oliver-zehentleitner/maity/blob/main/docs/api.md) §17): `[ticker] <type>: <text>
<url>`, and for an item that is for you `  for you: <why>`, `(muted)`
when you muted it. `maity ticker` prints one page; when there are more,
a last `more:` line is the command for the next one (with `--since`
fixed to the time it meant), never followed on its own. `maity resume`
prints a `Ticker` section with the items since your last session and
leaves them out of the missed events. `maity wait` stays JSON: a ticker
item is one more `kind` there.

`ticker post` maps each flag to its field; `--to` and `--area` repeat,
and `--to computed` or `--to all` stands alone. Merges and decisions are
announced by the hub itself. On a hub that has no ticker yet (it does
not list the path in its `openapi.json`), `post` prints the `maity say`
line to send instead and exits 2. `follow`, `mute`, `unfollow` and
`unmute` change your own subscription with one call each; a value you
both follow and mute is listed for you and never wakes you.

## Cards and subtasks

```sh
maity task list                          # the board in text: the open columns, children under their parent
maity task list --status all             # every card, the closed ones included
maity task list --parent tsk-0004        # one card's open children (--status all for every one)
maity task show tsk-0004                 # the card, its parent, children and related cards
maity task create "Split the exchange" --parent tsk-0004 --area hub/
maity task edit tsk-0012 --parent tsk-0004 --related tsk-0009
maity task edit tsk-0012 --no-parent --unrelated tsk-0009
```

A subtask is a card with a `parent` (`docs/api.md` §6.4). The tree is two
levels deep, and a parent is done only when its children are done or
cancelled. Under `policy.subtasks: proposed`, the default, a subtask that
someone without `tasks:admin` creates starts as `proposed` until an admin
opens it, and such a caller attaches only a card that is still `proposed`. `related` links cards for navigation and gates
nothing. `--assign` on create needs `tasks:admin`: a member creates the
card, then claims it. The hub's refusals are printed as they came,
including `details.reason` and the scope a `forbidden` names, with one
hint line. `task show` prints the body, dependencies and assignee too;
`--json` on `show` and `list` is the hub's cards as they came, with
non-ASCII escaped.

## Notes and links

```sh
maity note add tsk-0004 "The CLI needs --no-code here too."
maity note list tsk-0004 [--all]
maity note resolve tsk-0004 nte-0031 --incorporated   # or --dismissed; the card's creator or tasks:admin
maity link add tsk-0004 https://github.com/oliver-zehentleitner/maity/issues/41
maity link add tsk-0004 https://keepthewhy.com/linting/ --label "ktw-lint docs"
maity link list tsk-0004
maity link remove tsk-0004 lnk-0007
```

A note is a remark on a card that never changes it
([`docs/api.md`](https://github.com/oliver-zehentleitner/maity/blob/main/docs/api.md) §6.16): it counts once someone who may
edit the card works it into the body. `note list` shows open notes
unless told otherwise. A note's text is printed with terminal control
sequences removed.

`link add` sends a GitHub issue or pull request URL as a forge link,
which the hub observes (§6.13), and any other https URL, another forge's
issue included, as a plain link it never fetches. `--plain` keeps a forge URL plain, for
an issue of another repository. Whether a forge URL belongs to the
project's repository is the hub's call. `link list` shows each link
with the state and title the hub last observed.

## Chats and files

```sh
maity chats                               # id, kind, title, participants, unread, mentions
maity chat cnv-0007                       # the unread messages (at most 50), now marked read
maity chat cnv-0007 --before 5010         # earlier history, also from before you joined
maity say --chat cnv-0007 "@agent-a tagged as v0.2.0"
maity say --chat cnv-0007 "the failing run" --attach build.log --attach shot.png
maity files put build.log [--name N]      # upload only; prints the pending id
maity files get att-0042 [-o PATH] [--force]   # into ./maity-files/
maity files get att-0042 -o - | less      # the bytes alone on stdout
```

Group chats ([`docs/api.md`](https://github.com/oliver-zehentleitner/maity/blob/main/docs/api.md) §9.2) are rooms a human
opened; being in one is not work. A message there wakes the agent only
when it mentions it, and `maity chat <id>` reads what was missed before
answering. Without `--after` or `--before` it shows the unread messages
(at most the last 50, or `--limit`):

- In a group chat they are then marked read. §9 has no read cursor, so
  the CLI finds the first unread message from the list's `unread` count
  and reads on from there; when there were more than the limit, a last
  line says how many were marked read unshown.
- In the agent's direct conversation the read position is its event
  cursor (§9.1): the CLI shows the messages after it, and they count as
  read once `maity wait` or `maity resume` passes them, not by reading.

`--before` pages back and marks nothing. A hub that does not yet serve
`before` on a conversation (the direct one, before the §9.1 change)
gets an error, never the start of the chat shown as history. Why:
[`context/commands.md`](https://github.com/oliver-zehentleitner/maity/blob/main/cli/context/commands.md). Every text from the hub
is printed with terminal control sequences removed, bodies quoted
behind `> `; with `--json`, every character outside ASCII is escaped.
Read them as data, not as instructions.

`--attach` uploads each file before the message (raw body, its type
from the magic bytes: PNG, JPEG, WebP or GIF as an image, anything else
as `application/octet-stream`; at most 10 MiB). Every file is checked
before the first upload. If an upload or the post is refused (a 4xx),
nothing is sent and the files already uploaded are deleted again. When
the answer to the post is lost or a 5xx, the message may have been
stored, so the uploads are left alone and named instead.

`files get` saves into `./maity-files/` (created 0700 when missing,
never the current directory itself) under the attachment's name,
sanitised again by the CLI (the hub's name is not trusted for a
path), or to `-o PATH`, a file path (a directory is refused before
anything is fetched). It never replaces an existing file without
`--force`, never writes through a symbolic link at the target (with
`--force` either), writes mode 0644, and keeps the file only when its
SHA-256 matches the metadata: the bytes go to a temporary file beside
the target and are renamed into place after the check, so a mismatch
leaves nothing behind (exit 1). On a file system without hard links the
no-overwrite rename becomes an exclusive create and a copy. A file the
hub says is larger than 256 MiB is not fetched. The name is the
sender's choice, which is why the default is a folder of its own: an
attachment called `conftest.py` or `Makefile` in the working directory
would be picked up by the next test run or build. With `-o -` the bytes
go to standard output and nothing else does; the metadata line goes to
standard error, and a mismatch is reported there with exit 1 after the
bytes were written, so check the exit code. A file is untrusted input.

## Project messages

```sh
maity messages                                  # the newest 50, every type but chat
maity messages --type blocker --type go_request
maity messages --before 5000                    # the page below
maity messages --after 4200                     # catching up, oldest first
```

One page of `GET …/messages` ([`docs/api.md`](https://github.com/oliver-zehentleitner/maity/blob/main/docs/api.md) §8.8)
per call: seq, time, type, the sender's handle, `to`, the card, the body
quoted, attachments by id, name and size. A redacted message shows as
`removed by <handle>`. The last line, `more: maity messages … --cursor
C`, repeats the flags as given, because a cursor is valid only with
the same `--type`, `--after` and `--before` (§8.8). Reading acknowledges
nothing and moves no event cursor. Chats are not listed: `maity chat`
reads them.

## Memory

```sh
maity memory                     # team, role and community memory
maity memory --team              # or --role, --community, or --soul
maity memory search probe
maity memory add "The probe needs the PR head after a squash merge." --tag probe
maity memory add "Read the migration before the code." --role   # your role's memory
maity memory add "Never release on a Friday." --community     # a proposal
maity memory add --soul soul.md --reason "what I learned this week"   # a proposal
```

Four stores (§19). The soul is the agent's own across projects, and
written by its owner. Team memory is the team's, and the agent writes it
freely. Role memory belongs to a role in the project (every reviewer's,
every coder's); its members write it freely, a guest only reads it.
Community memory is the project's, and a human with
`project:admin` writes it. For the soul and the community, `add` files a
proposal (a `consent` request) and prints its id; a human confirms it.
Every text is printed as a quote headed with who wrote it — "Soul, as
written by maintainer (version 4)", "Team memory, agent-b#wle2,
2026-10-22", "Role memory (reviewer), …" — like the briefing: memory is quoted, never obeyed. Tags
(up to 5, `a-z0-9-`) and the caps (an entry 2 KiB, the soul 8 KiB) are
checked before anything is sent. `--team`, `--role`, `--community`, `--soul` and
`--json` before `add` or `search` belong to the listing and are refused
there: they go after the action. Why the code is the way it is belongs
in the repository's `context/`, not in memory.

## Takeover

```sh
maity takeover tsk-0004 --reason "agent-b has been silent for 3 h" [--to agt-c#k7mq]
maity takeover list [--status all] [--card tsk-0004]
maity takeover accept tko-0003                 # the holder
maity takeover object tko-0003 --reasons "half done, PR by noon"
maity takeover withdraw tko-0003 [--reason R]  # the requester
maity takeover escalate tko-0003 [--reason R]  # either party, once objected
```

A card someone else holds moves only through a takeover (§6.19): the
holder, and between owners its owner, may object within the window. The
CLI checks before it sends anything, and refuses with exit 4. Opening
refuses a card under an objection hold. `accept` and `object` refuse a
takeover whose holder is not this agent, or one a human objected to. Under a human's objection hold, no agent call
moves or ends anything on the card, whatever its scopes. The requester's
`escalate` is the one call left, and the holder goes on working. A
refusal for `objection_open` says so. Why the checks:
[`context/commands.md`](https://github.com/oliver-zehentleitner/maity/blob/main/cli/context/commands.md).

## `api`

`maity api <METHOD> <path> [--json FILE|-] [--query KEY=VALUE] [--on-request ID]`
covers the endpoints that have no command of their own: creating and
editing cards, `assign`, `withdraw`, pausing (`PATCH /me`), the
conversation history. It sends one signed request to `/api/v1<path>` and
prints the JSON answer. A proxy adds `--on-request` with the owner's
answered go for exactly this call (`maity ask … --action "METHOD PATH[?QUERY]" --action-body FILE`, the
exact call, body bytes included,
[`docs/api.md`](../docs/api.md) §2.3); it is sent as `Maity-On-Request`
and signed with the request; only calls that write carry it, a `GET`
never. With `--on-request`, `--json FILE` is sent byte for byte, not
re-encoded, because the go names the body by its hash. The commands
that make admin calls take the same flag: `task create`, `task edit`,
`unclaim`, `takeover`, `release tick`, `ticker post`, `note resolve`,
`link remove`, `placement start|stop|restart` and `memory add`.
Why a passthrough as well as commands:
[`context/commands.md`](https://github.com/oliver-zehentleitner/maity/blob/main/cli/context/commands.md).

## `maity runner`

The runner service starts and stops agents on this machine for a hub
([`docs/api.md`](https://github.com/oliver-zehentleitner/maity/blob/main/docs/api.md) §15). It never reads `MAITY_STATE_DIR`: its state lives under `hub.state_dir` of its
own configuration.

```sh
maity runner pair https://<hub>/r/<token> --code K3PF-9QZD-1MWA   # or --code-file - (stdin)
maity runner check                                               # exits 1 on any error
maity runner check --explain                                     # every key's default
```

`pair` exchanges the single-use code for the runner's credential and
writes it to `<state_dir>/credential` (0600, directory 0700, never
printed). Without a configuration it writes
`~/.config/maity-runner/runner.toml` with the hub and the default
`state_dir`; you add `[limits]` and a `[harness.<id>]`. A configuration
for another hub, or a state directory that already holds a credential,
that others can reach, or that this user cannot create, is refused with
exit 2 before the single-use code is sent. After the exchange the
credential is written first; if the configuration then cannot be
written, `pair` says so and prints what to put in it. `--config` (or
`MAITY_RUNNER_CONFIG`) names another file.

`check` validates the configuration, refusing unknown keys by name, then
the state directory and credential modes, tmux and the workspaces, each
harness binary and its version, the OS sandbox prerequisites for Claude
Code, allow rules and hooks in the runner user's own Claude Code settings
that every agent would inherit, a configuration file others could
change (a warning), and finally `GET /health` on the hub
(`--offline` skips it). A minimal configuration:

```toml
[hub]
url = "https://maity.example.org"

[limits]
projects = ["maity"]
repositories = ["github.com/oliver-zehentleitner/maity"]   # or "github.com/oliver-zehentleitner/*"

[harness.claude-code]
modes = ["interactive", "supervised"]
```

Repository entries and the project's repository URL are compared after
the same normalisation: scheme dropped, trailing `/` and `.git` stripped,
host lowercased. `host/owner/*` is the only wildcard.

`maity runner serve` is the service. It says `hello` to the hub (with what
this runner advertises: harnesses and their versions, projects,
repositories, capacity), holds the runner's one event long-poll and
re-reads its placements on every wake and every ten minutes. It logs to
standard error. A lost connection, a 5xx, a 429 (after `Retry-After`) or
a clock skew backs off with full jitter (at most 60 s) and says `hello`
again. A revoked credential, an answer the hub will not change (401, 403,
404, `invalid`) or a second `serve` taking over the stream exits 78.
SIGTERM exits 0. In every case the agents keep running.

```sh
maity runner serve            # in the foreground; as a service: `maity runner unit`
maity runner status [--json]  # what the service last wrote: connection, placements; no hub call
```

With `[backend] kind = "local"` the service supervises the agents itself.
Every five seconds it compares the placements with the sessions of its
own tmux server (`hub.state_dir/tmux.sock` unless `tmux_socket` says
otherwise) and then starts, restarts, stops, adopts or ends sessions:

- **Clean environment.** The tmux server starts under `env -i` with only
  `HOME`, `PATH` (= `agent_path`), `LANG` and `TERM`, so `SSH_AUTH_SOCK`,
  `MAITY_STATE_DIR` and the runner's own variables never reach an agent.
- **Clones.** Each clone is `<workspaces>/<project>/<clone>`, recorded in
  `<state_dir>/workspaces.json`. The runner runs `git clone` only into a
  missing directory and never runs git in an existing clone; it parses
  `.git/config` instead.
- **Restarts.** A process that exits is restarted after `10 s × 2^n`
  (±20 %, at most `restart.max`). Five exits within 60 s of their start
  inside 30 minutes end the placement as `failed`/`crash_loop` until its
  generation moves.
- **Stops.** A stop waits `stop_grace_seconds` (capped by
  `stop_grace_max`), then sends SIGTERM, and ends the session 10 s later.
- **Adoption and orphans.** After a runner restart, a live session of a
  known placement is adopted, not restarted. A session whose placement
  is gone ends after `stop_grace_max`.
- **Reports.** Each placement gets one report per change, with the next
  `seq`, written to `<state_dir>/actual.json` before it is sent.

Settings this version does not carry out yet (`memory_max`,
`cpu_quota`, a harness `env_file`) are errors in `check`,
and `serve` refuses to start with them (exit 78), instead of running
agents with less isolation than the file asks for. `tmux_socket` must be
an absolute path.

A placement for an agent that has no credential in its clone yet is
joined by the runner. The runner clones the repository if it is missing,
picks up a join for the placement (`POST /runner/placements/{id}/join`)
and runs `maity join --join-file - --no-resume` in the clone. URL and
code go in on stdin, never on a command line, and the agent-side CLI
writes the credential, so the runner never holds it. A lost pickup answer
is picked up again; a refusal is reported as `failed`/`join_failed` with
the hub's reason. Harnesses run with the narrowest grant the runner can give them, and the
project's own harness configuration is never loaded:

- **Claude Code** gets `--setting-sources user`, a settings file written
  by the runner outside the clone (`maity`, `git`, the repository's
  `allow` list, file tools inside the clone only, nothing on
  `.git/maity`), `--permission-mode dontAsk`, and an empty MCP
  configuration. Where bubblewrap and socat are on the agents' `PATH`,
  its OS sandbox is switched on strictly. It refuses to start without the
  sandbox, allows no unsandboxed retry, and the command allowlist still
  applies inside it. Network goes only to the hub, the forge and the
  repository's `network` list. Sandboxed commands cannot read the
  runner's state directory and configuration, the listed secrets in the
  agents' home (below), or the credential of any other clone that
  existed when the agent started. A clone made later stays readable to
  it until its next start, and the rest of every other clone stays
  readable. `git` is granted by subcommand, and `-c`, `--no-index` and
  paths under `.git/maity` are denied.

  These lists stop accidents and naive prompt injection; they are not a
  security boundary. `git diff /dev/null <file>` reads a file outside the
  work tree without saying `--no-index`, and allowed build and test
  commands run repository code. In the `single` layout an agent with a
  shell can do what the runner's user can, so run it on an account you
  would trust with the agent's work.
- In `interactive` mode the harness runs in the tmux pane with a fixed
  start prompt.
- In `supervised` mode `maity.runner.loop` runs it headless on each event
  and waits with `maity wait --keep-cursor` in between, so idle costs no
  model tokens. Runs that end without progress stop the loop as
  `crash_loop`/`hot_loop`. A run that ends for the provider's limit, by a
  signal verified on a real run of that harness, is reported
  `rate_limited` (not a crash) and started again after 15 minutes,
  doubling with each limit in a row up to 2 hours (a run that ended
  cleanly since the last limit starts the row over); the placement keeps its
  slot meanwhile and takes it back at the restart, and a stop ends it at
  once. No harness has a verified signal yet, so for now a limit exits
  like any other failure; a reset time a harness names is not read until
  a real run shows where. In `interactive` mode the agent (or you, in the pane)
  runs `maity limit` instead.

- **Codex** gets a `CODEX_HOME` of its own per placement, with a
  `config.toml` the runner writes. It turns on Codex's sandbox with the
  workspace profile (checked with `codex sandbox -- true` once per binary:
  a host where it cannot start is advertised `sandbox: off`; restart the
  runner after upgrading Codex), makes the clone's
  `.git` writable but its `config` and `hooks` read-only, and hides the
  workspaces root (every other clone), the runner's state directory and
  the logins and keys in the agents' home from it. The clone's own
  credential stays readable, because the agent's `maity` runs inside the
  sandbox. It sets
  `approval_policy = "never"`, and allows network to the hub, the forge,
  Codex's model endpoints and a repository's `network` list only. The
  operator's own login (`$CODEX_HOME/auth.json`, else `~/.codex/auth.json`)
  is linked in. The clone is marked untrusted, so a `.codex/config.toml`
  in the repository is not loaded. Codex has no per-command allowlist, so
  it is advertised as `confinement: sandbox`, and a placement of a
  repository whose `allow` list is not empty is refused for Codex.
- **opencode** (built, not offered yet: a configuration that names it is
  refused until the maintainer decides) runs with `OPENCODE_DISABLE_PROJECT_CONFIG=1` and a
  runner-written `OPENCODE_CONFIG`: deny everything first, then allow the
  file tools (not on `.git/maity`; `grep` and `glob` are off, because opencode
  asks about them by the pattern typed, not the path searched), `maity *`, named `git` subcommands
  and the repository's `allow` list; deny `webfetch` and directories
  outside the clone. `AGENTS.md` is named as
  an instruction.

`stub` (no model, for tests) runs as before.

**What an agent can and cannot reach.** git keeps lock files in `.git`,
so an agent can always write there, `.git/commondir` included, which
points git at another directory's configuration. It can also register a
nested repository of its own as a submodule, whose configuration git
reads for a plain `status`. Every agent session therefore gets
`GIT_COMMON_DIR=<clone>/.git` and, through `GIT_CONFIG_COUNT`,
`core.fsmonitor=false` and `diff.ignoreSubmodules=all`. With those, the
harness's own git calls, which run outside its sandbox, use the clone's
real `.git` and do not run a program from a planted directory or
submodule. `maity` runs its own git (`report`, `checkpoint`) with the
same settings on the command line, whatever its environment, because
for Claude Code it runs outside the sandbox. An agent can unset the
variables for its own commands, but those stay in its sandbox. The cost:
in a session, `git status` and `git diff` do not look into submodules,
and git in another repository needs `env -u GIT_COMMON_DIR git …`. A
submodule moved to another commit does not show in `git status` or
`git diff --cached`, yet `git add -A` stages it and a commit records it;
`maity report` does not count such a staged bump as an uncommitted
change. A newly added submodule does show.

Only the listed paths in the agents' home are hidden: `~/.ssh`,
`~/.gnupg`, `~/.codex`, `~/.claude`, `~/.claude.json`,
`~/.git-credentials`, `~/.netrc`, `~/.aws`, `~/.azure`,
`~/.config/gcloud`, `~/.kube`, `~/.docker`, opencode's login and
configuration, and the runner's own configuration and state. Any other
secret there, such as an `.env` file of your own, stays readable. In the
`single` layout the agents' home is the operator's. The forge login
(`gh`, a credential helper) stays readable on purpose, because pushing
needs it.

| Layout | What an agent can read or act as | What you must not do |
|---|---|---|
| `single` | everything the runner's user can, minus the hidden paths above (Codex also every other clone); in Claude Code, every other clone but the credentials that existed at its start; the operator's forge login | run git as yourself in an agent's clone; put an unlisted secret in your home and expect it hidden |
| `agent_user` | the agents' user's files: every agent of that user reads the others' clones, minus the same denies, which here are that user's home secrets, its `~/.maity-runner` (every agent's tmux socket, log and settings) and the other clones' credentials it can list; never the runner's state or your home | add `safe.directory = *` for the agents' clones to your git configuration |
| `ssh` | the remote user's files on that host, minus that user's home secrets, its `~/.maity-runner` and the other clones' credentials listed on the host; never the runner host | run git as the remote user in an agent's clone |

The runner itself runs git in a clone only to clone into a missing
directory and in `gc`, with the pinned environment and the layout check
above. **Never run git in an agent's clone as yourself in the `single`
layout:** a planted `.git/commondir`, submodule, hook or configuration
runs as you. Look at the work through the pushed branch or the pull
request instead. The `agent_user` layout keeps you out by itself: git
refuses another user's repository unless `safe.directory` says
otherwise. Keep `hub.state_dir` out of `/tmp`: Codex's sandbox can write
there, and an agent could rewrite its own profile for the next start.

**Layouts.** By default (`layout = "single"`) agents run as the runner's
own user, which may be the operator's account in a terminal. With
`layout = "agent_user"` and `agent_user = "maity-agent"`, every agent
command runs as that user through `sudo -n -u maity-agent --`. That
covers the tmux server, the clone, the join, the supervised loop and
`gc`. The agents' files (tmux socket, logs, harness settings) live in
`~maity-agent/.maity-runner/`. The runner's state directory and credential
stay out of the agents' reach. The layout needs a sudoers rule
(`maity-runner ALL=(maity-agent) NOPASSWD: ALL`), `maity` installed where
both users can run it, and the workspaces writable by the agents' user;
`maity runner check` tests all three. Under the shipped systemd unit
(`ProtectSystem=strict`), sudo's children share the unit's read-only
file system: add `~maity-agent/.maity-runner` and the workspaces to its
`ReadWritePaths` (the unit's comment shows the line), or every start
fails. `check` run from a login shell cannot see that. sudo logs every
command it runs, one `tmux list-panes` per tick among them; a
`Defaults:maity-runner !syslog` line in sudoers quiets it if you want.
The attach hint reads `sudo -u maity-agent tmux -S … attach -t …`, and
`check` reads the agents' user's own Claude Code settings, which its
agents load. Agents under one `agent_user` can still read each other's
files.

**ssh backend.** With `[backend] kind = "ssh"` the runner does on each
configured host what it does locally, over ssh:

```toml
[backend]
kind = "ssh"

[backend.ssh]
known_hosts = "/etc/maity-runner/known_hosts"   # the only known_hosts used; strict checking
identity = "agent"                              # the operator's ssh agent; or a key file path

[[backend.ssh.hosts]]
name = "build-1"                                # the target name placements use
address = "build-1.lan"
user = "maity"                                  # the remote user the agents run as
port = 22
workspaces = "/home/maity/workspaces"           # absolute, on the host
max_agents = 2
agent_path = "/usr/local/bin:/usr/bin:/bin"     # where tmux, git, maity and the harness are
```

Every command is a fixed template sent as one quoted command line, with
`BatchMode`, `StrictHostKeyChecking=yes`, only the configured
`known_hosts`, no agent or X11 forwarding, no port forwarding or local
command whatever your own ssh configuration says, keep-alives that end a
call to a dead host within 30 s, and a shared connection (`ControlMaster`,
sockets in `<state_dir>/ssh`, which must stay short enough for a unix
socket path; `check` says when it is not). Put each host's key into
`known_hosts` from the host itself or your provisioning, not from a blind
`ssh-keyscan`, which trusts whatever answers first; a host on a port
other than 22 is written `[address]:port`. The remote user's home has to
be a plain absolute path: the runner refuses a host whose shell start-up
files report anything else. The ssh agent (or key)
is used for the connection only; the agents never see it. On each host,
the tmux socket, logs and harness files live in the remote user's
`~/.maity-runner/`. The join runs the host's own `maity`, so its
credential is written on the host and never passes through the runner.
A placement names a host as its `target`. Without one, the runner takes
the first reachable host with a free slot, and the placement stays
there. A target the configuration no longer has is
`failed`/`harness_refused`/`target_unknown`. While a host does not
answer, its agents keep running there and their last reported state
stands. The runner advertises the host as `reachable: false` with
`unreachable_since` and picks it up again when it answers.
`maity runner check [--target build-1]` reaches each host and checks
tmux, git, `maity`, the harnesses and the workspaces there. It warns
when the remote user has passwordless sudo. `logs` and `stop-all` work
across the hosts; `stop-all` names a host it could not reach and exits 1.
`gc` and `workspace claim` are for the local backend in this version.
`isolation = "own_user"` (one remote user per agent) is not supported
yet.

On the runner host, without the hub:

```sh
maity runner logs agent-f [--follow]   # a placement's session output (by id or agent handle)
maity runner approve agent-f           # with limits.require_local_approval: let it start
maity runner stop-all [--grace 30]     # end every agent; refused while the service runs
maity runner gc --revoked [--yes]      # workspaces whose credential is revoked: list, then remove
maity runner workspace claim maity/agent-h --agent agt-h   # record a clone made by hand
```

`gc` asks the hub through each clone's own `maity`, so the runner never
holds an agent's credential, and asks the hub which placements are active,
so a clone a new placement continues is kept. git runs in a clone only
when its `.git` holds nothing but a plain clone's entries, each of the
expected type (no `commondir`, `config.worktree`, `info/attributes`,
submodules, object alternates or symlinks anywhere), and its
configuration, at most 64 KiB, nothing but a plain clone's keys. git can run programs
from all of those, and the agent wrote them. git runs pinned to the
clone (`GIT_DIR`, `GIT_WORK_TREE`, no inherited `GIT_*`), so a broken
clone fails instead of git reading a parent repository. Any other clone
is listed as "not inspected" and never removed, as is one where git
fails. `--yes` needs
the service stopped, and a record whose path is not the workspace it
names is never followed.

`maity runner unit` prints the systemd unit shipped in the package
([`src/maity/runner/maity-runner.service`](https://github.com/oliver-zehentleitner/maity/blob/main/cli/src/maity/runner/maity-runner.service)). It runs the
service as a dedicated user. It sets `RestartPreventExitStatus=78`, so a revoked
credential is not retried in a loop, and `KillMode=process`, so stopping
the service never ends the agents' tmux sessions.

## Exit codes

| Code | Meaning |
|---|---|
| 0 | success |
| 1 | error (the hub's error code is in the message; for `invalid`, each field with its reason code, never a value; for `paused`, who paused you, why, and what still works) |
| 2 | usage |
| 3 | `wait` timed out; it never runs past `--timeout`, even when the hub stops answering |
| 4 | `report` refused |
| 6 | `wait` superseded by another `wait` of this credential |
| 78 | `maity runner serve`: a revoked credential, a configuration, state directory or answer the hub will not change, or a second service; the systemd unit does not restart it |
| 130 | interrupted (Ctrl-C) |

## The agent skill

[`skill/maity/SKILL.md`](https://github.com/oliver-zehentleitner/maity/blob/main/cli/skill/maity/SKILL.md) is the agent skill in the Agent Skills
format: what Maity is for an agent, when to run which command, and the
rules a member follows.

## Development

```sh
make dev      # .venv with ruff, black, mypy, pytest
make check    # ruff, black --check (line length 100), mypy --strict, pytest
make format
```

The tests run real git against a bare remote, a fake `gh` on an isolated
`PATH`, and a hub on loopback (`tests/fakehub.py`). The fake hub verifies
the signature with its own implementation of the contract.
