Files
marketplace/skills/sync/SKILL.md
T
naudachu 2f82b501bd feat: evict closed issues from the local store
The store is a working set, not an archive. Until now nothing removed a
closed issue from it: #10 put a filter on the write and said so explicitly
("existing store files are not cleaned"), and the migration was never
anybody's job. The only way out was rm past every script, followed by
rebuilding INDEX.md by hand.

issue_evict.py removes <id>.md and every sidecar under that slug for an
issue that is state: closed AND carries an origin: naming a tracker, then
rebuilds INDEX.md. --dry-run prints and writes nothing at all.

Two conditions, and the second one is the whole safety argument. An
origin: local issue IS the work — there is no other copy — so it is never
evicted, in any state, not even when named on the command line: it is
reported and kept. The only files that go are ones whose own metadata says
pull.py <n> brings them back, which is the trade push.py already makes
when it drops a file the tracker just confirmed.

The command lives in the domain layer, and the layering rule decides that
rather than convenience: state: and origin: are domain fields and the
answer is already on disk, so eviction needs no network, no login and no
tea. The domain also gains issue.slug_files — every file the store holds
under one slug, which is all_ids' "a slug has no dot in it" read the other
way round, and lets the domain remove an issue completely without learning
what a comment thread is.

skills/sync/scripts/evict.py is the bridge form, and it exists because a
local state: is only as fresh as the last pull: an issue closed in the web
UI still reads open here. It refreshes state: from Gitea, 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:
every candidate's state is fetched before anything is removed, each answer
must be an object carrying the number asked about and a state the domain
recognizes (confirmed_state, the counterpart of confirmed_number), and a
failed or unconfirmed call evicts nothing — not even the candidates whose
answers had already arrived, and no refreshed state: is written back
either. A candidate is an issue with a gitea: handle; origin: local has
none, is never asked about, and is never removed.

.remote.json is deliberately not pruned. It is the number -> slug ledger,
its entries are supposed to outlive the files they name, and an evicted
issue is in exactly the state a pushed one is.

AGENTS.md gains the rule the tracker side never wrote down: pull by number
fetches an issue in any state — an address is not a query. Eviction does
not revoke it, so a closed issue pulled after a cleanup is on disk again,
and that is the tracker answering what it was asked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:28:13 +05:00

21 KiB

name, description
name description
sync Move issues between the local store and Gitea — pull issues into tmp/issues/, push local issues up, post comments. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, or comment on one. Working with an issue's content (writing, grepping, validating, dependency graph) is /tea:issue and needs no network.

/tea:sync — the bridge between the local store and Gitea

One job: translate between tmp/issues/<id>.md and Gitea's JSON, and carry the result over the wire. Everything about what an issue is — format, types, validation, the dependency graph — belongs to /tea:issue and is imported from there, never redefined here.

Direction of knowledge, and it is one-way:

skills/issue   domain     what an issue is            offline, no tracker
      ▲
      │ imports
skills/sync    bridge     map.py    md <-> Gitea JSON, pure, no I/O
                          _gitea.py login, tea api, pagination, filters

skills/issue never imports anything from here.

Never read an issue through raw tea

tea issues <n> -o json and tea api .../issues/<n> dump the full payload — avatars, nested user objects, every comment body — into your context whether you need it or not. Use pull.py: it writes flat markdown and prints a compact index.

Scripts

In <skill-base-dir>/scripts/. None of them take --login: they resolve the operator's pin from .claude/settings.local.json themselves, the same source the tea-guard hook reads. No pin → exit with a pointer to /tea:auth.

