Files
marketplace/AGENTS.md
T
naudachu 596cf853e8 fix: keep request payloads out of the issue store
`labels.py` handed `_gitea.api` the issue store as a place to put the
request file, and on a checkout without a store that quietly created
`tmp/issues/.payload/`. Bootstrapping a repository's labels touches no
issue at all, so the one rule the store has — nothing materializes it as
a side effect of a write — was broken by an operation that has no
business knowing the store exists.

Where a request body goes was never the caller's decision to make. It is
now the transport's: `tmp/payload/`, resolved from `_gitea.py`'s own
location the way both domains resolve theirs, so every caller — sync and
wiki alike — writes to one directory whatever it was invoked from, and
`out_root` is gone from `api`, `add_dependency` and all six call sites.
The directory is created by the first write of a run and not before: a
`--dry-run` leaves nothing behind. `tmp/` is already gitignored.

The name carries the distinction the old path lost. A store holds the
only copy of something; this holds debris kept for a retry or a
post-mortem, and deleting it costs nothing. A dotdir sitting among an
issue's files claimed otherwise, and `ls tmp/issues` started lying about
what existed.

tests/test_payload_root.py runs the real `labels.py` in a throwaway repo
against a fake `tea` on PATH: no store appears, the payloads land in
tmp/payload/, a dry run writes nothing, and a run from a subdirectory
still resolves to the repo root. Two source checks keep the callers from
drifting apart again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:25:33 +05:00

