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>
114 lines
5.4 KiB
Markdown
114 lines
5.4 KiB
Markdown
# AGENTS.md
|
|
|
|
## Project goals
|
|
|
|
1. **Unify and systematize issue workflow** for the development team with
|
|
minimal context usage. Issue operations are wrapped in scripts so agents
|
|
spend tokens on the task, not on re-deriving commands and formats.
|
|
2. **Keep the tracker out of the work.** An issue is a unit of work first and a
|
|
Gitea row second. The two are separate layers, and the first one does not
|
|
know the second exists.
|
|
3. **Route all Gitea interaction through the `tea` CLI via scripts** instead of
|
|
direct ad-hoc calls wherever possible. Scripts give deterministic,
|
|
reviewable behavior; the `tea-guard` hook enforces that every `tea`
|
|
invocation runs under the operator-pinned login.
|
|
|
|
## Layers
|
|
|
|
The hard rule of this repo. Knowledge flows one way only:
|
|
|
|
```
|
|
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
|
▲ offline — no tracker, no network, stdlib imports only
|
|
│ imports
|
|
skills/sync BRIDGE map.py md <-> Gitea JSON, pure functions, no I/O
|
|
_gitea.py login pin, tea api, pagination, filters
|
|
skills/use REFERENCE tea CLI docs for everything that is not an issue
|
|
skills/auth IDENTITY pin the login the whole tracker side runs under
|
|
▲
|
|
│ calls
|
|
agents/ EXECUTION tea-runner: runs the scripts, reports a receipt
|
|
```
|
|
|
|
`skills/issue` never imports from `skills/sync`. Delete `skills/sync` and the
|
|
domain layer keeps working. The check is mechanical — every import under
|
|
`skills/issue/scripts/` is stdlib, and `subprocess` is not among them:
|
|
|
|
```bash
|
|
grep -rh '^import \|^from ' skills/issue/scripts/ | sort -u
|
|
```
|
|
|
|
If a tracker concept (issue number, login, HTTP call, label color) shows up in
|
|
the domain layer, it is in the wrong place.
|
|
|
|
## Repo layout
|
|
|
|
- `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`)
|
|
- `skills/issue` — issues as units of work (`/tea:issue`), entirely offline
|
|
- `references/format.md` — canonical issue format; single source of truth
|
|
- `scripts/issue.py` — domain module: slug identity, parse/render, validation,
|
|
taxonomy, dependency graph
|
|
- `scripts/issue_new.py` — create a local issue from its type template
|
|
- `scripts/issue_check.py` — validate against the format
|
|
- `scripts/issue_tree.py` — draw the dependency graph
|
|
- `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md`
|
|
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
|
|
- `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here
|
|
- `scripts/_gitea.py` — transport: login pin, `tea api`, pagination, filters,
|
|
label ids, the remote-id map
|
|
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
|
|
- `skills/use` — `tea` CLI reference for everything that is not an issue
|
|
(`/tea:use`); `references/tea/` holds the command docs
|
|
- `agents/tea-runner.md` — subagent on Haiku that executes the scripts and
|
|
returns a compact receipt. Delegate batches (bulk pull, push a named set,
|
|
bootstrap labels, rebuild the index), never the thinking: it has no `Edit`
|
|
and no `Write`, may not `--force`, and may not decide what an issue says.
|
|
Delegating a single call costs more than running it inline — the win is the
|
|
loop, the retry, and the error triage.
|
|
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations
|
|
that don't use the pinned login; `agents-sync` keeps every directory canonical
|
|
(`AGENTS.md` real file, `CLAUDE.md` symlink to it)
|
|
- `tests/` — stdlib `unittest`, no third-party anything
|
|
|
|
## Tests
|
|
|
|
```bash
|
|
python3 -m unittest discover -s tests -v
|
|
```
|
|
|
|
Plain `unittest`; no pytest, no dependencies — the scripts under test are
|
|
stdlib-only and the tests hold the same line. `skills/*/scripts/` are not
|
|
packages, so a test that needs the domain module imports it with
|
|
`sys.path.insert`.
|
|
|
|
**A test never touches `tmp/issues/`.** Anything that needs a store builds a
|
|
throwaway repository in a `tempfile.TemporaryDirectory()` — a `.git` marker, a
|
|
copy of the script layers, fixture issues — and runs the real scripts inside it
|
|
as subprocesses. That is the only way to test behavior that depends on where a
|
|
script is run from, and it keeps the developer's own store out of the blast
|
|
radius.
|
|
|
|
## Local issue store
|
|
|
|
`tmp/issues/` (gitignored) is **the store, not a cache of Gitea**. One flat
|
|
markdown file per issue, named by its slug, with one metadata field per line so
|
|
plain grep works without a parser.
|
|
|
|
- **The path is `<repo root>/tmp/issues`, resolved from `issue.py`'s own
|
|
location, not from cwd.** `issue.store_root()` walks up from `__file__` to the
|
|
nearest `.git` or `AGENTS.md` — so every script in both layers sees one store
|
|
whatever directory it is run from. An explicit `--out` overrides it and is
|
|
used exactly as typed; a relative `--out` stays relative to cwd.
|
|
- Nothing creates the store as a side effect of a write. Readers distinguish
|
|
"does not exist" from "is empty"; only `issue_new.py` and `pull.py` create it,
|
|
and they say so on stderr.
|
|
- Identity is the slug (`wire-sqlc-appclick.md`), never a tracker number.
|
|
Numbers live in the `gitea:` field.
|
|
- `origin: local` is a durable state. An issue that never leaves this machine is
|
|
complete and valid, not a draft.
|
|
- Pushing is additive: the file is never deleted, it gains `gitea:` / `url:` /
|
|
`synced:`.
|
|
- Pulling overwrites the body — a fetch, not a merge.
|
|
- No drift tracking. `synced:` tells you how old your copy is; re-pull when it
|
|
matters.
|