# AGENTS.md ## Project goals 1. **Unify and systematize issue workflow** for the development team with minimal context usage. Issue operations are wrapped in scripts so agents spend tokens on the task, not on re-deriving commands and formats. 2. **Keep the tracker out of the work.** An issue is a unit of work first and a Gitea row second. The two are separate layers, and the first one does not know the second exists. 3. **Route all Gitea interaction through the `tea` CLI via scripts** instead of direct ad-hoc calls wherever possible. Scripts give deterministic, reviewable behavior; the `tea-guard` hook enforces that every `tea` invocation runs under the operator-pinned login. ## Layers The hard rule of this repo. Two domains, two bridges, one transport, and knowledge flows one way only: ``` skills/issue DOMAIN what an issue is: format, validation, dependency graph skills/page DOMAIN what a page tree is: title <-> path, order, the index ▲ offline — no tracker, no network, stdlib imports only │ imports skills/sync BRIDGE map.py md <-> Gitea issue JSON, pure, no I/O _gitea.py tea api, pagination, filters, payloads skills/wiki BRIDGE wikimap.py md <-> Gitea wiki JSON, pure, no I/O transport is _gitea.py — there is no second one │ imports ▼ skills/auth IDENTITY pin the login the whole tracker side runs under ▲ pin.py where the pin is and how it is found — │ imports imported by _gitea.py AND by hooks/tea-guard.sh hooks/tea-guard so `tea` and the scripts cannot disagree skills/use REFERENCE tea CLI docs for everything that is not an issue ▲ │ calls agents/ EXECUTION tea-runner: runs the scripts, reports a receipt ``` A domain never imports its bridge, and the two domains do not import each other: delete `skills/sync` and issues still work, delete `skills/wiki` and page trees still work, delete either domain and the other is untouched. The check is mechanical — every import under a domain's `scripts/` is stdlib, and `subprocess` is not among them: ```bash grep -rh '^import \|^from ' skills/issue/scripts/ | sort -u grep -rh '^import \|^from ' skills/page/scripts/ | sort -u ``` If a tracker concept (issue number, login, HTTP call, label color, `sub_url`, `content_base64`) shows up in a domain layer, it is in the wrong place. ## Repo layout - `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`) - `scripts/pin.py` — the one written copy of the pin's location and search order (see "The login pin" below); stdlib, no subprocess, no network - `skills/issue` — issues as units of work (`/tea:issue`), entirely offline - `references/format.md` — canonical issue format; single source of truth - `scripts/issue.py` — domain module: slug identity, parse/render, validation, taxonomy, dependency graph, body checkboxes - `scripts/issue_new.py` — create a local issue from its type template - `scripts/issue_check.py` — validate against the format - `scripts/issue_ac.py` — list the body's checkboxes; tick one by number or substring, changing exactly one character of the file - `scripts/issue_tree.py` — draw the dependency graph - `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md` - `skills/sync` — move issues between the local store and Gitea (`/tea:sync`) - `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here - `scripts/_gitea.py` — transport: `tea api`, pagination, filters, label ids, the remote-id map, `tmp/payload/`; the login comes from `auth/pin.py` - `scripts/pull.py`, `push.py`, `remote.py`, `comment.py` - `scripts/labels.py` — put the canonical `type/*` and `severity/*` set into a repository; reads the domain taxonomy, never the store - `skills/page` — a discussion's artifacts as a page tree (`/tea:page`), entirely offline - `references/pages.md` — canonical page-tree format; single source of truth - `scripts/page.py` — domain module: title ↔ path, ordering, the manifest, importing a directory of markdown, the index - `scripts/page_import.py` — copy a directory of markdown into a space, titling every file - `scripts/page_index.py` — write the table-of-contents page - `scripts/page_ls.py` — the tree, the titles, one sync-state tag per page - `skills/wiki` — move page trees between a local space and a Gitea wiki (`/tea:wiki`) - `scripts/wikimap.py` — md ↔ Gitea wiki JSON, pure, no I/O - `scripts/wiki_ls.py`, `wiki_pull.py`, `wiki_push.py` — transport is `skills/sync/scripts/_gitea.py` - `skills/use` — `tea` CLI reference for everything that is not an issue (`/tea:use`); `references/tea/` holds the command docs - `agents/tea-runner.md` — subagent on Haiku that executes the scripts and returns a compact receipt. Delegate batches (bulk pull, push a named set, bootstrap labels, rebuild the index), never the thinking: it has no `Edit` and no `Write`, may not `--force`, and may not decide what an issue says. Delegating a single call costs more than running it inline — the win is the loop, the retry, and the error triage. - `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations that don't use the pinned login (resolving it through `auth/pin.py`); `agents-sync` keeps every directory canonical (`AGENTS.md` real file, `CLAUDE.md` symlink to it) - `tests/` — stdlib `unittest`, no third-party anything ## The login pin `/.claude/settings.local.json` → `env.GITEA_LOGIN`, written by `/tea:auth` and read at call time. **The search order is written once, in `skills/auth/scripts/pin.py`**, and both callers import it: the transport (`_gitea.require_login`) and the `tea-guard` hook. Neither spells the path or the walk itself, and a test asserts they don't. Start directories, first hit wins: `$CLAUDE_PROJECT_DIR`, then a hint the caller supplies (the hook passes the Bash payload's `cwd`; a script passes nothing), then the current directory. Each one is searched up its parent chain, and then — only if that found nothing — up the parent chain of the **main working tree of any linked worktree** met on the way, reached by reading `gitdir:` out of a `.git` *file* and following `commondir`. **The pin is not resolved from `__file__`, and that asymmetry with `issue.store_root`/`page.store_root`/`_gitea.PAYLOAD_ROOT` is deliberate.** Where an installation keeps its files is a fact about the installation; whose login a project runs under is a fact about the project. A plugin installed outside any repository and pointed at somebody else's tree must not answer the second question from its own directory. So the search runs from the working directory upward — and reaches a worktree's main checkout by asking git. Two failures this replaces, both worth remembering: a git worktree is a *sibling* of the main checkout, so the untracked pin is not on its parent chain and the whole sync layer died there while `tea` in the same directory worked; and the cure it invited — `/tea:auth` inside the worktree — writes a second settings file into a directory that is deleted with the worktree. ## Tests ```bash python3 -m unittest discover -s tests -v ``` Plain `unittest`; no pytest, no dependencies — the scripts under test are stdlib-only and the tests hold the same line. `skills/*/scripts/` are not packages, so a test that needs the domain module imports it with `sys.path.insert`. **A test never touches `tmp/issues/`, `tmp/wiki/` or `tmp/payload/`.** Anything that needs a store builds a throwaway repository in a `tempfile.TemporaryDirectory()` — a `.git` marker, a copy of the script layers, fixture issues or artifacts — and runs the real scripts inside it as subprocesses. That is the only way to test behavior that depends on where a script is run from, and it keeps the developer's own store out of the blast radius. `tmp/payload/` is in that list because `_gitea.PAYLOAD_ROOT` is resolved once, from the module's own location: a test that stubs the transport *below* `api()` — at `subprocess`, to exercise a non-2xx — reaches the real write. Such a test patches `PAYLOAD_ROOT` to its own temp directory too. ## Local issue store `tmp/issues/` (gitignored) holds **two kinds of file, and only one of them is a store.** An `origin: local` issue lives here and nowhere else — this file *is* the issue, and losing it loses the work. Anything with `origin: gitea` is a **cache**: the tracker has it, this copy is a working copy, and it is deleted the moment a push confirms the tracker is up to date. One flat markdown file per issue, named by its slug, with one metadata field per line so plain grep works without a parser. - **The path is `/tmp/issues`, resolved from `issue.py`'s own location, not from cwd.** `issue.store_root()` walks up from `__file__` to the nearest `.git` or `AGENTS.md` — so every script in both layers sees one store whatever directory it is run from. An explicit `--out` overrides it and is used exactly as typed; a relative `--out` stays relative to cwd. - Nothing creates the store as a side effect of a write. Readers distinguish "does not exist" from "is empty"; only `issue_new.py` and `pull.py` create it, and they say so on stderr. - Identity is the slug (`wire-sqlc-appclick.md`), never a tracker number. Numbers live in the `gitea:` field. - `origin: local` is a complete state, not a draft: an issue that never leaves this machine is valid and finished. It is not a *durable* state, though — pushing ends it, and the local file goes with it. - **A successful push deletes the local file** (`.md` and `.comments.md`), and prints the number and URL the issue now lives at. `--update` too: one rule, no exception. What is in the store is what has not left. Get it back with `pull.py `. - Deletion happens only after a confirmed tracker response and only after `.remote.json` has been written. Network down, non-2xx, an answer that does not carry the right number: the file stays and the run stops. A never-pushed `origin: local` issue is never touched by any of this. - The slug survives the round trip because it goes up in the body as `` (`map.with_id_marker`) and is indexed by number in `tmp/issues/.remote.json`. A rename in the web UI, a lost `.remote.json`, a fresh clone, another machine — the file comes back under the same name and every `depends:` that points at it still resolves. - `.remote.json` is therefore no longer "an index over the files": it is the local number → slug ledger, its entries outlive the files they name, and nothing prunes them. It is still recoverable — from the markers in Gitea, not from the files. - Pulling overwrites the body — a fetch, not a merge. It is also how a pushed issue comes back at all. - No drift tracking, and now nothing to track: there is no second copy to diverge from. `synced:` tells you how old your working copy is. ## Local wiki cache `tmp/wiki//` (gitignored) holds page trees — a discussion's artifacts, organized. Same stance as the issue store, resolved the same way from `page.py`'s own location, with the same `--out` rule. - Identity is the **title**, and `/` inside it is the only hierarchy there is. The Gitea wiki is flat: it escapes a title into one filename by rules of its own (`space -> -`, `/ -> %2F`, a literal `-` forces a trailing `.-`). - **`sub_url` is Gitea's address for a page and is never constructed.** It is read back from the API and stored in `.pages.json`. One built by hand that is almost right creates a second page instead of editing the first. - **Never commit a subdirectory into a wiki's git repository.** Gitea does not see it — the page exists on disk and nowhere in the API or the UI. Do not clone the wiki repo to work in; use the scripts. - A title is a decision, not a derivation. A re-import replaces bodies and keeps titles, so editing a heading cannot silently rename a published page. `--retitle` opts in, and the rename reaches the wiki on the next push. - A page with no `sub_url` has never been published — a complete state, the way `origin: local` is for an issue. **The parallel stops at the push**: a pushed page stays on disk, a pushed issue does not. - Change detection is one hash (`pushed`). Pulling overwrites; pushing is additive and never deletes — the one place the two domains deliberately disagree, because a page tree is worked on locally and an issue is not. - The `tea` CLI has no wiki subcommand. `tea api` is the only route, through `_gitea.py`. ## Request payloads `tmp/payload/` (gitignored) holds the JSON bodies `tea api -d @file` was given, one file per named request, kept after the call for a retry or a post-mortem. It is **not a store and holds nobody's only copy** — deleting it costs nothing. - One directory for every caller — sync and wiki both — resolved from `_gitea.py`'s own location, so which command wrote a body does not change where it landed. `_gitea.api` takes no directory argument; that it once did is exactly how a label bootstrap came to create `tmp/issues/`. - It is created lazily, by the first write of a run, and only then: a `--dry-run` or a run with nothing to send leaves no directory behind. - **A scratchpad may never sit inside a store.** Store contents are the thing being tracked; request bodies are debris of the transport. When the two share a path, an operation that touches no issue at all still materializes the issue store, and the operator's `ls tmp/issues` starts lying about what exists.