205 lines
11 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. Two domains, two bridges, one transport, and
knowledge flows one way only:
```
skills/issue DOMAIN what an issue is: format, validation, dependency graph
skills/page DOMAIN what a page tree is: title <-> path, order, the index
▲ offline — no tracker, no network, stdlib imports only
│ imports
skills/sync BRIDGE map.py md <-> Gitea issue JSON, pure, no I/O
_gitea.py login pin, tea api, pagination, filters
skills/wiki BRIDGE wikimap.py md <-> Gitea wiki JSON, pure, no I/O
transport is _gitea.py — there is no second one
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
```
A domain never imports its bridge, and the two domains do not import each
other: delete `skills/sync` and issues still work, delete `skills/wiki` and page
trees still work, delete either domain and the other is untouched. The check is
mechanical — every import under a domain's `scripts/` is stdlib, and
`subprocess` is not among them:
```bash
grep -rh '^import \|^from ' skills/issue/scripts/ | sort -u
grep -rh '^import \|^from ' skills/page/scripts/ | sort -u
```
If a tracker concept (issue number, login, HTTP call, label color, `sub_url`,
`content_base64`) shows up in a 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, body checkboxes
- `scripts/issue_new.py` — create a local issue from its type template
- `scripts/issue_check.py` — validate against the format
- `scripts/issue_ac.py` — list the body's checkboxes; tick one by number or
substring, changing exactly one character of the file
- `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, `tmp/payload/`
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
- `scripts/labels.py` — put the canonical `type/*` and `severity/*` set into a
repository; reads the domain taxonomy, never the store
- `skills/page` — a discussion's artifacts as a page tree (`/tea:page`),
entirely offline
- `references/pages.md` — canonical page-tree format; single source of truth
- `scripts/page.py` — domain module: title ↔ path, ordering, the manifest,
importing a directory of markdown, the index
- `scripts/page_import.py` — copy a directory of markdown into a space,
titling every file
- `scripts/page_index.py` — write the table-of-contents page
- `scripts/page_ls.py` — the tree, the titles, one sync-state tag per page
- `skills/wiki` — move page trees between a local space and a Gitea wiki
(`/tea:wiki`)
- `scripts/wikimap.py` — md ↔ Gitea wiki JSON, pure, no I/O
- `scripts/wiki_ls.py`, `wiki_pull.py`, `wiki_push.py` — transport is
`skills/sync/scripts/_gitea.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/` or `tmp/wiki/`.** Anything that needs a
store builds a throwaway repository in a `tempfile.TemporaryDirectory()` — a
`.git` marker, a copy of the script layers, fixture issues or artifacts — 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) holds **two kinds of file, and only one of them is a
store.** An `origin: local` issue lives here and nowhere else — this file *is*
the issue, and losing it loses the work. Anything with `origin: gitea` is a
**cache**: the tracker has it, this copy is a working copy, and it is deleted
the moment a push confirms the tracker is up to date.
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 complete state, not a draft: an issue that never leaves
this machine is valid and finished. It is not a *durable* state, though —
pushing ends it, and the local file goes with it.
- **A successful push deletes the local file** (`<id>.md` and
`<id>.comments.md`), and prints the number and URL the issue now lives at.
`--update` too: one rule, no exception. What is in the store is what has not
left. Get it back with `pull.py <n>`.
- Deletion happens only after a confirmed tracker response and only after
`.remote.json` has been written. Network down, non-2xx, an answer that does
not carry the right number: the file stays and the run stops. A never-pushed
`origin: local` issue is never touched by any of this.
- The slug survives the round trip because it goes up in the body as
`<!-- tea:id … -->` (`map.with_id_marker`) and is indexed by number in
`tmp/issues/.remote.json`. A rename in the web UI, a lost `.remote.json`, a
fresh clone, another machine — the file comes back under the same name and
every `depends:` that points at it still resolves.
- `.remote.json` is therefore no longer "an index over the files": it is the
local number → slug ledger, its entries outlive the files they name, and
nothing prunes them. It is still recoverable — from the markers in Gitea, not
from the files.
- Pulling overwrites the body — a fetch, not a merge. It is also how a pushed
issue comes back at all.
- No drift tracking, and now nothing to track: there is no second copy to
diverge from. `synced:` tells you how old your working copy is.
## Local wiki cache
`tmp/wiki/<space>/` (gitignored) holds page trees — a discussion's artifacts,
organized. Same stance as the issue store, resolved the same way from
`page.py`'s own location, with the same `--out` rule.
- Identity is the **title**, and `/` inside it is the only hierarchy there is.
The Gitea wiki is flat: it escapes a title into one filename by rules of its
own (`space -> -`, `/ -> %2F`, a literal `-` forces a trailing `.-`).
- **`sub_url` is Gitea's address for a page and is never constructed.** It is
read back from the API and stored in `.pages.json`. One built by hand that is
almost right creates a second page instead of editing the first.
- **Never commit a subdirectory into a wiki's git repository.** Gitea does not
see it — the page exists on disk and nowhere in the API or the UI. Do not
clone the wiki repo to work in; use the scripts.
- A title is a decision, not a derivation. A re-import replaces bodies and
keeps titles, so editing a heading cannot silently rename a published page.
`--retitle` opts in, and the rename reaches the wiki on the next push.
- A page with no `sub_url` has never been published — a complete state, the way
`origin: local` is for an issue. **The parallel stops at the push**: a pushed
page stays on disk, a pushed issue does not.
- Change detection is one hash (`pushed`). Pulling overwrites; pushing is
additive and never deletes — the one place the two domains deliberately
disagree, because a page tree is worked on locally and an issue is not.
- The `tea` CLI has no wiki subcommand. `tea api` is the only route, through
`_gitea.py`.
## Request payloads
`tmp/payload/` (gitignored) holds the JSON bodies `tea api -d @file` was given,
one file per named request, kept after the call for a retry or a post-mortem.
It is **not a store and holds nobody's only copy** — deleting it costs nothing.
- One directory for every caller — sync and wiki both — resolved from
`_gitea.py`'s own location, so which command wrote a body does not change
where it landed. `_gitea.api` takes no directory argument; that it once did
is exactly how a label bootstrap came to create `tmp/issues/`.
- It is created lazily, by the first write of a run, and only then: a `--dry-run`
or a run with nothing to send leaves no directory behind.
- **A scratchpad may never sit inside a store.** Store contents are the thing
being tracked; request bodies are debris of the transport. When the two share
a path, an operation that touches no issue at all still materializes the issue
store, and the operator's `ls tmp/issues` starts lying about what exists.