The plugin is issues and nothing else now. `skills/page` (the page-tree domain) and `skills/wiki` (its bridge to a Gitea wiki) are gone, and with them the issue domain's `wiki:` field — page titles were the only thing that tied the two domains together, and a field the tracker has no column for never came back from a pull anyway. What is left is the shape AGENTS.md already claimed for the rest of the repo: one domain, one bridge, one transport. The docs, the plugin manifest, and tea-runner's skill table now say so too, and test_payload_root walks the one script directory that remains. Also removes openspec/config.yaml; nothing in the repo referenced it. 378 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
13 KiB
AGENTS.md
Project goals
- 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.
- 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.
- Route all Gitea interaction through the
teaCLI via scripts instead of direct ad-hoc calls wherever possible. Scripts give deterministic, reviewable behavior; thetea-guardhook enforces that everyteainvocation 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:
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 bytea(/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 offlinereferences/format.md— canonical issue format; single source of truthscripts/issue.py— domain module: slug identity, parse/render, validation, taxonomy, dependency graph, body checkboxesscripts/issue_new.py— create a local issue from its type templatescripts/issue_check.py— validate against the formatscripts/issue_ac.py— list the body's checkboxes; tick one by number or substring, changing exactly one character of the filescripts/issue_tree.py— draw the dependency graphscripts/issue_evict.py— remove closed issues from the store; never anorigin: localonescripts/issue_index.py— rebuildtmp/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 herescripts/_gitea.py— transport:tea api, pagination, filters, label ids, the remote-id map,tmp/payload/; the login comes fromauth/pin.pyscripts/pull.py,push.py,remote.py,comment.pyscripts/close.py— the state field, both ways; explicit ids onlyscripts/evict.py— refreshstate:from Gitea, then hand the decision to the domain'sissue_evict.runscripts/labels.py— put the canonicaltype/*andseverity/*set into a repository; reads the domain taxonomy, never the store
skills/use—teaCLI reference for everything that is not an issue (/tea:use);references/tea/holds the command docsagents/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 noEditand noWrite, 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-guardblocks or rewritesteainvocations that don't use the pinned login (resolving it throughauth/pin.py);agents-synckeeps every directory canonical (AGENTS.mdreal file,CLAUDE.mdsymlink to it)tests/— stdlibunittest, 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.
The pin is not resolved from __file__, and that asymmetry with
issue.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
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/ 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 — 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
<repo root>/tmp/issues, resolved fromissue.py's own location, not from cwd.issue.store_root()walks up from__file__to the nearest.gitorAGENTS.md— so every script in both layers sees one store whatever directory it is run from. An explicit--outoverrides it and is used exactly as typed; a relative--outstays 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.pyandpull.pycreate it, and they say so on stderr. - Identity is the slug (
wire-sqlc-appclick.md), never a tracker number. Numbers live in thegitea:field. origin: localis 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>.mdand<id>.comments.md), and prints the number and URL the issue now lives at.--updatetoo: one rule, no exception. What is in the store is what has not left. Get it back withpull.py <n>— which brings its blockers back with it: a pull returns the unit of work, not one row of it.--no-depsnarrows it to the one issue, and the cost of the default is inpull.py's docstring. - Deletion happens only after a confirmed tracker response and only after
.remote.jsonhas been written. Network down, non-2xx, an answer that does not carry the right number: the file stays and the run stops. A never-pushedorigin: localissue 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 intmp/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 everydepends:that points at it still resolves. .remote.jsonis 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.pyremoves<id>.mdand every sidecar under that slug for anything that isstate: closedand carries anorigin:naming a tracker, then rebuildsINDEX.md.--dry-runprints and writes nothing.origin: localis 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:andorigin:— are domain fields and the answer is already on disk. No network, no login, notea.skills/sync/scripts/evict.pyis the bridge form: it refreshesstate:from the tracker first (a localstate:is only as fresh as the last pull) and then callsissue_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 42puts 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
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, resolved from
_gitea.py's own location, so which command wrote a body does not change where it landed._gitea.apitakes no directory argument; that it once did is exactly how a label bootstrap came to createtmp/issues/. - It is created lazily, by the first write of a run, and only then: a
--dry-runor 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/issuesstarts lying about what exists.