fix: resolve the issue store from the project, not the plugin

`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>
This commit is contained in:
naudachu
2026-08-11 13:38:39 +05:00
parent 27e4b6b1da
commit fb5445915f
30 changed files with 1193 additions and 430 deletions
+23 -18
View File
@@ -1,11 +1,11 @@
---
name: sync
description: Move issues between the local store and Gitea — pull issues into tmp/issues/, push local issues up, post comments, close and reopen them. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, comment on one, or close/reopen one. Working with an issue's content (writing, grepping, validating, dependency graph) is /tea:issue and needs no network.
description: Move issues between the local store and Gitea — pull issues into .tea/issues/, push local issues up, post comments, close and reopen them. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, comment on one, or close/reopen 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
One job: translate between `.tea/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.
@@ -43,7 +43,7 @@ there is nothing to pin a second time. No pin anywhere → exit with a pointer t
| Script | What it does |
|---|---|
| `remote.py [--state] [--label] [--milestone] [-q TEXT] [--limit N]` | discovery: one line per Gitea issue to stdout, writes nothing; `--limit` caps the **listing** (default 30) |
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT [--limit N]` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty; follows dependencies by default (`--no-deps` to stop); `--limit` caps what is **stored** (default 100) |
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT [--limit N]` | Gitea → `.tea/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty; follows dependencies by default (`--no-deps` to stop); `--limit` caps what is **stored** (default 100) |
| `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 |
@@ -55,11 +55,16 @@ 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.
`--out` defaults to `issue.ISSUE_ROOT` on every one of them — the domain
layer's `<project root>/.tea/issues`, found by walking up from the working
directory to the nearest `.tea/` marker. 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.
A project with no marker is not a project these scripts will write into: they
stop and name the directories they searched. Run `/tea:issue`'s
`issue_init.py` in it first.
## Identity mapping
@@ -74,7 +79,7 @@ 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 … -->`
outlast it: `.tea/issues/.remote.json` (number → slug) and the `<!-- tea:id … -->`
marker in the issue body on the Gitea side. See [How the slug comes
back](#how-the-slug-comes-back).
@@ -140,7 +145,7 @@ closed issues included. It writes nothing, so there is no write for a limit to
bound — enumeration is its whole job.
**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
thread gets `.tea/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
@@ -226,12 +231,12 @@ 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 file**`tmp/issues/<id>.md` and
**A successful push DELETES the local file**`.tea/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
dropped /repo/.tea/issues/wire-sqlc-appclick.md
pull.py 42 to work on it again
```
@@ -267,7 +272,7 @@ 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 |
| `.tea/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
@@ -348,8 +353,8 @@ changed only under `--fix`. Running it twice creates nothing. `tech/*` and
`comp/*` are open-ended by design and stay push-created.
Labels belong to the repository, not to any issue, so this one runs on a
checkout with no store and leaves it that way — nothing here reads `tmp/issues/`
and nothing creates it. The request bodies go to `tmp/payload/` (below).
checkout with no store and leaves it that way — nothing here reads `.tea/issues/`
and nothing creates it. The request bodies go to `.tea/payload/` (below).
A milestone must already exist in the repo — push attaches, it does not create.
@@ -515,11 +520,11 @@ precisely so the mechanism this section rules out is not needed.
## Rich payloads for everything else
Every body these scripts send is written to `<repo>/tmp/payload/<name>.json`
Every body these scripts send is written to `<project>/.tea/payload/<name>.json`
first and passed as `-d @file`, then kept for a retry or a look at what actually
went up. One gitignored directory for all of them, chosen by the transport and
not by the caller. **It is not a store**: nothing in it is anybody's only copy,
and it is never `tmp/issues/` — a command that touches no issue must not leave
and it is never `.tea/issues/` — a command that touches no issue must not leave
an issue store behind.
Comments and issues are wrapped by the scripts above. For **other** entities
@@ -527,7 +532,7 @@ Comments and issues are wrapped by the scripts above. For **other** entities
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
macOS). Write the JSON payload to `$PWD/.tea/payload/` first and POST it with
`tea api -d @file`. Procedure and endpoint table: `/tea:use`.
## Login
+38 -44
View File
@@ -14,19 +14,20 @@ and the scripts can never disagree about which login a directory runs under. No
script here accepts a login argument: the operator's pin is the only identity
they will use. No pin -> exit with a pointer to /tea:auth.
Also holds the id map (tmp/issues/.remote.json), which pairs a remote key with
Also holds the id map (.tea/issues/.remote.json), which pairs a remote key with
a local slug, and the paths of the store-side files this layer writes. All of
it is transport bookkeeping, not domain data — the domain never reads any of
it, and losing the map still costs a re-pull and not information: the slug it
records also travels in the issue body as `<!-- tea:id … -->` (map.py), so a
pull rebuilds the entry from the tracker. See `rebuild_map`.
Request bodies go to tmp/payload/, which is this module's own scratchpad and
Request bodies go to .tea/payload/, which is this module's own scratchpad and
NOT a store: nothing in it is anybody's only copy, and writing one must never
materialize tmp/issues/ on a checkout that has none. Bootstrapping labels
materialize .tea/issues/ on a project that has none. Bootstrapping labels
touches no issue at all — it used to leave a store behind anyway, because the
request file had nowhere else to live. One directory, every caller, resolved
from this file the way the two domains resolve theirs.
request file had nowhere else to live. One directory, every caller, resolved by
the domain's project walk so the scratchpad and the store cannot land in
different projects.
"""
import datetime
import json
@@ -48,20 +49,25 @@ PAGE_SLACK = 4
# --------------------------------------------------------------------------
# where request bodies land
# --------------------------------------------------------------------------
# Anchored on THIS FILE, like issue.store_root, so every caller — sync,
# whatever comes next — writes to one directory whatever it was invoked from. Visible and top-level under tmp/, not a dotdir hidden
# inside somebody's store, because a scratchpad that looks like store contents
# is how this went wrong the first time. `tmp/` is already gitignored.
PAYLOAD_PARTS = ("tmp", "payload")
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
# git; the agents-sync hook only ever puts one at a repository root.
REPO_MARKERS = (".git", "AGENTS.md")
# A sibling of the store under `.tea/`, never a directory inside it: a
# scratchpad that looks like store contents is how this went wrong the first
# time, when a label bootstrap that touches no issue at all materialized
# `tmp/issues/` on a fresh checkout. Same marker, same walk, one directory for
# every caller — see payload_root below.
_HERE = os.path.dirname(os.path.abspath(__file__))
# The domain owns "which project is this" and this layer imports it rather than
# walking the tree a second time. Two copies of the walk is how the guard and
# the transport once disagreed about a worktree; the same trap, one layer over.
_ISSUE_SCRIPTS = os.path.abspath(
os.path.join(_HERE, os.pardir, os.pardir, "issue", "scripts"))
if _ISSUE_SCRIPTS not in sys.path:
sys.path.append(_ISSUE_SCRIPTS)
import issue as _issue # noqa: E402
PAYLOAD_PARTS = (_issue.MARKER, "payload")
def die(msg, code=1):
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
@@ -76,30 +82,15 @@ def now_iso():
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def repo_root(start):
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None."""
d = os.path.abspath(start)
while True:
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
return d
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
def payload_root(start=None):
"""Absolute path of the request-body scratchpad.
"""Absolute path of the request-body scratchpad, or None with no project.
`start` overrides the anchor so the resolution can be exercised against a
scratch tree. Outside a repository, cwd gets a turn, then the cwd-relative
location stands — made absolute so an error can name the directory it
really wrote to."""
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
root = repo_root(anchor)
if root:
return os.path.join(root, *PAYLOAD_PARTS)
return os.path.abspath(os.path.join(*PAYLOAD_PARTS))
scratch tree. Sibling of the store, under the same marker and resolved by
the same walk: which command wrote a body does not change where it landed,
and neither does which directory it was run from."""
root = _issue.project_root(start)
return os.path.join(root, *PAYLOAD_PARTS) if root else None
PAYLOAD_ROOT = payload_root()
@@ -113,12 +104,13 @@ PAYLOAD_ROOT = payload_root()
# same function. When the two had a copy each, a git worktree got a hook that
# resolved the pin and a transport that did not — in the same directory.
#
# Note the asymmetry with PAYLOAD_ROOT above, and with issue.store_root: those
# are anchored on their own file, this is not, and both are right. 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 a plugin installed
# outside any repository must not answer it from its own directory. See the
# module docstring in pin.py.
# The pin resolves from the working directory upward, and PAYLOAD_ROOT and
# issue.store_root now do the same. They did not always: those two were
# anchored on their own file, on the reasoning that 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. The identity
# layer was right first; the other two followed it. See pin.py's docstring for
# the walk, and issue.py's for what the old anchor cost.
_AUTH_SCRIPTS = os.path.abspath(
os.path.join(_HERE, os.pardir, os.pardir, "auth", "scripts"))
@@ -157,6 +149,8 @@ def api(login, endpoint, method="GET", payload=None, payload_name=None,
if method != "GET":
cmd += ["-X", method]
if payload is not None:
if PAYLOAD_ROOT is None:
die(_issue.no_project_error())
os.makedirs(PAYLOAD_ROOT, exist_ok=True)
path = os.path.join(PAYLOAD_ROOT, "%s.json" % (payload_name or "request"))
with open(path, "w") as f:
@@ -495,7 +489,7 @@ def rebuild_map(root, issues):
Gitea the issue, and — in `<!-- tea:id … -->` — its slug
.remote.json a local number -> slug ledger, a cache of that marker
tmp/issues/*.md whatever happens to be checked out right now
.tea/issues/*.md whatever happens to be checked out right now
Which makes this a MERGE and never a replacement: it starts from what is
already recorded and adds what the remaining files say. What it cannot
+1 -1
View File
@@ -179,7 +179,7 @@ def main():
help="print what would change; makes no request at all")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
root = args.out
+1 -1
View File
@@ -44,7 +44,7 @@ def main():
help="PATCH an existing comment instead of posting a new one")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
root = args.out
+1 -1
View File
@@ -113,7 +113,7 @@ def main(argv=None):
ap.add_argument("--dry-run", action="store_true",
help="ask the tracker and report; write and delete nothing")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args(argv)
root = args.out
+3 -3
View File
@@ -33,9 +33,9 @@ Only repository labels are read; an organization's own labels sit behind a
different endpoint and are neither read nor written.
The issue store is out of scope too, and not incidentally. A label belongs to
the repository, not to any issue, so this command neither reads tmp/issues/ nor
the repository, not to any issue, so this command neither reads .tea/issues/ nor
creates it — the taxonomy it paints comes from the domain MODULE, and the
request bodies it sends go to the transport's own tmp/payload/.
request bodies it sends go to the transport's own .tea/payload/.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
@@ -173,7 +173,7 @@ def main():
base = _gitea.repo_base(args.repo)
# Read first, always: the plan is decided against the repository itself,
# never against tmp/issues/.labels.json. That cache is what makes
# never against .tea/issues/.labels.json. That cache is what makes
# _gitea.ensure_labels cheap for push.py and wrong for a bootstrap — it
# answers "what did we create last time", and the answer here has to be
# "what does the repository have right now".
+1 -1
View File
@@ -127,7 +127,7 @@ def parse_remote_key(key):
# finds it before the prose rather than buried in it.
#
# WHAT THE LOCAL FILE SEES: nothing. `from_api` strips every marker before the
# body is written to disk, so `tmp/issues/<id>.md` holds exactly what the author
# body is written to disk, so `.tea/issues/<id>.md` holds exactly what the author
# wrote — checkbox line numbers, `issue_check.py`, and diffs are all unaffected,
# and the slug is already the file's name, so a copy of it in the body would be
# duplicated state.
+11 -6
View File
@@ -10,7 +10,7 @@ once Gitea has confirmed it, so pulling is not a refresh of 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 the web UI and even on a machine that has never seen the
issue: the slug travels in the body as `<!-- tea:id … -->`, and
tmp/issues/.remote.json indexes it by number. See `id_for` for the order those
.tea/issues/.remote.json indexes it by number. See `id_for` for the order those
are consulted in. The marker itself is stripped out of what is written to disk.
Two ways to name what to pull:
@@ -55,7 +55,7 @@ writes nothing at all, so there is no write to bound and its `--limit` means
what it says — how many lines to print.
Comments ride along by default, in both modes and for every issue written:
the thread lands in tmp/issues/<id>.comments.md, beside the issue. It costs
the thread lands in .tea/issues/<id>.comments.md, beside the issue. It costs
nothing when there is nothing to fetch — the payload already carries the
comment count, so an issue with none makes no request, and a file left over
from an earlier pull is deleted. An absent file therefore means "no comments",
@@ -218,7 +218,7 @@ def main():
help="skip issues already on disk instead of refetching")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
filtered = bool(args.milestone or args.label or args.query)
@@ -229,9 +229,14 @@ def main():
root = args.out
# A first pull into a fresh checkout has to create the store; it says so,
# and the path is absolute, so it cannot be a stray cwd.
if issue.create_store(root):
sys.stderr.write("created store %s\n" % os.path.abspath(root))
# and the path is absolute, so it cannot be a stray cwd. With no project
# marker anywhere there is nowhere legitimate to put one — pulling into a
# guessed directory is what stranded issues inside the plugin.
try:
if issue.create_store(root):
sys.stderr.write("created store %s\n" % os.path.abspath(root))
except issue.StoreMissing as e:
_gitea.die(str(e))
login = _gitea.require_login()
+2 -2
View File
@@ -2,7 +2,7 @@
"""
push.py — local store -> Gitea, and the local copy goes away.
**A successful push deletes `tmp/issues/<id>.md` and `<id>.comments.md`.** Once
**A successful push deletes `.tea/issues/<id>.md` and `<id>.comments.md`.** Once
the tracker has the issue, the tracker IS the issue: what is left in the store
is only what has not left this machine. Get it back with `pull.py <n>` — it
comes back under the same slug, because the slug travelled up in the body as
@@ -223,7 +223,7 @@ def main():
ap.add_argument("--force", action="store_true", help="push despite format violations")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
root = args.out
+1 -1
View File
@@ -45,7 +45,7 @@ def main():
ap.add_argument("--limit", type=int, default=30)
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
login = _gitea.require_login()