fb5445915f
`issue.store_root` and `_gitea.PAYLOAD_ROOT` were anchored on `__file__`, on
the reasoning that where an installation keeps its files is a fact about the
installation. That holds for an installation and not for a store.
Installed, the plugin therefore resolved every project's issues inside its own
directory — and a plugin cache is versioned, so the store moved on each
update:
~/.claude/plugins/cache/tea/tea/2.0.0/tmp/issues 5 files, 2 origin: local
~/.claude/plugins/cache/tea/tea/2.1.0/tmp/issues 12 files
~/.claude/plugins/cache/claude-skills/tea/2.2.0/ empty, the current one
Issues written from one project were invisible from the next, and an `origin:
local` file — which IS the issue, the only copy — was stranded a version bump
at a time. Two of them were.
The store is a fact about the project, exactly as the login pin is. So the
anchor is now an explicit marker an operator creates, `.tea/`, searched for up
from $CLAUDE_PROJECT_DIR and then cwd — the pin's order, so the two cannot
disagree about which project this is. Inferred markers were tried and are worse
than useless here: `.git` is in every clone including this plugin's own, and
the agents-sync hook writes an AGENTS.md next to every AGENTS.md, so the plugin
root always carried one and cwd never got a turn.
With no marker anywhere, `store_root()` is None and every entry point reports
which directories it searched. A store in a plausible-looking directory is the
failure this replaces, so nothing falls back to one.
- `.tea/` holds the store and the transport's scratchpad: `.tea/issues`,
`.tea/payload`. One marker, one walk, one gitignore line.
- `issue_init.py` creates it, moves an old `tmp/issues` store in rather than
copying, adds `.tea/` to `.gitignore`, and refuses to pick a winner when both
sides hold the same file name.
- A linked worktree has no marker — it is gitignored — and reaches the main
checkout's store by the hop the pin already took.
- `parents`, `gitdir_of` and `main_worktree` move from `pin.py` into the domain
and `pin.py` imports them. The domain depends on nothing, so it is the layer
all three callers can borrow from, and the walk stays written once: the
guard, the transport and the store cannot disagree about a directory.
The suite stopped copying the script layers into its fixtures. That is what hid
this: with the scripts inside the fixture, the installation and the project
were the same directory. They are now deliberately far apart, and a regression
test asserts the plugin tree gains no files when commands run against a project
somewhere else.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
263 lines
15 KiB
Markdown
263 lines
15 KiB
Markdown
# 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. One domain, one bridge, one transport, and
|
|
knowledge flows one way only:
|
|
|
|
```
|
|
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
|
▲ 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
|
|
│ 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
|
|
```
|
|
|
|
The domain never imports its bridge: delete `skills/sync` and issues still
|
|
work. The check is mechanical — every import under the domain's `scripts/` is
|
|
stdlib, and `subprocess` is not among them:
|
|
|
|
```bash
|
|
grep -rh '^import \|^from ' skills/issue/scripts/ | sort -u
|
|
```
|
|
|
|
If a tracker concept (issue number, login, HTTP call, label color) shows up in
|
|
the 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_evict.py` — remove closed issues from the store; never an
|
|
`origin: local` one
|
|
- `scripts/issue_index.py` — rebuild `.tea/issues/INDEX.md`
|
|
- `scripts/issue_init.py` — create the `.tea/` marker that makes a directory
|
|
a project; migrates an old `tmp/issues` store in, adds `.tea/` to
|
|
`.gitignore`. Idempotent, and refuses to pick a winner on a name clash
|
|
- `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, `.tea/payload/`; the login comes from `auth/pin.py`
|
|
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
|
|
- `scripts/close.py` — the state field, both ways; explicit ids only
|
|
- `scripts/evict.py` — refresh `state:` from Gitea, then hand the decision to
|
|
the domain's `issue_evict.run`
|
|
- `scripts/labels.py` — put the canonical `type/*` and `severity/*` set into a
|
|
repository; reads the domain taxonomy, never the store
|
|
- `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
|
|
|
|
`<project root>/.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`.
|
|
|
|
**Nothing here resolves from `__file__`, and the walk is written once.**
|
|
`issue.parents`, `issue.gitdir_of` and `issue.main_worktree` live in the domain
|
|
— the layer that depends on nothing, and therefore the only one all three
|
|
callers may borrow from — and `pin.py` imports them. The guard, the transport
|
|
and the store cannot disagree about a directory, because there is one walk.
|
|
|
|
Where an installation keeps its files is a fact about the installation. Whose
|
|
login a project runs under is a fact about the project — **and so is which
|
|
issues it has.** A plugin installed outside any repository and pointed at
|
|
somebody else's tree must answer both from the tree it was pointed at.
|
|
|
|
Three failures this replaces, all 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;
|
|
- the cure that invited — `/tea:auth` inside the worktree — writes a second
|
|
settings file into a directory that is deleted with the worktree;
|
|
- and the store and the payload root, which *were* anchored on `__file__`,
|
|
resolved inside the installed plugin: `~/.claude/plugins/cache/tea/tea/2.0.0/
|
|
tmp/issues`, a **versioned** directory. Issues written from one project were
|
|
invisible from the next and stranded by every plugin update. Two `origin:
|
|
local` files — the only copy of that work, by definition — were found there.
|
|
|
|
## 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 `.tea/issues/` or `.tea/payload/`.** Anything that needs
|
|
a store builds a throwaway project in a `tempfile.TemporaryDirectory()` — a
|
|
`.tea/` marker and fixture issues — 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.
|
|
|
|
**The scripts are not copied into the fixture.** They stay where they really
|
|
live, and the fixture is somewhere else entirely — that separation *is* the
|
|
contract: a plugin is installed in one place and used on projects in another.
|
|
The suite used to copy both layers in, which made the two the same directory
|
|
and hid the `__file__` bug completely.
|
|
|
|
**A subprocess fixture strips `CLAUDE_PROJECT_DIR`** unless the test is about
|
|
it. It is the first anchor of the walk, so the harness's own value would point
|
|
every fixture at this repository.
|
|
|
|
## Local issue store
|
|
|
|
`.tea/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 `<project root>/.tea/issues`, where the project root is the
|
|
nearest ancestor of the WORKING DIRECTORY holding a `.tea/` marker.**
|
|
`issue.project_root()` walks up from `$CLAUDE_PROJECT_DIR`, then cwd — the
|
|
same order and the same walk as the login pin, since both answer "which
|
|
project is this". Every script in both layers sees one store from anywhere
|
|
inside the project; a `cd` into a *different* project answers with that
|
|
project's store, which is the point. An explicit `--out` overrides it and is
|
|
used exactly as typed; a relative `--out` stays relative to cwd.
|
|
- **The marker is created by `issue_init.py`, never inferred.** An operator
|
|
states that a directory is a project; nothing guesses it. `.git` was tried as
|
|
the marker and is in every clone including this plugin's own — see below.
|
|
- **No marker anywhere is an answer, not a fallback.** `store_root()` returns
|
|
None and every entry point reports which directories it searched. A store
|
|
placed in a plausible-looking directory is the failure this replaces.
|
|
- **A linked worktree resolves to the main checkout.** The marker is gitignored,
|
|
so a worktree never has one; it is the same project on another branch, and it
|
|
reaches the store by the same hop the pin takes. Do not run `issue_init.py`
|
|
in a worktree — one project would get two stores, and the second disappears
|
|
with the branch.
|
|
- 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** (`<id>.md` and
|
|
`<id>.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 <n>` — which brings its blockers back with it:
|
|
a pull returns the unit of work, not one row of it. `--no-deps` narrows it to
|
|
the one issue, and the cost of the default is in `pull.py`'s docstring.
|
|
- 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
|
|
`<!-- tea:id … -->` (`map.with_id_marker`) and is indexed by number in
|
|
`.tea/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. **Eviction does not prune it either**, for the same reason a
|
|
push does not: an evicted issue is in exactly the state a pushed one is.
|
|
- **A closed issue is evicted, not archived.** `issue_evict.py` removes
|
|
`<id>.md` and every sidecar under that slug for anything that is `state:
|
|
closed` **and** carries an `origin:` naming a tracker, then rebuilds
|
|
`INDEX.md`. `--dry-run` prints and writes nothing. **`origin: local` is never
|
|
evicted, in any state, not even when named on the command line** — that file
|
|
*is* the issue and nothing can fetch it back.
|
|
- **Eviction lives in the domain** (`skills/issue/scripts/issue_evict.py`),
|
|
because its two inputs — `state:` and `origin:` — are domain fields and the
|
|
answer is already on disk. No network, no login, no `tea`.
|
|
`skills/sync/scripts/evict.py` is the bridge form: it refreshes `state:` from
|
|
the tracker first (a local `state:` is only as fresh as the last pull) and then
|
|
calls `issue_evict.run`. One implementation of "what may be evicted", in the
|
|
layer that owns the fields it reads. Same gate as push, one step earlier: a
|
|
failed or unconfirmed tracker answer evicts nothing at all.
|
|
- **Pull by number fetches an issue in any state — a number is a number.** An
|
|
address is not a query: `pull.py 42` puts a closed issue on disk exactly as it
|
|
always has, and so does `#42`, `owner/repo#42`, or its URL. Only filter mode
|
|
(`--milestone`, `--label`, `-q`) leaves closed issues out. Eviction does not
|
|
revoke this: a closed issue pulled after a cleanup lands on disk again, and
|
|
that is the tracker answering what it was asked, not a regression. Evict it
|
|
again when you are done with it.
|
|
- 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.
|
|
|
|
## Request payloads
|
|
|
|
`.tea/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, a sibling of the store under the same marker
|
|
and resolved by the same walk, so which command wrote a body does not change
|
|
where it landed — and the scratchpad and the store can never end up in two
|
|
different projects. `_gitea.api` takes no directory argument; that it once
|
|
did is exactly how a label bootstrap came to create the issue store.
|
|
- 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 .tea/issues` starts lying about what exists.
|