2f82b501bd
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>
376 lines
14 KiB
Markdown
376 lines
14 KiB
Markdown
# Issue format
|
|
|
|
Canonical format for every issue in this project, whether it ever reaches a
|
|
tracker or not. Designed to be unambiguous for both humans and LLMs: fixed
|
|
English section headers in a fixed order, verifiable acceptance criteria, one
|
|
issue = one deliverable. Source spec: the project wiki
|
|
([Issues-Workflow](https://git.noodles.cam/claude-skills/tea/wiki/Issues-Workflow)).
|
|
|
|
Nothing here depends on Gitea. How these files are mapped onto a tracker is the
|
|
sync layer's business — see `/tea:sync`.
|
|
|
|
## Identity
|
|
|
|
An issue is one file, `tmp/issues/<id>.md`, and `id` is a slug: lowercase
|
|
ASCII, digits, single dashes, derived from the title. **The slug is the
|
|
identity.** It is stable for the life of the issue — a retitled issue keeps its
|
|
slug; an issue pushed to a tracker, deleted locally and fetched back a month
|
|
later keeps it too. Tracker numbers are a foreign key stored in a field, never
|
|
the name of anything.
|
|
|
|
```
|
|
tmp/issues/wire-sqlc-appclick.md
|
|
```
|
|
|
|
A slug never contains a dot, which is how the store tells an issue from the
|
|
files parked beside it (`<id>.comments.md`).
|
|
|
|
Stability is a promise the format makes, so something has to keep it once the
|
|
file is gone. That is the sync layer's problem and its answer is a marker in the
|
|
body — see `/tea:sync`; the domain neither writes nor reads it, and it never
|
|
appears in the file on disk.
|
|
|
|
## Metadata block
|
|
|
|
One field per line, lists inline, so plain `grep` works without a parser:
|
|
|
|
```markdown
|
|
---
|
|
id: wire-sqlc-appclick
|
|
state: open
|
|
labels: [type/task, tech/sql]
|
|
assignees: [naudachu]
|
|
milestone: v0.2
|
|
depends: [migrate-schema]
|
|
wiki: [Simple Chains/Ideas/Chain core]
|
|
origin: gitea
|
|
branch: feat/wire-sqlc
|
|
gitea: claude-skills/tea#42
|
|
remote-updated: 2026-08-09T18:24:01Z
|
|
synced: 2026-08-09T18:40:00Z
|
|
url: https://git.noodles.cam/claude-skills/tea/issues/42
|
|
---
|
|
# Wire sqlc into the appclick repo layer
|
|
|
|
## Summary
|
|
…
|
|
```
|
|
|
|
| Field | Owner | Meaning |
|
|
|---|---|---|
|
|
| `id` | domain | slug; equals the file name |
|
|
| `state` | domain | `open` or `closed` |
|
|
| `labels` | domain | see namespaces below; exactly one `type/*` |
|
|
| `assignees` | domain | logins; may be empty |
|
|
| `milestone` | domain | title, or `none` |
|
|
| `depends` | domain | ids this issue depends on — **the authoritative graph** |
|
|
| `wiki` | domain | page **titles** this issue is written up in; may be empty. Titles, not URLs — a title is a name for a document and stays in this layer, a URL is tracker bookkeeping. `/tea:page` owns what those titles mean; `page_ls.py --titles` prints them |
|
|
| `origin` | domain | `local`, or the name of a tracker this also lives in |
|
|
| `gitea` | sync | the handle in that tracker: `owner/repo#N` |
|
|
| `branch` | sync | the tracker's branch link (Gitea `ref`); push fills an empty one with the current git branch, and never overwrites a filled one |
|
|
| `url`, `synced`, `remote-updated`, `comments` | sync | bookkeeping |
|
|
|
|
Domain fields render first, in the order above; sync fields follow, sorted.
|
|
|
|
`origin` is domain-owned on purpose: *whether* a piece of work exists anywhere
|
|
but here is a fact about the work. *Where* that is, and how to reach it, is the
|
|
sync layer's business — the domain carries `gitea:` and the rest through
|
|
load/save verbatim and never reads them. That passthrough is why one file can
|
|
represent a local issue and a synced one without a second format.
|
|
|
|
`origin: local` is a **complete state, not a pending one.** An issue that never
|
|
leaves this machine is valid and finished work; pushing it is optional and
|
|
nothing here treats it as a draft.
|
|
|
|
It is not a *permanent* state, and it is what the file's fate depends on:
|
|
|
|
| `origin:` | what the file is | what a push does to it | what eviction does to it |
|
|
|---|---|---|---|
|
|
| `local` | the issue itself — the only copy there is | creates it in the tracker, then deletes the file | **nothing, ever** — in any state, named or not |
|
|
| a tracker | a working copy of something the tracker already has | updates the tracker, then deletes the file | removes it once `state: closed` |
|
|
|
|
**A successful push deletes `tmp/issues/<id>.md`** (and `<id>.comments.md`), on
|
|
create and on `--update` alike. What is in the store is what has not left this
|
|
machine; everything else is fetched again when it is needed. The rule, its
|
|
safety conditions, and how the slug survives are `/tea:sync`'s to state.
|
|
|
|
**A closed issue is evicted from the store** by `issue_evict.py` — same trade,
|
|
one condition more: the work is done *and* it exists somewhere else. An
|
|
`origin: local` issue is never evicted, because there is nowhere to fetch it
|
|
back from. The store is a working set, not an archive; `pull.py <n>` fetches a
|
|
closed issue again whenever it is wanted.
|
|
|
|
The `id` never changes across that round trip, which is why `depends:` in other
|
|
issues keeps working. That is the format's promise; the mechanism is not.
|
|
|
|
## Language rules
|
|
|
|
- **Issue title**: English, imperative mood, no type prefix — the type lives in
|
|
the label, not the title. Good: `Fix tea-guard crash on empty settings file`.
|
|
Bad: `fix: crash`, `[bug] crash`, `Крашится гвард`.
|
|
- **Section headers**: the exact English literals below, as `##` headings, in
|
|
the given order. Do not translate, rename, or reorder them.
|
|
- **Body prose** (text inside sections): Russian.
|
|
|
|
## Label namespaces
|
|
|
|
Four namespaces classify an issue. Two are exclusive (at most one label from
|
|
the namespace), two are free-form:
|
|
|
|
| Namespace | Exclusive | Purpose |
|
|
|---|---|---|
|
|
| `type/*` | yes | What kind of work; primarily its business value. Mandatory, exactly one. |
|
|
| `severity/*` | yes | Business impact. At most one; apply when the impact is known. |
|
|
| `tech/*` | no | Technology the issue is bound to. Any number. |
|
|
| `comp/*` | no | System component of this repo. Any number; no preset — project-specific. |
|
|
|
|
### `type/*` — mandatory, exactly one
|
|
|
|
| Label | Meaning |
|
|
|---|---|
|
|
| `type/bug` | Something behaves incorrectly in existing code |
|
|
| `type/task` | Implementation of new functionality |
|
|
| `type/refactor` | Internal restructuring: file moves, architecture; behavior must not change |
|
|
| `type/test` | Writing or fixing tests |
|
|
| `type/feature` | Container: several issues delivering one unit of business value |
|
|
| `type/draft` | Idea captured for later; not ready for work |
|
|
|
|
### `severity/*` — at most one
|
|
|
|
`severity/low`, `severity/medium`, `severity/high`, `severity/showstopper`,
|
|
`severity/critical`.
|
|
|
|
### `tech/*` — any number
|
|
|
|
Technology-bound labels, e.g. `tech/sql` (pgx, sqlc, sql-migrate — persistent
|
|
storage), `tech/obs` (grafana, loki, prometheus, alloy — observability),
|
|
`tech/postgres`.
|
|
|
|
### `comp/*` — any number
|
|
|
|
Components of this repo's system, e.g. `comp/appclick`. No preset list —
|
|
derive from the project.
|
|
|
|
> Label **colors** are not part of the format: a hex code is how a tracker
|
|
> paints a chip, not what an issue is. They live in `skills/sync/scripts/map.py`
|
|
> and are applied on push.
|
|
|
|
## Dependencies
|
|
|
|
`depends:` in the metadata block is the graph, and it holds **ids**:
|
|
|
|
```markdown
|
|
depends: [migrate-schema, add-pool-cfg]
|
|
```
|
|
|
|
An optional `## Depends on` section, placed right after `## Spec`, carries the
|
|
human explanation — one reference per line, with a reason where it helps:
|
|
|
|
```markdown
|
|
## Depends on
|
|
- migrate-schema — нужна схема БД из этого issue
|
|
- add-pool-cfg
|
|
```
|
|
|
|
The section is prose and is passed to and from a tracker unchanged; only
|
|
`depends:` is walked when the graph is computed. Keeping them consistent is on
|
|
you — `issue_check.py` warns when the section names an id that `depends:` does
|
|
not list. Omit the section when there are no dependencies; never write an empty
|
|
one.
|
|
|
|
A `type/feature` container writes the same relation under `## Issues` instead
|
|
(see the template below). Same direction, same rule: every id named there also
|
|
belongs in that issue's `depends:`. The warning names whichever of the two
|
|
sections the reference actually came from.
|
|
|
|
Draw the graph with `issue_tree.py`. The reverse direction is a grep:
|
|
|
|
```bash
|
|
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md
|
|
```
|
|
|
|
## Shared rules
|
|
|
|
- `## Summary` is always the first section; `## Acceptance criteria` is always
|
|
present (exception: `type/draft`). These two are the anchors every reader
|
|
(human or LLM) relies on.
|
|
- `## Spec` is **mandatory in every type**. Its value is a repo path
|
|
(`docs/specs/auth.md`), a URL, or the literal `none` when no spec exists.
|
|
Never omit the section and never invent a link — `none` is an explicit,
|
|
valid answer.
|
|
- Acceptance criteria are `- [ ]` checkboxes; each item is an objectively
|
|
checkable condition, not an aspiration.
|
|
- A checkbox is **item markup, not a property of one section**: `- [ ]`
|
|
unticked, `- [x]` ticked, and it means the same under `## Issues` as under
|
|
`## Acceptance criteria`. An item that wraps continues on an indented line
|
|
and is still one item. A `- [ ]` inside a ``` code fence is an example of the
|
|
markup, not state. Tick them with `issue_ac.py`, which reads the whole body
|
|
on exactly these rules and rewrites one character; progress (`3/7`) is
|
|
counted off the body and is never a metadata field.
|
|
- Code references use the `path/file.ext:line` form; related issues by id.
|
|
- Screenshots are allowed but their content must be duplicated as text — an
|
|
LLM reading these files cannot see images.
|
|
- If acceptance criteria grow past ~5 unrelated items, split the issue (or
|
|
promote it to a `type/feature` container with child issues).
|
|
|
|
## Template: `type/bug`
|
|
|
|
```markdown
|
|
## Summary
|
|
Что сломано и где проявляется, одно-два предложения.
|
|
|
|
## Spec
|
|
`docs/specs/auth.md`, URL — или `none`.
|
|
|
|
## Steps to reproduce
|
|
1. …
|
|
2. …
|
|
|
|
## Expected
|
|
Что должно было произойти.
|
|
|
|
## Actual
|
|
Что происходит на самом деле: вывод команды, лог.
|
|
|
|
## Environment
|
|
Только релевантное: версии, ОС, конфигурация.
|
|
|
|
## Acceptance criteria
|
|
- [ ] баг не воспроизводится по шагам выше
|
|
- [ ] добавлена проверка на регрессию (если применимо)
|
|
```
|
|
|
|
## Template: `type/task`
|
|
|
|
```markdown
|
|
## Summary
|
|
Что нужно сделать, одно-два предложения.
|
|
|
|
## Spec
|
|
Ссылка или `none`.
|
|
|
|
## Motivation
|
|
Какую проблему пользователя/системы это решает.
|
|
|
|
## Acceptance criteria
|
|
- [ ] проверяемое условие
|
|
- [ ] …
|
|
|
|
## Constraints
|
|
Что НЕ входит в объём; технические рамки. (опционально)
|
|
```
|
|
|
|
## Template: `type/refactor`
|
|
|
|
```markdown
|
|
## Summary
|
|
Что перестраиваем и в каких файлах (`path/file:line`).
|
|
|
|
## Spec
|
|
Ссылка или `none`.
|
|
|
|
## Motivation
|
|
Чем плохо текущее состояние: дублирование, связность, читаемость.
|
|
|
|
## Invariants
|
|
Что НЕ должно измениться: поведение, публичные API, форматы данных.
|
|
|
|
## Acceptance criteria
|
|
- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …)
|
|
```
|
|
|
|
## Template: `type/test`
|
|
|
|
```markdown
|
|
## Summary
|
|
Что покрываем тестами и где (`path/file:line`).
|
|
|
|
## Spec
|
|
Ссылка или `none`.
|
|
|
|
## Motivation
|
|
Зачем: регрессия после бага, пробел в покрытии, флаки-тест.
|
|
|
|
## Test cases
|
|
- сценарий → ожидаемый результат
|
|
- …
|
|
|
|
## Acceptance criteria
|
|
- [ ] перечисленные кейсы покрыты и зелёные
|
|
- [ ] тесты проходят в CI
|
|
```
|
|
|
|
## Template: `type/feature`
|
|
|
|
A container: one unit of business value delivered by several child issues.
|
|
Child issues carry their own `type/*` (`task`, `bug`, `test`, …) and know
|
|
nothing about the container.
|
|
|
|
**The container depends on its children, never the reverse.** Every child id
|
|
goes in the container's own `depends:` and, as prose, in its `## Issues`
|
|
section; a child's `depends:` is for that child's real dependencies and must
|
|
not point back at the container. Keep implementation detail in the children;
|
|
the feature body stays at business level.
|
|
|
|
That direction is not a convention picked at random. "The container is closed
|
|
when its children are closed" *is* a dependency relation. "This child belongs
|
|
to that feature" is a membership relation, and membership has no place in a
|
|
dependency graph. Pointed the other way the two rules contradict each other:
|
|
the moment the container listed a child that already depended on it,
|
|
`issue_check.py` would report `ERROR cycle`. With the edge going down, the
|
|
graph reads as nesting — `issue_tree.py` draws the container as the root with
|
|
its children beneath it — and the check is green.
|
|
|
|
So the container's metadata block carries the children:
|
|
|
|
```markdown
|
|
depends: [wire-sqlc-appclick, add-pool-cfg]
|
|
```
|
|
|
|
and its body repeats them for a human:
|
|
|
|
```markdown
|
|
## Summary
|
|
Бизнес-ценность одним-двумя предложениями.
|
|
|
|
## Spec
|
|
Ссылка или `none`.
|
|
|
|
## Motivation
|
|
Какую проблему пользователя/системы это решает.
|
|
|
|
## Issues
|
|
- [ ] wire-sqlc-appclick — краткое описание части
|
|
- [ ] add-pool-cfg — краткое описание части
|
|
|
|
## Acceptance criteria
|
|
- [ ] все дочерние issues закрыты
|
|
- [ ] проверяемое условие уровня фичи (например, e2e-сценарий работает)
|
|
```
|
|
|
|
## Template: `type/draft`
|
|
|
|
A parking spot for ideas that are not fleshed out yet. Minimal structure, no
|
|
acceptance criteria required. Before implementation starts, a draft MUST be
|
|
promoted: relabeled to a concrete type and rewritten into that type's template.
|
|
|
|
```markdown
|
|
## Summary
|
|
Идея одним-двумя предложениями.
|
|
|
|
## Spec
|
|
Ссылка или `none` (для драфтов обычно `none`).
|
|
|
|
## Notes
|
|
Свободные заметки: что известно, открытые вопросы, варианты.
|
|
```
|
|
|
|
## Containers beyond `type/feature`
|
|
|
|
- **Milestone** — a set of issues with an optional time bound. Locally it is
|
|
just the `milestone:` field; a tracker-side milestone must already exist for
|
|
a push to attach the issue to it.
|
|
- **Project** — a set of issues tracked by status columns (Backlog, ToDo,
|
|
InProgress, Ready, Done). Not represented in this format and not reachable
|
|
through the Gitea API — web UI only.
|