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
+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()