83f73c5cea
tea and tdl were two repositories, each carrying its own
.claude-plugin/marketplace.json — two marketplaces to register for what
is one collection. Fold them into one.
The repo root is now the marketplace and nothing else: a single
.claude-plugin/marketplace.json whose entries point at ./plugins/tea and
./plugins/tdl. A plugin's root is its own directory under plugins/, so
${CLAUDE_PLUGIN_ROOT} still resolves inside it and every path a plugin
uses stays relative to itself — the hooks and the test roots needed no
adjustment beyond the move.
tea's files move with git mv, so its history and blame follow. tdl
arrives as a plain copy; its history stays in claude-skills/threedotslab.
test_payload_root asserted `tmp/` was ignored by REPO/.gitignore. The
rule is that tmp/ is ignored, not which file says so, and git reads every
.gitignore on the way up — so the test now walks up to the repo root the
same way git does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
279 lines
13 KiB
Markdown
279 lines
13 KiB
Markdown
---
|
|
name: issue
|
|
description: Work with this project's issues as units of work — create, read, grep, validate, and walk their dependency graph. Entirely offline; issues are local markdown files and need no tracker. Load when the user asks to file/create an issue, read or find issues, check an issue against the format, or see what depends on what. For pushing to or pulling from Gitea, load /tea:sync instead.
|
|
---
|
|
|
|
# /tea:issue — issues as units of work
|
|
|
|
An issue is a markdown file in `tmp/issues/`. This skill covers everything you
|
|
do **with** an issue: writing one, reading one, checking it against the
|
|
canonical format, and walking the dependency graph.
|
|
|
|
**Nothing here touches the network.** No `tea`, no Gitea, no login. An issue
|
|
that lives only on this machine is a first-class issue, not a draft waiting to
|
|
be uploaded. Synchronizing with a tracker is a separate, optional layer —
|
|
`/tea:sync`.
|
|
|
|
Read [`references/format.md`](references/format.md) before creating or editing
|
|
an issue. It is the single source of truth for identity, metadata, types,
|
|
labels, templates, and language rules.
|
|
|
|
## Identity: the slug
|
|
|
|
The file name is the id and the id is a slug — `tmp/issues/wire-sqlc-appclick.md`.
|
|
It never changes, not when the title changes and not when the issue is pushed
|
|
somewhere. Tracker numbers live in a metadata field (`gitea: owner/repo#42`),
|
|
never in a file name and never in `depends:`.
|
|
|
|
Consequence worth internalizing: **`#42` means nothing in this layer.** Refer to
|
|
issues by id.
|
|
|
|
## Scripts
|
|
|
|
All offline, all in `<skill-base-dir>/scripts/`.
|
|
|
|
| Script | What it does |
|
|
|---|---|
|
|
| `issue_new.py --type T --title "…"` | create `tmp/issues/<slug>.md` from the type's template |
|
|
| `issue_check.py [id…]` | validate against the canonical format; exit 1 on errors |
|
|
| `issue_ac.py <id> [--check N\|TEXT]` | list the body's checkboxes; tick or untick one |
|
|
| `issue_tree.py [id…]` | draw the dependency graph from `depends:` |
|
|
| `issue_evict.py [id…] [--dry-run]` | remove closed issues from the store; **never** an `origin: local` one |
|
|
| `issue_index.py` | rebuild `tmp/issues/INDEX.md` |
|
|
| `issue.py` | the domain module the others import — not a command |
|
|
|
|
```
|
|
tmp/issues/INDEX.md table of every issue — read this first
|
|
tmp/issues/wire-sqlc-appclick.md metadata block + `# Title` + body
|
|
tmp/issues/wire-sqlc.comments.md comment thread (written by /tea:sync only)
|
|
tmp/issues/tree-<id>.md saved graph (issue_tree.py --write)
|
|
```
|
|
|
|
## Where the store is
|
|
|
|
`<repo root>/tmp/issues` — **not** `tmp/issues` relative to wherever you are
|
|
standing. The scripts resolve it by walking up from their own file to the
|
|
nearest `.git` or `AGENTS.md`, so they all see one store no matter which
|
|
directory you run them from, and a `cd` earlier in the session changes nothing.
|
|
|
|
`--out` overrides that and is taken **literally**: an absolute path is used as
|
|
given, a relative one stays relative to the current directory. Nothing rewrites
|
|
what you typed.
|
|
|
|
Two things follow, and both are deliberate:
|
|
|
|
- A store that is not there reports `does not exist`; a store with no issues in
|
|
it reports `is empty`. They are different problems.
|
|
- No script conjures a store as a side effect of writing. Only `issue_new.py`
|
|
creates one — the first issue in a fresh checkout — and it says so on stderr.
|
|
|
|
## Reading: grep, don't parse
|
|
|
|
Metadata is one field per line with inline lists precisely so plain `grep`
|
|
works. `INDEX.md` first, then the files:
|
|
|
|
```bash
|
|
grep -l 'labels:.*type/bug' tmp/issues/*.md # all bugs
|
|
grep -l 'origin: local' tmp/issues/*.md # never pushed anywhere
|
|
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it
|
|
grep -A3 '## Acceptance criteria' tmp/issues/wire-*.md
|
|
grep -c '^- \[ \]' tmp/issues/wire-sqlc-appclick.md # open checkboxes
|
|
```
|
|
|
|
Read whole files only for the issues the task actually needs.
|
|
|
|
## Creating an issue
|
|
|
|
1. **Read the format**: [`references/format.md`](references/format.md).
|
|
2. **Pick the type** — `bug`, `task`, `refactor`, `test`, `feature` (a
|
|
container for several issues with one business value), or `draft` (an idea
|
|
not ready for work). If it is not obvious from the request, ask the user
|
|
(one question).
|
|
3. **Scaffold it:**
|
|
```bash
|
|
python3 <skill-base-dir>/scripts/issue_new.py \
|
|
--type task --title "Wire sqlc into the appclick repo layer" \
|
|
--label tech/sql --label comp/appclick --depends migrate-schema
|
|
```
|
|
English imperative title with no type prefix; `--depends` takes ids.
|
|
4. **Fill the sections** with Edit — every section of the template present and
|
|
in order, headers English, prose Russian. `## Spec` gets a repo path, a URL,
|
|
or the literal `none`; ask the user if you cannot determine which.
|
|
5. **Check it:**
|
|
```bash
|
|
python3 <skill-base-dir>/scripts/issue_check.py wire-sqlc-appclick
|
|
```
|
|
|
|
One file = one issue. Several related issues = several files, linked through
|
|
`depends:`.
|
|
|
|
The issue is now real and complete. Publishing it to Gitea is a separate
|
|
decision — `/tea:sync` — and does not change the file's status here.
|
|
|
|
## Editing an issue
|
|
|
|
Edit the file. Change `state:` to close it, edit `labels:`, add ids to
|
|
`depends:`. Re-run `issue_check.py` afterwards, and `issue_index.py` to refresh
|
|
the table. Checkboxes are the exception — use `issue_ac.py`, below.
|
|
|
|
If the issue is synced (`origin: gitea`), the file is a working copy: your edit
|
|
is local until you run `push.py --update` from `/tea:sync`, and that push
|
|
**deletes the file** once Gitea has it. Closing one of those is `close.py` from
|
|
`/tea:sync` — it moves the state on both sides in a single run; editing
|
|
`state:` here alone would only ever tell this machine. Nothing tracks drift, and with one copy
|
|
at a time there is little to track — a file that is still here has not been
|
|
pushed. Get it back with `pull.py <n>`; the slug does not change.
|
|
|
|
## Ticking checkboxes
|
|
|
|
A checkbox is the one part of a body that is **state** and not prose, so it has
|
|
a command of its own. Never rewrite a body just to tick a box: the rewrite
|
|
re-flows lines and re-words sentences, and the issue's diff swells around a
|
|
change that means one character.
|
|
|
|
```bash
|
|
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick
|
|
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --check 3
|
|
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --check "регресс"
|
|
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --uncheck 3
|
|
```
|
|
|
|
With no flag it prints the numbered list with each item's state, grouped by the
|
|
heading the item sits under. `--check` / `--uncheck` take that number or a
|
|
substring of the item's text (case-insensitive).
|
|
|
|
- **Every checkbox in the body counts, not just `## Acceptance criteria`.** A
|
|
`type/feature` keeps its children as checkboxes under `## Issues`, and they
|
|
are numbered in the same list. The script is named after the section most
|
|
boxes live in, nothing more.
|
|
- **A substring must match exactly one item.** Two matches is an error that
|
|
lists them; pick by number instead. It never guesses.
|
|
- **Exactly one character of the file changes.** Metadata, wording, wrapping
|
|
and trailing whitespace all come back byte for byte, so `git diff` and the
|
|
tracker's diff show the tick and nothing else.
|
|
- Examples inside a ``` fence are markup, not state — they are skipped.
|
|
- `INDEX.md` gains a `progress` column (`3/7`, blank when the issue has no
|
|
boxes), recomputed from the body on every build and stored in no field.
|
|
`issue_ac.py` rebuilds the index after a successful tick.
|
|
|
|
Getting the tick to the tracker is a separate step — `push.py --update` in
|
|
`/tea:sync`.
|
|
|
|
## Writing a proper description
|
|
|
|
Issues get filed on the run — "comments aren't pulled", "the guard broke".
|
|
That is a request, not a statement of work: no reproduction steps, no
|
|
`path/file:line`, acceptance criteria nobody can check. Rewriting one into the
|
|
canonical format is a procedure, not improvisation.
|
|
|
|
1. **Read the issue whole**, and everything it points at — the ids in
|
|
`depends:`, the `## Spec` target, the files it names.
|
|
2. **Determine the type and its template.** The `type/*` label selects one of
|
|
the templates in [`references/format.md`](references/format.md), and that
|
|
template's section list is the shape you are aiming at. If the label is
|
|
missing or wrong, decide it now and fix `labels:`; promoting a `type/draft`
|
|
to a concrete type is this same step.
|
|
3. **Locate the anchor points in the code.** Grep the repo for every file,
|
|
symbol, command, and error string the issue mentions, until you can name
|
|
lines:
|
|
```bash
|
|
grep -rn 'GITEA_LOGIN' hooks/ skills/
|
|
```
|
|
Work that does not exist yet still has anchor points — the files the change
|
|
will land in, and the ones that will call it.
|
|
4. **Gather the missing context.** What has to be there when you are done:
|
|
- code references in the `path/file.ext:line` form, for every place the
|
|
change lands;
|
|
- reproduction steps — exact commands and their real output (`type/bug`
|
|
splits them across `## Steps to reproduce` / `## Expected` / `## Actual`);
|
|
- acceptance criteria that are objectively checkable: a command that exits
|
|
0, a file that exists, a section that is present — not aspirations;
|
|
- a real value for `## Spec` — a repo path, a URL, or the literal `none`.
|
|
|
|
**A missing fact is either found in the repository or becomes a question to
|
|
the user. Inventing one is forbidden.** Ask in one batch, and keep `none` in
|
|
`## Spec` as the legitimate answer it is — never a plausible-looking link.
|
|
5. **Rewrite the sections** with Edit: every section of the template, in the
|
|
template's order, English headers and Russian prose. Replace the body; do
|
|
not append a second telling of the same issue below the old one.
|
|
6. **Check it:**
|
|
```bash
|
|
python3 <skill-base-dir>/scripts/issue_check.py wire-sqlc-appclick
|
|
```
|
|
Errors mean malformed, warnings mean the type's template is not fully
|
|
filled in. Re-run `issue_index.py` if the labels changed.
|
|
|
|
The procedure is identical for `origin: local` and `origin: gitea` — it works
|
|
on `tmp/issues/<id>.md`, and this layer does not know the difference. Getting
|
|
the rewritten body into the tracker is a separate decision — `push.py --update`
|
|
in `/tea:sync` — and is no part of this.
|
|
|
|
## Evicting closed issues
|
|
|
|
The store is a working set, not an archive. A closed issue is not a unit of
|
|
work any more, and one command takes it out — no `rm`, no rebuilding `INDEX.md`
|
|
by hand:
|
|
|
|
```bash
|
|
python3 <skill-base-dir>/scripts/issue_evict.py --dry-run # what would go
|
|
python3 <skill-base-dir>/scripts/issue_evict.py # every closed one
|
|
python3 <skill-base-dir>/scripts/issue_evict.py old-thing # just this one
|
|
```
|
|
|
|
Two conditions, both read off the file, and the second one is the whole safety
|
|
argument:
|
|
|
|
| `state:` | `origin:` | what eviction does |
|
|
|---|---|---|
|
|
| `closed` | a tracker | removes `<id>.md` and every sidecar under that slug |
|
|
| `closed` | `local` | **keeps it, always**, and says why |
|
|
| `open` | anything | keeps it |
|
|
|
|
**`origin: local` is never evicted, in any state, not even when you name it on
|
|
the command line.** That file *is* the issue; there is no copy to fetch back.
|
|
Only a file whose own metadata says the work lives somewhere else may go — the
|
|
same trade `push.py` makes when it drops a file the tracker just confirmed.
|
|
|
|
- `--dry-run` prints what would go and writes nothing at all, `INDEX.md`
|
|
included.
|
|
- `INDEX.md` is rebuilt afterwards, so the table and the directory agree. It is
|
|
rebuilt only when something was actually removed.
|
|
- `.remote.json` is **not** pruned, deliberately: it is the number → slug
|
|
ledger, 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). An evicted issue
|
|
is in exactly the state a pushed one is.
|
|
- **This is not a one-off migration.** `pull.py <n>` fetches an issue in any
|
|
state — a number is an address, not a query — so a closed issue pulled after
|
|
an eviction lands on disk again. Not a regression: evict it again when you are
|
|
done reading it.
|
|
|
|
This command is offline and decides from `state:` in the file, which is only as
|
|
fresh as the last pull. To have the tracker's answer instead — an issue closed
|
|
in the web UI five minutes ago — use `/tea:sync`'s `evict.py`, which refreshes
|
|
`state:` first and then calls exactly this decision.
|
|
|
|
## Dependency graph
|
|
|
|
`depends:` is the authoritative edge list; the body's `## Depends on` section
|
|
is prose for humans. `issue_check.py` warns when they disagree.
|
|
|
|
```bash
|
|
python3 <skill-base-dir>/scripts/issue_tree.py # all roots
|
|
python3 <skill-base-dir>/scripts/issue_tree.py wire-sqlc-appclick --write
|
|
```
|
|
|
|
A `type/feature` plus its children read as one document: draw the tree once for
|
|
the shape, then grep the files.
|
|
|
|
## Layering rule
|
|
|
|
This skill must keep working with `skills/sync/` deleted. Every import under
|
|
`scripts/` is stdlib, and `subprocess` is not among them:
|
|
|
|
```bash
|
|
grep -rhn '^import\|^from' skills/issue/scripts/ | sort -u
|
|
```
|
|
|
|
If you find yourself wanting a tracker concept here — an issue number, a login,
|
|
an HTTP call — it belongs in `/tea:sync`.
|