Commit Graph

5 Commits

Author SHA1 Message Date
naudachu e6b4cf773c Merge origin/main into feat/evict-closed-issues
Three doc conflicts, all unions: the script lists in AGENTS.md and the runner
gain both close.py and evict.py, and the sync skill keeps both the closing and
the evicting sections. Rule 4 of the runner is rewritten once to carry both
halves — closing is now a script it may run on named ids, retitling and remote
deletion stay forbidden, and the two allowed local deletions (push's own, and
eviction) are listed together.
2026-08-10 18:32:39 +05:00
naudachu 1d7abc11ae fix: resolve the login pin from a git worktree
`_gitea.require_login` walked up from CWD and nowhere else. A worktree is a
sibling of the main checkout, not a descendant, and `settings.local.json` is
untracked — so the pin lives in the main checkout only, is not on the
worktree's parent chain, and the whole tracker half of the plugin died there
with "no login pinned". In the same directory the guard resolved it fine,
because it had a search of its own: one order, written twice, disagreeing.

It is written once now, in skills/auth/scripts/pin.py, and both callers import
it — the transport and hooks/tea-guard.sh. $CLAUDE_PROJECT_DIR, then a hint the
caller supplies (the hook passes its payload's cwd), then the current
directory; each searched up its parent chain, and only if that finds nothing,
across into the main working tree of a linked worktree met on the way, reached
by reading `gitdir:` out of the `.git` FILE and following `commondir`. No
subprocess — a PreToolUse hook runs before every Bash call and must not fork to
answer this.

The search still starts at the working directory and never at `__file__`,
deliberately asymmetric with `issue.store_root` and `_gitea.PAYLOAD_ROOT`.
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 pointed at
somebody else's tree must not answer that from its own directory. pin.py says
so in as many words, so the next reader does not "fix" the inconsistency.

Two consequences fall out of it. `/tea:auth` no longer has any reason to run
inside a worktree, so no second pin lands in a directory that is deleted with
the branch — the skill now says to write it beside the common `.git`. And the
scripts can run where the work is: the workaround the bug forced, cwd in the
main checkout, made push.py send that checkout's branch as `ref`, which is the
one thing `branch:` exists to record.

tests/test_login_pin.py holds both halves: the hop against a hand-built layout
and against a real `git worktree add`, a run from the worktree finding the
login, no pin anywhere still erroring, the scripts' own directory not becoming
a source, `ref` coming out as the worktree's branch, and the hook and a script
answering the same directory alike. Two mechanical checks keep the callers from
growing a second copy of the walk. Three existing fixtures now copy
skills/auth/scripts, which the transport imports.

Refs #24.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 18:14:18 +05:00
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
naudachu 47f53a7edc test: check the domain layer against sys.stdlib_module_names
The layering test pinned an exact import set, so any new stdlib import in
skills/issue tripped it — 'collections', added by issue_ac.py, did. Assert
the rule AGENTS.md actually states (stdlib only, never subprocess) instead
of a frozen snapshot of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:43:24 +05:00
naudachu 6d01ead245 fix: resolve the issue store path independently of the working directory
ISSUE_ROOT was the relative `tmp/issues`, so "the store" was whatever
directory the shell happened to be standing in. It is the --out default
in all eight scripts of both layers, which made one `cd` — and a `cd`
outlives the command that ran it — enough for readers to report an empty
store on a full one and for writers to quietly build a second store
beside the first. `issue_index.py` run from inside tmp/issues left
tmp/issues/tmp/issues/ behind and exited 0.

The anchor is issue.py's own __file__, not cwd. A script's location is a
fact about the installation; cwd is a fact about the last `cd`, and the
scripts are invoked by path from wherever the agent happens to be. From
there `store_root()` walks up to the nearest repo marker — `.git`
(exists(), not isdir(): a worktree's .git is a file) or AGENTS.md for a
copy taken out of git — and joins tmp/issues. Markers rather than a
fixed number of `..` hops, because the layout is not a promise. cwd is
tried only if the scripts are not inside a repository at all.

The function lives in the domain layer and skills/sync imports it, so
both layers agree by construction — the direction the layering rule
allows. skills/issue stays stdlib-only.

An explicit --out still wins and is used exactly as typed: a relative
--out stays relative to cwd, because that is what the operator asked
for. No new environment surface.

Two consequences the issue also asked for:

- Missing is no longer reported as empty. `store_error()` returns one
  message for a path that is not there and another for a store with no
  issues in it.
- Nothing conjures a store as a side effect of a write. save() and
  issue_index.build() require it instead of os.makedirs'ing it; only
  issue_new.py and pull.py create one, and both say so on stderr.

Establishes tests/ — plain stdlib unittest, no pytest, no dependencies.
The store tests build a throwaway repo in a TemporaryDirectory (a .git
marker, a copy of both script layers, fixture issues) and run the real
scripts inside it as subprocesses from five different working
directories; tmp/issues/ is never touched. Against the pre-fix scripts
15 of the 21 fail, reproducing the report exactly — five stray stores,
including tmp/issues/tmp/issues.

    python3 -m unittest discover -s tests -v

Closes claude-skills/tea#15

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