# 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/.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 (`.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. Set it with `issue_new.py --wiki ""` (repeatable) or by editing the line. The tracker has no field for it, so it is never sent — and a pull, which merges nothing but checkbox state, does not bring it back | | `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.