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
+67 -33
View File
@@ -64,11 +64,14 @@ the domain layer, it is in the wrong place.
- `scripts/issue_tree.py` — draw the dependency graph
- `scripts/issue_evict.py` — remove closed issues from the store; never an
`origin: local` one
- `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md`
- `scripts/issue_index.py` — rebuild `.tea/issues/INDEX.md`
- `scripts/issue_init.py` — create the `.tea/` marker that makes a directory
a project; migrates an old `tmp/issues` store in, adds `.tea/` to
`.gitignore`. Idempotent, and refuses to pick a winner on a name clash
- `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 here
- `scripts/_gitea.py` — transport: `tea api`, pagination, filters, label ids,
the remote-id map, `tmp/payload/`; the login comes from `auth/pin.py`
the remote-id map, `.tea/payload/`; the login comes from `auth/pin.py`
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
- `scripts/close.py` — the state field, both ways; explicit ids only
- `scripts/evict.py` — refresh `state:` from Gitea, then hand the decision to
@@ -104,19 +107,29 @@ and then — only if that found nothing — up the parent chain of the **main
working tree of any linked worktree** met on the way, reached by reading
`gitdir:` out of a `.git` *file* and following `commondir`.
**The pin is not resolved from `__file__`, and that asymmetry with
`issue.store_root`/`_gitea.PAYLOAD_ROOT` is deliberate.**
Where an installation keeps its files is a fact about the installation; whose
login a project runs under is a fact about the project. A plugin installed
outside any repository and pointed at somebody else's tree must not answer the
second question from its own directory. So the search runs from the working
directory upward — and reaches a worktree's main checkout by asking git.
**Nothing here resolves from `__file__`, and the walk is written once.**
`issue.parents`, `issue.gitdir_of` and `issue.main_worktree` live in the domain
— the layer that depends on nothing, and therefore the only one all three
callers may borrow from — and `pin.py` imports them. The guard, the transport
and the store cannot disagree about a directory, because there is one walk.
Two failures this replaces, both worth remembering: a git worktree is a
*sibling* of the main checkout, so the untracked pin is not on its parent chain
and the whole sync layer died there while `tea` in the same directory worked;
and the cure it invited — `/tea:auth` inside the worktree — writes a second
settings file into a directory that is deleted with the worktree.
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.** A plugin installed outside any repository and pointed at
somebody else's tree must answer both from the tree it was pointed at.
Three failures this replaces, all worth remembering:
- a git worktree is a *sibling* of the main checkout, so the untracked pin is
not on its parent chain, and the whole sync layer died there while `tea` in
the same directory worked;
- the cure that invited — `/tea:auth` inside the worktree — writes a second
settings file into a directory that is deleted with the worktree;
- and the store and the payload root, which *were* anchored on `__file__`,
resolved inside the installed plugin: `~/.claude/plugins/cache/tea/tea/2.0.0/
tmp/issues`, a **versioned** directory. Issues written from one project were
invisible from the next and stranded by every plugin update. Two `origin:
local` files — the only copy of that work, by definition — were found there.
## Tests
@@ -129,21 +142,26 @@ 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/` 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 — and runs the real
scripts inside it as subprocesses. That is the only way to test behavior that depends on where a
**A test never touches `.tea/issues/` or `.tea/payload/`.** Anything that needs
a store builds a throwaway project in a `tempfile.TemporaryDirectory()` — a
`.tea/` marker and 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.
`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.
**The scripts are not copied into the fixture.** They stay where they really
live, and the fixture is somewhere else entirely — that separation *is* the
contract: a plugin is installed in one place and used on projects in another.
The suite used to copy both layers in, which made the two the same directory
and hid the `__file__` bug completely.
**A subprocess fixture strips `CLAUDE_PROJECT_DIR`** unless the test is about
it. It is the first anchor of the walk, so the harness's own value would point
every fixture at this repository.
## Local issue store
`tmp/issues/` (gitignored) holds **two kinds of file, and only one of them is a
`.tea/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
@@ -152,11 +170,25 @@ 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 from `issue.py`'s own
location, not from cwd.** `issue.store_root()` walks up from `__file__` to the
nearest `.git` or `AGENTS.md` — so every script in both layers sees one store
whatever directory it is run from. An explicit `--out` overrides it and is
- **The path is `<project root>/.tea/issues`, where the project root is the
nearest ancestor of the WORKING DIRECTORY holding a `.tea/` marker.**
`issue.project_root()` walks up from `$CLAUDE_PROJECT_DIR`, then cwd — the
same order and the same walk as the login pin, since both answer "which
project is this". Every script in both layers sees one store from anywhere
inside the project; a `cd` into a *different* project answers with that
project's store, which is the point. An explicit `--out` overrides it and is
used exactly as typed; a relative `--out` stays relative to cwd.
- **The marker is created by `issue_init.py`, never inferred.** An operator
states that a directory is a project; nothing guesses it. `.git` was tried as
the marker and is in every clone including this plugin's own — see below.
- **No marker anywhere is an answer, not a fallback.** `store_root()` returns
None and every entry point reports which directories it searched. A store
placed in a plausible-looking directory is the failure this replaces.
- **A linked worktree resolves to the main checkout.** The marker is gitignored,
so a worktree never has one; it is the same project on another branch, and it
reaches the store by the same hop the pin takes. Do not run `issue_init.py`
in a worktree — one project would get two stores, and the second disappears
with the branch.
- Nothing creates the store as a side effect of a write. Readers distinguish
"does not exist" from "is empty"; only `issue_new.py` and `pull.py` create it,
and they say so on stderr.
@@ -177,7 +209,7 @@ line so plain grep works without a parser.
`origin: local` issue 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 in
`tmp/issues/.remote.json`. A rename in the web UI, a lost `.remote.json`, a
`.tea/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
every `depends:` that points at it still resolves.
- `.remote.json` is therefore no longer "an index over the files": it is the
@@ -213,16 +245,18 @@ line so plain grep works without a parser.
## Request payloads
`tmp/payload/` (gitignored) holds the JSON bodies `tea api -d @file` was given,
`.tea/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, resolved from `_gitea.py`'s own location, so
which command wrote a body does not change where it landed. `_gitea.api` takes no directory argument; that it once did
is exactly how a label bootstrap came to create `tmp/issues/`.
- One directory for every caller, a sibling of the store under the same marker
and resolved by the same walk, so which command wrote a body does not change
where it landed — and the scratchpad and the store can never end up in two
different projects. `_gitea.api` takes no directory argument; that it once
did is exactly how a label bootstrap came to create the issue store.
- It is created lazily, by the first write of a run, and only then: a `--dry-run`
or 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/issues` starts lying about what exists.
store, and the operator's `ls .tea/issues` starts lying about what exists.