feat: evict closed issues from the local store #26

Merged
claude merged 2 commits from feat/evict-closed-issues into main 2026-08-10 13:32:50 +00:00
Collaborator

Closes #20.

What

skills/issue/scripts/issue_evict.py — one command, no rm:

issue_evict.py --dry-run   # what would go; writes nothing at all
issue_evict.py             # every closed issue that is not origin: local
issue_evict.py old-thing   # just this one

It removes <id>.md and every sidecar under that slug (<id>.comments.md
today) for an issue that is state: closed and carries an origin:
naming a tracker, then rebuilds INDEX.md — and only when something actually
went, so a no-op run writes nothing.

origin: local is never evicted

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 kept in
every state, including closed and including when named on the command line —
reported with the reason, never removed. 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.

Which layer owns eviction

The domain (skills/issue), as issue #20 argued it should: the two inputs
are state: and origin:, both domain-owned fields, and the answer is already
on disk. No network, no login, no tea. The domain layer stays stdlib-only —
tests/test_store_path.py::TestLayering still passes.

skills/sync/scripts/evict.py is the bridge form, and it does not own the
decision: a local state: is only as fresh as the last pull, so an issue closed
in the web UI still reads open here. It refreshes state: from Gitea and then
calls issue_evict.run — one implementation of "what may be evicted", in the
layer that owns the fields it reads. Without it the observed workflow needs
pull.py 11 12 13 14 15 first, which writes the five closed files back to disk
before anything can remove them.

The gate, one step earlier than push's

evict.py fetches every candidate's state before removing anything. Each
answer must be an object carrying the number that was asked about and a state
the domain recognizes (confirmed_state, written as the counterpart of
push.confirmed_number). A tea that will not run, a non-2xx, an answer for
another issue, an unknown state: the run stops and nothing is evicted — 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.

One deviation from the acceptance criteria, stated deliberately

The criterion asks that INDEX.md and .remote.json match the directory
after a cleanup. INDEX.md does. .remote.json is deliberately not pruned:
since #19 it is the number → slug ledger, not an index over the files, and its
entries are supposed to outlive the files they name — that is what makes
pull.py <n> land on the same slug after a push deleted the file. An evicted
issue is in exactly the state a pushed one is, so there is nothing to reconcile
and nothing to fix up by hand, which is what the criterion is after.

Docs

  • AGENTS.md — the rule the tracker side never wrote down: pull by number
    fetches an issue in any state, a number is a number.
    Eviction does not
    revoke it; a closed issue pulled after a cleanup is on disk again, and that is
    the tracker answering what it was asked, not a regression. Plus the ownership
    decision and the ledger rule above.
  • skills/issue/SKILL.md, skills/sync/SKILL.md — command tables and a section
    each.
  • skills/issue/references/format.md — the origin: table gains a "what
    eviction does to it" column.
  • agents/tea-runner.md — its "delete nothing" rule now names the two local
    deletions it may cause, and requires --dry-run first.

Tests

tests/test_evict_closed.py, 51 cases, stdlib unittest, temp stores, no
network anywhere. The dangerous half is asserted path by path: origin: local
kept (unnamed, named, with sidecars), open issues kept, --dry-run writing
nothing including INDEX.md, and every tracker failure leaving the whole store
on disk — a raised transport, a real non-2xx through _gitea.api with tea
exiting 1, an answer for another issue, an answer with no state, an empty
answer.

Ran 283 tests in 4.529s

OK
Closes #20. ## What `skills/issue/scripts/issue_evict.py` — one command, no `rm`: ```bash issue_evict.py --dry-run # what would go; writes nothing at all issue_evict.py # every closed issue that is not origin: local issue_evict.py old-thing # just this one ``` It removes `<id>.md` and every sidecar under that slug (`<id>.comments.md` today) for an issue that is `state: closed` **and** carries an `origin:` naming a tracker, then rebuilds `INDEX.md` — and only when something actually went, so a no-op run writes nothing. ## origin: local is never evicted 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 kept in every state, including closed and including when named on the command line — reported with the reason, never removed. 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. ## Which layer owns eviction **The domain** (`skills/issue`), as issue #20 argued it should: the two inputs are `state:` and `origin:`, both domain-owned fields, and the answer is already on disk. No network, no login, no `tea`. The domain layer stays stdlib-only — `tests/test_store_path.py::TestLayering` still passes. `skills/sync/scripts/evict.py` is the bridge form, and it does not own the decision: a local `state:` is only as fresh as the last pull, so an issue closed in the web UI still reads `open` here. It refreshes `state:` from Gitea and then calls `issue_evict.run` — one implementation of "what may be evicted", in the layer that owns the fields it reads. Without it the observed workflow needs `pull.py 11 12 13 14 15` first, which writes the five closed files back to disk before anything can remove them. ## The gate, one step earlier than push's `evict.py` fetches **every** candidate's state before removing anything. Each answer must be an object carrying the number that was asked about and a state the domain recognizes (`confirmed_state`, written as the counterpart of `push.confirmed_number`). A `tea` that will not run, a non-2xx, an answer for another issue, an unknown state: the run stops and nothing is evicted — 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. ## One deviation from the acceptance criteria, stated deliberately The criterion asks that `INDEX.md` **and `.remote.json`** match the directory after a cleanup. `INDEX.md` does. `.remote.json` is deliberately **not** pruned: since #19 it is the number → slug ledger, not an index over the files, and its entries are supposed to outlive the files they name — that is what makes `pull.py <n>` land on the same slug after a push deleted the file. An evicted issue is in exactly the state a pushed one is, so there is nothing to reconcile and nothing to fix up by hand, which is what the criterion is after. ## Docs - `AGENTS.md` — the rule the tracker side never wrote down: **pull by number fetches an issue in any state, a number is a number.** Eviction does not revoke it; a closed issue pulled after a cleanup is on disk again, and that is the tracker answering what it was asked, not a regression. Plus the ownership decision and the ledger rule above. - `skills/issue/SKILL.md`, `skills/sync/SKILL.md` — command tables and a section each. - `skills/issue/references/format.md` — the `origin:` table gains a "what eviction does to it" column. - `agents/tea-runner.md` — its "delete nothing" rule now names the two local deletions it may cause, and requires `--dry-run` first. ## Tests `tests/test_evict_closed.py`, 51 cases, stdlib `unittest`, temp stores, no network anywhere. The dangerous half is asserted path by path: `origin: local` kept (unnamed, named, with sidecars), open issues kept, `--dry-run` writing nothing including `INDEX.md`, and every tracker failure leaving the whole store on disk — a raised transport, a real non-2xx through `_gitea.api` with `tea` exiting 1, an answer for another issue, an answer with no state, an empty answer. ``` Ran 283 tests in 4.529s OK ```
claude added 1 commit 2026-08-10 12:29:21 +00:00
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>
naudachu added 1 commit 2026-08-10 13:32:41 +00:00
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.
claude merged commit f7cffd7c48 into main 2026-08-10 13:32:50 +00:00
Sign in to join this conversation.