Gitea becomes the source of truth. Once a push is confirmed, push.py
deletes tmp/issues/<id>.md and <id>.comments.md and prints the number and
URL the issue now lives at; the current state is obtained by pulling
again rather than by reconciling. --update follows the same rule, with no
exception: what is local is what has not left.
This reverses three statements AGENTS.md used to make, and rewriting them
is part of the change:
- "tmp/issues/ is the store, not a cache of Gitea" — it is both, split
by origin:. An origin: local file is the only copy of the work; an
origin: gitea file is a deletable working copy.
- "Pushing is additive: the file is never deleted" — it is deleted.
- "origin: local is a durable state" — complete, but not durable:
pushing ends it.
Slug stability, which the format promises for the life of an issue, can
no longer rest on a file push is about to delete. The slug goes up in the
body as a hidden marker, <!-- tea:id <slug> -->, on the first line:
map.to_payload strips every marker and prepends exactly one, map.from_api
strips every marker on the way down, so the local file never holds one
and a body cannot accumulate them however many round trips it makes. The
marker survives a rename in the web UI, a lost .remote.json, a fresh
clone and another machine — none of which a local index does.
Deletion is the last thing that happens to an issue and only after the
transport returned, the answer carried a positive integer number (and, on
--update, the number that was PATCHed — push.confirmed_number), and
.remote.json was written. A raised transport, a non-2xx, an empty or
mismatched body each leave the file on disk and stop the run.
.remote.json is no longer "only an index over the files": its entries now
deliberately outlive them, so it is the local number -> slug ledger and
rebuild_map merges into it instead of reconstructing it from files that
may be gone. It stays recoverable, from the markers in Gitea rather than
from the files. push.dep_state reads it too, so a blocker whose file an
earlier push dropped still gets its native dependency link.
Also fixes a pre-existing bug the new tests hit: issue.all_ids treated
<id>.comments.md as an issue called "<id>.comments", so a bare push.py in
a store holding pulled threads tried to file a comment thread as a unit
of work. A slug has no dot in it.
tests/test_drop_after_push.py covers the round trip (push -> gone -> pull
-> identical in slug, depends: and body), the marker's algebra, and every
failure path separately. test_push_dependencies.py is updated where it
encoded the old "never deleted" contract. 183 tests, no network.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.1 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. 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 JSON, pure functions, no I/O
_gitea.py login pin, tea api, pagination, filters
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
skills/issue never imports from skills/sync. Delete skills/sync and the
domain layer keeps working. The check is mechanical — every import under
skills/issue/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)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 mapscripts/pull.py,push.py,remote.py,comment.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/. 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.
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>. - 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.