# Conflicts: # skills/sync/scripts/_gitea.py
12 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. 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 login pin, tea api, pagination, filters
skills/wiki BRIDGE wikimap.py md <-> Gitea wiki JSON, pure, no I/O
transport is _gitea.py — there is no second one
skills/use REFERENCE tea CLI docs for everything that is not an issue
skills/auth IDENTITY pin the login the whole tracker side runs under
▲
│ 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:
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 bytea(/tea:auth)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_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: login pin,tea api, pagination, filters, label ids, the remote-id map,tmp/payload/scripts/pull.py,push.py,remote.py,comment.pyscripts/labels.py— put the canonicaltype/*andseverity/*set into a repository; reads the domain taxonomy, never the store
skills/page— a discussion's artifacts as a page tree (/tea:page), entirely offlinereferences/pages.md— canonical page-tree format; single source of truthscripts/page.py— domain module: title ↔ path, ordering, the manifest, importing a directory of markdown, the indexscripts/page_import.py— copy a directory of markdown into a space, titling every filescripts/page_index.py— write the table-of-contents pagescripts/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/Oscripts/wiki_ls.py,wiki_pull.py,wiki_push.py— transport isskills/sync/scripts/_gitea.py
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;agents-synckeeps every directory canonical (AGENTS.mdreal file,CLAUDE.mdsymlink to it)tests/— stdlibunittest, no third-party anything
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/, 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
<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.- 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/<space>/ (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_urlis 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.
--retitleopts in, and the rename reaches the wiki on the next push. - A page with no
sub_urlhas never been published — a complete state, the wayorigin: localis 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
teaCLI has no wiki subcommand.tea apiis 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.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.