6d01ead245
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>
193 lines
8.6 KiB
Markdown
193 lines
8.6 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_tree.py [id…]` | draw the dependency graph from `depends:` |
|
|
| `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:`, tick checkboxes in
|
|
`## Acceptance criteria`, add ids to `depends:`. Re-run `issue_check.py`
|
|
afterwards, and `issue_index.py` to refresh the table.
|
|
|
|
If the issue is synced (`origin: gitea`), your edit is local until you run
|
|
`push.py --update` from `/tea:sync`. Nothing tracks that drift automatically.
|
|
|
|
## 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.
|
|
|
|
## 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`.
|