Script What it does
remote.py [--state] [--label] [--milestone] [-q TEXT] discovery: one line per Gitea issue to stdout, writes nothing
pull.py <key…> or pull.py --milestone M | --label L | -q TEXT Gitea → tmp/issues/<id>.md, plus <id>.comments.md when the thread is not empty
push.py [id…] [--update] [--dry-run] local → Gitea; validates first, deletes the local file on success and prints where it lives now
evict.py [id…] [--dry-run] refresh state: from Gitea, then evict the issues it reports closed; origin: local is never asked about and never removed
comment.py <id> --file F | --body TEXT [--edit N] post or edit a comment, then refetch the thread
labels.py [--dry-run] [--fix] bootstrap the canonical type/* + severity/* set in a repo; exact names left alone, lookalikes reported, drift fixed only with --fix
map.py, _gitea.py the two layers the commands import — not commands

Key forms for <key>: 42, #42, owner/repo#42, or a full issue URL. Repo defaults to the current directory's git remote; add --repo owner/repo outside one.

--out defaults to issue.ISSUE_ROOT on every one of them — the domain layer's <repo root>/tmp/issues, resolved from the scripts' own location rather than cwd. Both layers therefore address the same store by construction, from any directory. Pass --out to override; a relative one stays relative to cwd. Only pull.py will create a missing store, and it says so on stderr.

Identity mapping

The local id is a slug; Gitea's is a number. While a working copy exists, the pair is in the file:

origin: gitea
gitea: claude-skills/tea#42
url: https://git.noodles.cam/claude-skills/tea/issues/42
synced: 2026-08-09T18:40:00Z

But the file is deleted on push, so the pair also lives in two places that outlast it: tmp/issues/.remote.json (number → slug) and the <!-- tea:id … --> marker in the issue body on the Gitea side. See How the slug comes back.

.remote.json used to be described as an index over the files. It is not one any more — the files are a subset of what it knows, and its entries deliberately outlive them. It is the local ledger, and _gitea.rebuild_map merges into it rather than reconstructing it, so a rebuild can never drop a pushed issue. Nothing prunes it: "no file" no longer means "no such issue". Delete it anyway and nothing is lost — the next pull reads the slug off the marker and writes the entry back.

A retitled issue keeps its slug: neither record is keyed by the title.

Pulling

python3 <skill-base-dir>/scripts/pull.py 42
python3 <skill-base-dir>/scripts/pull.py --milestone 6          # id or title
python3 <skill-base-dir>/scripts/pull.py --label type/bug --state all
python3 <skill-base-dir>/scripts/pull.py -q sqlc --limit 20
python3 <skill-base-dir>/scripts/pull.py 40 --deps              # follow dependencies

Do not loop over numbers to pull a group — pass the filter. The list endpoint carries the issue bodies, so a milestone costs one request per 50 issues, not one per issue. Filters AND together; --state defaults to open; --limit to 100. Keys and filters are mutually exclusive.

A pull is how a pushed issue comes back. Push deleted the file, so this is not refreshing a copy you kept — it is how the copy comes to exist. It lands under the same slug it had before, even after a rename in Gitea and even on a machine that has never seen the issue; see How the slug comes back.

A pull overwrites the local body. It is a fetch, not a merge — unpushed local edits are lost, with one exception: checkbox state. --cached skips issues already on disk.

Closed issues stay out of the store. In filter mode they are enumerated but not written: --state all still shows the whole picture, only --state closed puts one on disk, and the number left out goes to stderr. An issue already on disk is refreshed either way — the local copy learns it was closed instead of staying open forever. Key mode is exempt: pull.py 1 fetches a closed issue as always, because an address is not a bulk read.

Comments come with every pull — there is no flag. An issue that has a thread gets tmp/issues/<id>.comments.md beside it, in key mode and in filter mode alike, and the issue's output line says how many. An issue with none costs nothing: the count arrives in the list payload, so no request is made and no file is written — and a file left over from a thread that has since been emptied is deleted. --cached skips the thread along with the body, so a skipped issue makes no request at all.

Two traps this handles for you:

  • Gitea silently ignores an unresolvable milestone filter and returns the whole backlog. pull.py resolves the milestone first (exiting with the real ones if it does not exist) and re-checks every returned issue locally. Never trust a raw tea api ...issues?milestones=X for this.
  • Projects are not fetchable. The projects API is not exposed (404 on Gitea 1.26 for repos/…/projects, orgs/…/projects, projects/{id}). Use milestones or labels; project columns live in the web UI only.

After a pull, draw the graph with /tea:issue's issue_tree.py — offline, no extra requests.

Checkboxes are the one exception

A checkbox is state, not prose, and it is the one thing a pull does not overwrite. For a checkbox line whose text matches a line in the local copy, [x] wins from whichever side has it — tick it in the web UI, tick it locally, tick it in both, the tick survives.

part of the body what a pull does to it
prose, headings, everything not a checkbox overwritten from the server, whole, as before
a checkbox whose text is in the local copy [x] from either side wins
a checkbox whose text is not in the local copy taken from the server as it stands, ticked or not
any issue the store has never seen written exactly as the server sent it

This is not drift tracking — Drift stands. A tick is monotone: an item only travels [ ][x], so joining the two sides is a set union, not a conflict to resolve. No base version is kept and nothing is compared against one; one rule for one line type replaces the whole mechanism.

The price, and it is real: a box unticked in the web UI comes back on the next pull. Unticking is not monotone, so the union cannot see it. Untick locally, then push.py --update — the body goes up whole and the server follows.

Matching is on the item's text after the domain parser has stripped it and rejoined wrapped lines with single spaces, so rewrapping a long item keeps its tick. Rewording one does not: different text is a different item. The same text twice in a body is read as a set — one ticked local copy ticks every server line with that text.

The parsing is /tea:issue's (issue.checkboxes / issue.set_checkbox), imported, never reimplemented here. The rule itself is map.merge_checkbox_state: pure, and testable without a Gitea anywhere.

Pushing

python3 <skill-base-dir>/scripts/push.py --dry-run     # validate, no network
python3 <skill-base-dir>/scripts/push.py               # every local-only issue
python3 <skill-base-dir>/scripts/push.py wire-sqlc-appclick
python3 <skill-base-dir>/scripts/push.py --update wire-sqlc-appclick   # PATCH

A successful push DELETES the local filetmp/issues/<id>.md and <id>.comments.md — and prints the number and URL the issue now lives at:

created  wire-sqlc-appclick  #42  https://git.noodles.cam/claude-skills/tea/issues/42
    dropped  /repo/tmp/issues/wire-sqlc-appclick.md
    pull.py 42 to work on it again

Once the tracker has the issue, the tracker is the issue. What is left in the store is what has not left this machine. There is no second copy, so there is nothing to reconcile and no "is mine the fresh one?" to answer — see Drift.

--update deletes too. One rule, no exception. A PATCH is a push; an issue that has just been sent is no more local than one that was just created. Edit an issue by pulling it, changing it, pushing it — the copy is gone again after.

What has to be true before anything is deleted

In order, and the delete is last:

  1. the transport returned — tea ran and exited 0 (a non-2xx exits the run), and
  2. the answer is an object carrying a positive integer number, and on --update the same number that was PATCHed (push.confirmed_number), and
  3. .remote.json has been written with number → slug.

Network down, a 422, an empty body, an answer for a different issue: the file is still there and the run stops with the path in the error. An origin: local issue that was not sent — including a local-only dependency that push only read to warn about — is never touched. --dry-run deletes nothing and sends nothing.

How the slug comes back

The slug is the issue's identity and the format promises it is stable for life, so it cannot live only in a file that push is about to delete. Two records, and the durable one is not local:

where survives how
<!-- tea:id wire-sqlc-appclick --> a rename in the web UI, a lost .remote.json, a fresh clone, another machine first line of the tracker-side body; an HTML comment, so Gitea renders nothing
tmp/issues/.remote.json the file being deleted number → slug, written before the delete

pull.py consults the ledger first (it is the one that knows about files on disk right now), then the marker, then falls back to slugifying the title for an issue filed in the web UI that has never had a local name. A marker is only taken at its word when that slug is free — it never overwrites an issue already in the store.

The marker never appears in the local file. map.to_payload puts exactly one at the top on the way up, map.from_api strips every one on the way down. Strip-all-then-prepend-one is the whole mechanism, which is why a body cannot accumulate them however many round trips it makes, and why a body that somehow gained two is cleaned on the next pull.

depends: survives the same round trip through Gitea's native links (below): push writes them, pull.py --deps reads them back, and the ledger turns the numbers into the slugs they had here.

Before anything is sent, /tea:issue's validator runs (exactly one type/*, at most one severity/*, English title with no type prefix, ## Summary / ## Spec / ## Acceptance criteria present). --force posts anyway — say why when you use it.

Dependencies

Issues go up in topological order, dependencies first, and the graph goes up with them. Once an issue has its number, every depends: entry that also has one becomes a native Gitea link, so the tracker shows the blocking panel and refuses to close a blocked issue before its blocker.

The two directions are symmetric, and they use the same endpoint:

direction endpoint
push.py depends: → native links POST …/issues/{n}/dependencies
pull.py --deps native links → depends: GET …/issues/{n}/dependencies

The POST body is Gitea's IssueMeta{"index", "owner", "repo"} naming the blocker, posted to the blocked issue's endpoint ("make the issue in the url depend on the issue in the form"). owner/repo travel with it, so a dependency in another repo links correctly.

  • Topological order means the blocker already has its number — no second pass.
  • A link the tracker already has is skipped: push GETs the existing ones first, so a repeat push is a no-op and a 409 never happens. Should a link fail anyway, it is a warning, not a dead run — the issues are already created.
  • --update carries links that appeared in depends: after the first push.
  • --dry-run prints every link it would make (#? for a number this run has not handed out yet) and makes no request at all.
  • Removing a link is out of scope. Push only adds. A dependency deleted from depends: leaves its Gitea link standing; drop it in the web UI or with tea api -X DELETE …/issues/N/dependencies.

A dependency that is still local-only is reported, not silently dropped: it has no number, so it gets no link. The body's ## Depends on prose is sent verbatim either way — nothing is lost, but the tracker shows no edge until that issue is pushed too.

Missing labels are created with the canonical color and, for type/* and severity/*, exclusive: truetea labels create cannot set that field (tea 0.14.2), so it goes through tea api. Colors live in map.py; the names and their meaning come from the domain taxonomy.

That is per-push and piecemeal: a repo only ever grows the labels its issues happened to use, so filtering by type/bug in the web UI stays impossible until someone pushes a bug. labels.py lays down the whole set — the 11 type/* and severity/* names — in one run:

python3 <skill-base-dir>/scripts/labels.py --dry-run   # the plan, no writes
python3 <skill-base-dir>/scripts/labels.py             # create what is missing

It reads the repo's labels first. An exactly-matching name is never re-created and never patched. A lookalikebug, Bug, type: bug, kind/bug — is reported with its id and left alone: renaming somebody else's label is a decision, not a migration. A color or exclusive that drifted is printed, and changed only under --fix. Running it twice creates nothing. tech/* and comp/* are open-ended by design and stay push-created.

A milestone must already exist in the repo — push attaches, it does not create.

branch: is Gitea's ref, the branch the work actually lives on. Push fills an empty one with the current git branch (git rev-parse --abbrev-ref HEAD) and writes it back into the issue file; a value already there is never overwritten, neither on create nor on --update. On a detached HEAD or outside a git repo no ref is sent and a warning names the issues that went up without one. Reading the branch is the only thing these scripts ask git for — they never check out, create, or write anything.

Evicting what the tracker says is closed

python3 <skill-base-dir>/scripts/evict.py --dry-run    # ask, report, change nothing
python3 <skill-base-dir>/scripts/evict.py              # and remove them
python3 <skill-base-dir>/scripts/evict.py old-thing    # just this one

Eviction itself belongs to /tea:issue (issue_evict.py) and is offline: the decision is state: closed plus an origin: that names a tracker, both read off the file. This script adds one thing in front of it — a state: that is not stale — and then calls that same decision. There is one implementation of "what may be evicted" and it is in the domain.

Why it exists: a local state: is only as fresh as the last pull, so an issue closed in the web UI still reads open here and the offline command correctly leaves it alone. The workaround was pull.py 11 12 13 14 15 — which writes the five closed files back to disk before anything can remove them.

Order of operations, and it is the safety argument:

  1. every candidate's state is fetched — all of them, before anything is removed;
  2. each answer must be an object carrying the number that was asked about and a state the domain recognizes (evict.confirmed_state, the counterpart of push.confirmed_number);
  3. only then does the eviction run.

A failed call evicts nothing — not even the candidates whose answers had already arrived, and no refreshed state: is written back either. Stricter than push, which deletes as it goes, and free: evictions have no order between them, so there is no reason to start before every answer is in.

  • A candidate is an issue carrying a gitea: handle. origin: local has none, is never asked about, and is never removed. An origin: gitea issue whose handle is missing or unparseable cannot be verified — it is reported on stderr and kept.
  • No --repo: the repo comes from each issue's own handle, so a store holding issues from two repos is checked against both.
  • One GET per candidate. The store is a working set that push keeps small, and a wrong answer here deletes a file — so each issue is asked about by its own address rather than inferred from a list a --limit could have truncated.
  • A state that disagrees with the file is written back, so the store stops lying about the issues that stay too. --dry-run makes no writes at all.
  • .remote.json is not pruned; see How the slug comes back — an evicted issue is exactly as findable as a pushed one.
  • pull.py <n> still fetches a closed issue. A number is an address, not a query. A closed issue pulled after an eviction is back on disk, and that is the tracker answering the question it was asked, not a regression.

What crosses the boundary, and what does not

domain Gitea note
id (slug) <!-- tea:id … --> first line of the tracker-side body; stripped out of the local copy
title title verbatim, both directions
body body verbatim up except the marker; verbatim down except the marker and checkbox state, which is unioned
state state same vocabulary
labels labels[] names both ways; ids only on write
assignees assignees[] logins
milestone milestone.title resolved to an id on write
depends native links slugs here, IssueMeta there; push writes them, pull --deps reads them
ref lands in branch:; sent only when non-empty
number, html_url lands in gitea: / url:

depends: is always slugs. The body's ## Depends on section is human prose and is passed through unchanged in both directions: a pull seeds depends: from the #N it finds there, a push never rewrites what the author wrote. A translator that edits prose churns the body on every round trip. The edge the tracker acts on is the native link, not the text — which is exactly why the text can be left alone.

Comments are pull-only in the store: <id>.comments.md is written by pull.py and comment.py, and editing it by hand changes nothing in Gitea.

Drift

There is none tracked, and since push started deleting what it sends there is very little left to track. A published issue has one copy — Gitea's — except while somebody is working on it, and that window closes at the next push. Nothing watches Gitea, nothing reconciles, nothing warns that a synced issue changed upstream. synced: tells you how old your working copy is; remote-updated: what the server said at that moment. Re-pull when it matters, and push when you are done so there is nothing to be stale.

The old question — "I edited this locally, does the server have it, whose text is newer?" — is answered by the store's contents rather than by a mechanism: a file that is here has not been pushed.

Checkbox state is not an exception to this. The union a pull applies reads only the two bodies in front of it — there is no base version, no history, and no way for it to report that anything diverged. One rule for one line type, precisely so the mechanism this section rules out is not needed.

Rich payloads for everything else

Comments and issues are wrapped by the scripts above. For other entities (pulls, releases, PATCHing something these scripts do not cover), entity subcommands like tea pulls create hang on a large or formatted body — an empty-looking positional triggers the $EDITOR fallback on a TTY that does not exist, and the harness eventually kills the process (exit 144 = 128 + SIGURG on macOS). Write the JSON payload to $PWD/tmp/ first and POST it with tea api -d @file. Procedure and endpoint table: /tea:use.

Login

Every tea call made by hand must carry the literal placeholder --login "$GITEA_LOGIN"; the tea-guard hook substitutes the operator's pin. Set it with /tea:auth. Details in /tea:use.