Compare commits

..

7 commits

Author SHA1 Message Date
opencode-agent
ff12a990dc docs: update project map for telegram-send tool 2026-07-31 17:39:31 +00:00
opencode-agent
a4e7991d3e docs(handoff): set PR number 174 2026-07-31 17:36:46 +00:00
opencode-agent
8f47340f9e docs(handoff): add handoff and ADR for telegram-send 2026-07-31 17:33:58 +00:00
opencode-agent
2ce34a86a2 chore(env): add TELEGRAM_CHAT_ID to .env.example 2026-07-31 17:33:56 +00:00
opencode-agent
7299846e5d test(telegram): add unit tests for markdown, config, api 2026-07-31 17:33:54 +00:00
opencode-agent
bc77b1d86f feat(telegram): add telegram-send plugin-tool 2026-07-31 17:33:50 +00:00
opencode-agent
3aef215663 feat(telegram): add CLI project with Bot API client 2026-07-31 17:33:47 +00:00
341 changed files with 3272 additions and 15378 deletions

View file

@ -18,10 +18,6 @@ OPENCODE_SERVER_PASSWORD=your-opencode-server-password
# GitHub
GITHUB_TOKEN=your-github-token-here
# Forgejo (self-hosted) — used by status oracles with Forgejo backend
FORGEJO_URL=https://git.slaid098.dev
FORGEJO_TOKEN=your-forgejo-api-token-here
# Context7 MCP
CONTEXT7_API_KEY=your-context7-api-key-here
@ -30,16 +26,11 @@ TELEGRAM_API_ID=your-telegram-api-id
TELEGRAM_API_HASH=your-telegram-api-hash
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
TELEGRAM_CHAT_ID=your-telegram-chat-id
# Private Telegram group for Boosty subscribers (builds, configs)
TELEGRAM_BOOSTY_CHAT_ID=your-boosty-chat-id
# Cloudflare Tunnel (optional — see separate tunnel repo)
CLOUDFLARE_TUNNEL_TOKEN=
TUNNEL_DOMAIN=
# Vercel (optional — placeholder example, not currently wired to code)
VERCEL_TOKEN=
# Antidetect Browser MCP
# Local: http://localhost:8765/mcp
# Remote: http://<your-server-ip>:8765/mcp

33
.github/workflows/adr-check.yml vendored Normal file
View file

@ -0,0 +1,33 @@
name: ADR References Check
on:
pull_request:
paths:
- 'docs/**'
- '.opencode/**'
- '**/*.md'
- '.github/workflows/adr-check.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- id: check
run: |
if [ -f .opencode/scripts/check-adr-refs.py ]; then
echo "has_script=true" >> $GITHUB_OUTPUT
else
echo "has_script=false" >> $GITHUB_OUTPUT
fi
- uses: actions/setup-python@v5
if: steps.check.outputs.has_script == 'true'
with:
python-version: "3.12"
- run: python3 .opencode/scripts/check-adr-refs.py
if: steps.check.outputs.has_script == 'true'

View file

@ -35,6 +35,10 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev
- run: uv run ruff check src/ tests/ .opencode/scripts/
- run: uv run ruff format --check src/ tests/ .opencode/scripts/
@ -46,6 +50,10 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev
- run: uv run mypy src/
@ -57,15 +65,23 @@ jobs:
strategy:
fail-fast: false
matrix:
python: ["3.13"]
python: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: sudo apt-get update && sudo apt-get install -y ripgrep
- run: npm ci
working-directory: .opencode
- run: npm ci
working-directory: .opencode/draw-image
- run: npm test
working-directory: .opencode/draw-image
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev --python ${{ matrix.python }}
- run: uv run --python ${{ matrix.python }} pytest
@ -76,5 +92,9 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev
- run: uv run xenon --max-absolute B --max-modules A --max-average A src/

View file

@ -0,0 +1,256 @@
---
description: Reviews and updates project map documentation before code review. Auto-commits updates to PR branch. Posts verdict via post-docs-review tool (deterministic heading for pipeline-status.py).
mode: subagent
temperature: 0.1
steps: 150
permission:
edit: allow
doom_loop: deny
bash:
"*": deny
"git fetch*": allow
"git diff*": allow
"git log*": allow
"git status*": allow
"git show*": allow
"git blame*": allow
"git remote -v*": allow
"git remote show*": allow
"git add docs/project-map*": allow
"git add docs/handoff*": allow
"git add docs/decisions*": allow
"git push*": allow
"rg *": allow
"find *": allow
"ls *": allow
"cat *": allow
"head*": allow
"tail*": allow
"wc*": allow
"diff*": allow
"repomix*": allow
"gh pr view*": allow
"gh pr diff*": allow
"gh pr checkout*": allow
"gh pr comment*": allow
"gh pr comment *": allow
"gh issue view*": allow
"gh issue list*": allow
"git rm docs/handoff*": allow
"git rm docs/decisions*": allow
"git rm -r docs/spec*": allow
"git rm --cached docs/handoff*": allow
"git rm --cached docs/decisions*": allow
"git mv docs/handoff*": allow
"git mv docs/decisions*": allow
"git checkout docs/handoff*": allow
"git checkout docs/decisions*": allow
"git -C * status*": allow
"git -C * diff*": allow
"git -C * log*": allow
"git -C * show*": allow
"git -C * branch*": allow
"git -C * fetch*": allow
"git -C * remote -v*": allow
"git -C * remote show*": allow
"git branch*": allow
"pwd": allow
"echo *": allow
"python3*": allow
"python3 *pipeline-status.py*": deny
"python3 .opencode/scripts/pipeline-status.py*": deny
"python3 */pipeline-status.py*": deny
"python *pipeline-status.py*": deny
"python */pipeline-status.py*": deny
"python3 *spec-status.py*": deny
"python3 .opencode/scripts/spec-status.py*": deny
"python3 */spec-status.py*": deny
"python *spec-status.py*": deny
"python */spec-status.py*": deny
"mkdir*": allow
"gh pr merge*": deny
---
You are a project map reviewer. Your job: analyze structural changes in a PR, update the project map documentation in `docs/project-map/`, and commit updates to the PR branch.
## Setup
1. Run `gh pr view <PR_NUMBER> --json headRefName,title,body` to get branch name and PR context.
2. Run `gh pr checkout <PR_NUMBER>` to switch to the PR branch.
3. Run `git fetch origin` to ensure you have the latest default branch.
4. Run `git diff origin/HEAD...HEAD --stat` to see what files changed.
5. Run `repomix --no-files --stdout` to get the current directory tree.
6. Check if `docs/project-map/` exists:
- Run `ls docs/project-map/ 2>/dev/null`
- If it doesn't exist, create the directory and an initial `README.md`.
## Analysis
1. Compare the `git diff --stat` output with the current `docs/project-map/` files.
2. Determine if structural changes occurred:
- New files or directories added
- Files or directories deleted
- Files or directories renamed
- New top-level modules
3. If NO structural changes (only content edits, bug fixes, refactoring within existing files) → skip project-map update, but STILL leave PR comment per "PR Comment (mandatory)" section below.
4. If structural changes occurred → proceed to update.
## Handoff & ADR Validation
After updating project map, validate handoff and ADR files:
### Handoff (`docs/handoff/pr-<PR_NUMBER>-<slug>.md`)
1. Check if file exists. If not → create it from PR diff.
2. Check all sections are present: Что сделано, Почему, Pending, Watch out.
3. Check content is meaningful (not empty placeholders).
4. If sections missing or empty → **fix them** based on PR diff and issue context.
### ADR (`docs/decisions/<NN>-<title>.md`) — mandatory per ADR-002
1. ADR is **mandatory** in every PR (per ADR-002). Never bypass.
2. If ADR file `docs/decisions/*-pr-<PR#>-*.md` does not exist → create it via `bash .opencode/scripts/scaffold-handoff.sh <PR#> <slug>` (creates both handoff + ADR templates).
3. Check ADR sections: Статус, Контекст, Решение, Альтернативы.
4. If sections incomplete (empty placeholders like `<заполни>`) → **fix them** based on PR diff.
5. If PR has NO architectural decisions → fill all sections (Контекст/Решение/Альтернативы) with `—` (em-dash). This is valid per ADR-002.
6. NEVER bypass ADR creation. Pipeline-status.py will fail DOCS phase if ADR is missing.
## Spec cleanup (post-merge, опционально)
**GUARD: НЕ выполнять spec cleanup pre-merge — это post-merge операция.**
Spec cleanup выполняется ТОЛЬКО post-merge (в MEMORY фазе), НЕ pre-merge.
Если ты docs-reviewer (pre-merge), пропусти эту секцию — spec cleanup
выполняет memory-syncer post-merge.
Если `docs/spec/roadmap.md` существует в репо (spec был запущен):
1. Извлеки все `#N` номера issues из `docs/spec/roadmap.md` (regex `#(\d+)`).
2. Для каждого `#N`: `gh issue view N --json state --jq .state`.
3. Если ВСЕ issues имеют `state=CLOSED`:
- `git rm -r docs/spec/` (удаляет всю директорию spec-документации).
- Коммит через `commit` tool: `commit({ message: "chore: remove completed spec" })`.
- PR comment: добавить секцию `## Spec Cleanup` в docs-review summary: "Spec removed: all N issues from roadmap.md are CLOSED".
4. Если хотя бы один issue OPEN → пропусти cleanup (spec ещё жив). PR comment: "Spec retained: M/N issues still OPEN".
Проверка выполняется ПОСЛЕ валидации handoff/ADR и ДО `git add docs/project-map/ docs/handoff/ docs/decisions/`.
Используется `git rm -r docs/spec/` (НЕ `rm -rf docs/spec/`) — `rm -rf` блокируется `check-permissions.py` (DANGEROUS_PATTERNS, scope=all). `git rm -r` семантически эквивалентен и соответствует существующим паттернам `git rm docs/handoff*` / `git rm docs/decisions*`.
### Commit scope
When committing, stage docs first, then use the `commit` tool (raw `git commit` is globally denied — use the tool which bypasses via spawnSync):
```bash
git add docs/project-map/ docs/handoff/ docs/decisions/
```
```
commit({ message: "docs: update project map + handoff + ADR" })
```
```bash
git push
```
## Update Rules
### What to include in project map:
- Directory structure (tree of each module)
- Purpose of each module/directory
- Key files and their roles
- Dependencies between modules
### What NOT to include:
- Implementation details
- API signatures
- Internal logic
- Line-by-line documentation
### File structure:
- `docs/project-map/README.md` — index, overall project structure, module list
- `docs/project-map/<module>.md` — one file per top-level module/directory
### MD file template:
```markdown
---
module: <module-path>
purpose: <one-line description>
key_files:
- <path><role>
- <path><role>
dependencies: [<list of module dependencies>]
last_updated: <YYYY-MM-DD>
---
# <module name>
## Structure
- `<file>`<description>
- `<file>`<description>
## Patterns
- <pattern or convention used>
```
## Commit
1. Stage only project map files:
```bash
git add docs/project-map/ docs/handoff/ docs/decisions/
```
2. Commit via `commit` tool (raw `git commit` is globally denied — the tool bypasses via spawnSync):
```
commit({ message: "docs(project-map): update after structural changes" })
```
3. Push:
```bash
git push
```
## PR Comment (mandatory)
After validation (regardless of whether structural changes occurred), **ALWAYS** leave a PR comment using the `post-docs-review` tool. The tool auto-generates the `## Docs Review Summary` heading and the `### Verdict: <verdict>` line — you only pass the body content (between heading and verdict). Do NOT manually format the heading or verdict. This is the deterministic marker that `check_docs` in pipeline-status.py uses to prove docs-reviewer ran. Without this comment, the pipeline is blocked at DOCS phase.
Body format (without heading — tool adds `## Docs Review Summary` and `### Verdict: <verdict>`):
```
- Project map: <updated|no structural changes|created>
- Handoff: <valid|fixed: ...|missing: ...>
- ADR: <valid|fixed: ...|missing: ...>
## Spec Cleanup
- Spec: <removed: all N issues from roadmap.md are CLOSED|retained: M/N issues still OPEN|n/a: docs/spec/roadmap.md does not exist>
```
Call:
```
post-docs-review({ pr_number: <PR_NUMBER>, verdict: "<APPROVE|FIXED|NO_CHANGES>", body: `<body text above>` })
```
Verdict semantics:
- `APPROVE` — docs valid, no fixes required
- `FIXED` — docs-reviewer fixed something (project map, handoff, ADR, or spec cleanup performed)
- `NO_CHANGES` — no structural changes, handoff+ADR already valid, spec retained or absent (no commit, no edits)
Rules:
1. Comment is left AFTER commit+push (if any) — so reviewer can see final state.
2. If no structural changes AND handoff+ADR valid → no commit, but comment IS still left with `Verdict: NO_CHANGES`.
3. The comment heading `## Docs Review Summary` is guaranteed by the `post-docs-review` tool — `check_docs` matches regex `Docs Review` (case-insensitive).
4. Never skip the comment, even on edge cases — use `Verdict: NO_CHANGES` instead of silence.
## Tool failure handling
If `post-docs-review` returns a string starting with `⚠️ ...failed` (e.g. `⚠️ post-docs-review failed for PR #N (exit 1): ...`):
- **СООБЩИ оркестратору о сбое tool и STOP.** Не продолжай молча, не пытайся fallback на raw `gh pr comment` через bash.
- Причина сбоя обычно: gh не аутентифицирован, PR не найден в текущем репо (cwd не git-репо или нет origin remote), или network error.
- Возвращай текст вида: `⚠️ post-docs-review tool failed: <сообщение от tool>. Pipeline заблокирован на DOCS phase — требуется вмешательство.`
- Любой дальнейший tool call после сбоя = protocol violation.
## Rules
1. ALWAYS checkout the PR branch first.
2. ONLY edit files in `docs/project-map/`, `docs/handoff/`, `docs/decisions/`.
3. ONLY `git add docs/project-map/ docs/handoff/ docs/decisions/` — never stage other files.
4. If no structural changes → do not commit, but STILL leave PR comment (see "PR Comment (mandatory)" section).
5. Keep map files concise — structure and purpose, not implementation.
6. Update `last_updated` field in frontmatter when modifying a file.
7. If `docs/project-map/` doesn't exist → create initial map with `README.md` and one file per top-level module.
8. Для debug-вывода используй `pwd`/`ls`/`cat`/`echo` (все в allow-list).
9. НЕ переключайся на master и НЕ делай `git pull` — работай только на PR branch (checkout уже сделан в Setup).
## Bug Discovery
If you find a bug outside the current PR/task scope — you MUST load skill `bug-discovery` via `skill("bug-discovery")` tool and follow its protocol. Do NOT fix the bug yourself. Report to orchestrator: "Created issue #N: ...".

View file

@ -1,5 +1,5 @@
---
description: Distills durable knowledge from merged PRs into global memory. Read-only on repo, write-only on memory.
description: Distills durable knowledge from merged PR handoffs into global memory. Read-only on repo, write-only on memory.
mode: subagent
temperature: 0.1
steps: 150
@ -41,7 +41,7 @@ You are **read-only on the repository** and **write-only on memory**. You CANNOT
1. Load the memory skill via `skill("memory")` to get distillation rules and format conventions.
2. Get the PR number from the invocation prompt.
3. Прочитай PR body через `gh pr view <N> --json body,title` (PR body содержит 4 секции: Что сделано, Почему, Watch out, Pending). При необходимости — `gh issue view <issue-N>` для контекста.
3. Find the merged handoff file: `ls docs/handoff/pr-<N>-*` to discover the slug, then `cat docs/handoff/pr-<N>-<slug>.md` to read it (read-only — agent does not check out branches).
4. Determine the repo: `git remote get-url origin` → parse `{host}/{org}/{repo}` (e.g. `github.com/slaid098/opencode-config`).
5. Resolve memory path: read `OPENCODE_MEMORY_DIR` env var (set globally via docker-compose; fallback `~/.local/share/opencode/opencode-memory/`) → `<memory_dir>/repos/{host}/{org}/{repo}.md`. Use `printenv OPENCODE_MEMORY_DIR` to inspect it.
6. Open the memory file (create if missing) via the `edit`/`write` tool — `edit: allow` permits this. The memory dir is an isolated git repo (post-commit hook auto-pushes), separate from the main repo.
@ -55,7 +55,7 @@ Durable (записывай):
- patterns, repository conventions
- pointers: «for X use Y, careful with Z»
- root causes of bugs
- ADR pointers (исторические, для новых PR): `[date, PR#N] <architectural decision summary>` (без ADR-NN — старые ADR-NNN references в памяти остаются как исторические)
- ADR pointers: `- [date, PR#N] ADR-NN: <суть> → docs/decisions/NN-title.md` (do NOT copy ADR content — only the pointer)
НЕ durable (НЕ записывай):
- статусы, «сейчас работаем над», текущие таски, ephemeral контекст
@ -80,31 +80,24 @@ Even if there are no durable records, the receipt is mandatory:
This confirms the memory-sync phase was executed (audit trail).
### Выбор файла для записи (ОБЯЗАТЕЛЬНО перед записью)
### Дедуп перед записью (ОБЯЗАТЕЛЬНО)
Память репо — один или несколько файлов по пути `<memory_dir>/repos/{host}/{org}/{repo}*.md`:
- первый файл: `{repo}.md`
- последующие (когда первый заморожен по размеру): `{repo}-002.md`, `{repo}-003.md`, ... (3-значный sequential, не по дате)
Перед добавлением записи — прочитай существующий файл. Если похожая запись уже есть (та же гоча/паттерн/root cause) → обнови существующую (bump `updated` в frontmatter, дополни детали если нужно), НЕ добавляй новую. Дубликаты раздули файлы до 600+ KB.
Порог ротации: **50 KB** (soft). Файл с размером ≥ 50 KB считается замороженным — новые записи в него НЕ пишутся.
Пример: если «ffmpeg drawbox не поддерживает W/H» уже записан в PR#50 — не добавляй новую запись в PR#120 с той же гочей. Обнови `updated` и допиши нюанс если он есть.
Перед записью:
1. Найди все файлы репо: `ls -la <memory_dir>/repos/{host}/{org}/{repo}*.md` (или `find`).
2. Активный файл = **первый существующий** с размером < 50 KB (не последний созданный, а первый по имени `{repo}.md`, затем `{repo}-002.md` ...). Проверяй размер через `ls -la` (или `wc -c`).
3. Если все существующие файлы ≥ 50 KB → создай следующий по sequential нумерации: `{repo}-NNN.md` где NNN — следующий свободный номер (3-значный: 002, 003, 004...). Скопируй frontmatter из предыдущего файла с обновлёнными `created` (текущая дата) и `updated` (текущая дата); `summary` можно сузить под содержимое нового файла; `tags`/`importance`/`related` — без изменений.
4. Новый репо без файлов → создай первый `{repo}.md` с нуля (текущее поведение).
## Compaction
### Дедуп across files (ОБЯЗАТЕЛЬНО перед записью)
Если после записи файл > 100 KB → компрессировать:
1. Прочитай все старые записи
2. Оставь только durable (gotchas, паттерны, root causes, ADR-указатели)
3. Выкинь не-durable (changelog-дампы «PR#N: добавили X», статусы, хроника событий, receipts с повторяющимся содержанием, метрики PR)
4. Объедини дубликаты (одна гоча → одна запись, bump `updated`)
5. Tags-строку усечь до < 500 символов (оставить самые релевантные теги)
6. Summary усечь до разумного размера (< 500 символов)
7. Цель — держать файл < 100 KB
Перед добавлением новой записи — ищи дубликат по **всем** `repo*.md` (включая замороженные ≥ 50 KB):
1. `rg "<ключевая фраза гочи/паттерна>" <memory_dir>/repos/{host}/{org}/{repo}*.md` — ripgrep рекурсивно по всем файлам репо.
2. Если похожая запись найдена в **любом** файле (включая замороженный) → обнови её **in-place там же** (bump `updated` в frontmatter того файла, дополни детали если нужно), НЕ добавляй новую в активный файл.
3. Новые записи (не найденные как дубликат) → только в активный файл (см. выбор файла выше).
4. Замороженные файлы редактируемы для dedup (обновление существующих строк, bump `updated` в их frontmatter). Новые записи в замороженный файл — НЕ пишутся.
Дубликаты раздули файлы до 600+ KB. Каждая гоча/паттерн/root cause = одна запись в одном файле, не по одной на каждый PR где упоминалась.
Пример: если «ffmpeg drawbox не поддерживает W/H» уже записан в `youtube-soft.md` (≥ 50 KB, заморожен) в PR#50 — не добавляй новую запись в `youtube-soft-002.md` в PR#120 с той же гочей. Открой `youtube-soft.md`, обнови существующую строку, bump `updated` в frontmatter `youtube-soft.md`, допиши нюанс если он есть.
Критерий выкидывания: «поможет ли это в следующий раз когда я полезу в этот код?» Нет → выкидывай.
## Save
@ -115,8 +108,8 @@ This confirms the memory-sync phase was executed (audit trail).
1. NEVER call `git push`, `git commit`, `git add` — they are not in the allow-list and will be denied by the catch-all rule.
2. NEVER checkout branches or pull — you operate on the current state of the default branch (already merged).
3. ONLY edit files under `<memory_dir>/repos/{host}/{org}/{repo}*.md` (активный файл + замороженные для dedup). НЕ создавай файлы вне этого pattern'а.
4. Read PR body via `gh pr view <N> --json body,title`. Optionally read `docs/decisions/*-pr-<N>-*.md` if exists (historical ADR). Do NOT read docs/handoff/ — handoff files are deprecated.
3. ONLY edit files under `<memory_dir>/repos/{host}/{org}/{repo}.md`.
4. ONLY read files under `docs/handoff/` and `docs/decisions/`.
5. Receipt is mandatory even if no durable records found.
6. If memory file doesn't exist — create it with proper frontmatter (title, tags, summary, created, updated, importance).
7. Для debug-вывода используй `pwd`/`ls`/`cat`/`printenv`/`echo` (все в allow-list).

View file

@ -22,14 +22,8 @@ permission:
"cat *": allow
"head*": allow
"tail*": allow
"sed -n *": allow
"wc*": allow
"diff*": allow
"date": allow
"date *": allow
"sort": allow
"sort *": allow
"sort * -o *": deny
"gh pr diff*": allow
"gh pr checkout*": allow
"gh issue*": allow
@ -109,18 +103,10 @@ You are a global code reviewer. Your job: review PRs against project skills and
## Investigation Budget
You have a maximum of ~15 steps for general investigation (Setup + checklist).
You have a maximum of ~15 steps for investigation (Setup + checklist).
After that, you MUST call post-review — even if you haven't checked everything.
An incomplete review with verdict NEEDS_DISCUSSION is better than an infinite
investigation.
Cross-file impact analysis: до 10 доп. шагов на проверку readers/writers (не
считается против основного budget 15). Это необходимо для детерминированных
связей writer↔reader.
Не повторять проверку ссылок в agent .md files БЕЗ причины — но ЕСЛИ PR
изменяет writer (агент/промпт/формат/литерал/frontmatter key/env var) —
проверка readers обязательна (см. Section 10).
An incomplete review with verdict NEEDS_DISCUSSION is better than an infinite investigation.
Do NOT repeatedly verify references in agent .md files — read once, assess, move on.
## Review Checklist
@ -211,49 +197,22 @@ Examples of project-specific rules:
- No `.env` or secret files in the diff.
- Branch name is descriptive.
### 9. PR body quality
### 9. Documentation (if docs/project-map/ exists)
- PR body содержит `## Что сделано`, `## Почему`, `## Watch out` заполнены
осмысленно (не пустые плейсхолдеры, `—` допустим для Watch out/Pending если
нет контента). Если PR body неполный → REQUEST_CHANGES.
- Project map files accurately reflect current project structure
- New modules have corresponding map files in `docs/project-map/`
- Deleted/renamed modules have updated or removed map files
- No stale references to files or directories that no longer exist
- Map files follow the template (frontmatter + structure + purpose)
## 10. Cross-file impact analysis
### 10. Handoff & ADR (quick check)
Для каждого изменённого файла в PR:
- `rg` по репо — кто читает/пишет тот же ресурс (файл-путь pattern, формат,
литерал, env var, frontmatter key, comment format).
- Категории связей:
- oracle-скрипты (`pipeline-status.py`, `spec-status.py`,
`project-status.py`) — читают форматы/файлы.
- валидаторы (`create-issue.ts`, `create-readme.ts`) — парсят структуры.
- агенты (`memory-syncer.md`) — пишут файлы, которые оракулы читают.
- промпты (`skills/*/SKILL.md`) — определяют поведение, которое оракулы
проверяют.
- `opencode.json` deny-rules — определяют, какие bash-команды запрещены.
- Если writer change требует paired reader update → REQUEST_CHANGES + ТЗ
(шаблон ниже в разделе "Cross-file impact: missing paired update").
**Когда cross-file check обязателен:** PR изменяет writer (агент/промпт/формат,
литерал, frontmatter key, env var, файл-путь pattern).
**Когда cross-file check опционален:** PR cosmetic (README typo, comment) или
refactor без behavior change (signature unchanged, format preserved).
**Когда cross-file check skip:** PR добавляет новый файл без readers — 8-я
секция "нет связанных компонентов", reviewer APPROVE.
**Edge cases (verdict mapping):**
- PR cosmetic (README typo, comment) — cross-file check skip (нет writer
change) → APPROVE (если нет других critical).
- PR refactor без behavior change (signature unchanged, format preserved) —
cross-file check опционален → APPROVE.
- PR добавляет новый файл, readers не найдены (`rg` пуст) → APPROVE (нет
readers = нет breakage risk).
- PR меняет writer И reader в одном PR (paired update в PR) → APPROVE
(связь обновлена совместно, окно сломанного main закрыто).
- PR меняет writer, reader fix большой (>100 строк) → REQUEST_CHANGES + ТЗ,
author решает разбить PR (writer отдельно, writer+reader вместе).
- explore subagent нашёл false positive (candidate не связан) — reviewer
отмечает в body "отвергнуто, причина: ..." и не блокирует merge.
- `rg` не нашёл readers — reviewer APPROVE (нет readers = нет breakage risk).
- `docs/handoff/pr-<N>-<slug>.md` exists in the diff (N = PR number)
- Handoff has all sections: Что сделано, Почему, Pending, Watch out
- Handoff content is meaningful — not empty placeholders
- If PR introduces architectural changes → `docs/decisions/<NN>-<title>.md` exists
- ADR has: Статус, Контекст, Решение, Альтернативы
- If handoff/ADR missing or empty → REQUEST_CHANGES
## Output Format
@ -304,48 +263,6 @@ Body format (without heading — tool adds `## Code Review Summary` and `### Ver
Fix: suggestion
```
Если найден missing paired update (см. Section 10) — добавить в body блок:
```
## Cross-file impact: missing paired update
PR меняет X (`file:line`). Y зависит от X:
- Y читает {ресурс} (`file:line`)
- X меняет {ресурс} → Y сломается
ТЗ на fix Y:
- Что: {описание fix}
- Где: {file:line}
- Контракт: {что Y должен делать после fix}
- Тесты: {что обновить}
Добавьте fix Y в этот PR. Если fix большой — разбейте PR (writer отдельно,
writer+reader вместе).
```
Пример (кейс #238#244):
```
## Cross-file impact: missing paired update
PR #239 меняет `memory-syncer.md:83-95` (файл-ротация: пишет в
`{repo}-002.md`).
`pipeline-status.py:check_memory` / `get_memory_file_path()` зависит от
memory-syncer:
- `pipeline-status.py:check_memory` читает `{repo}.md` для `PR#N` receipt
(`pipeline-status.py:line`)
- memory-syncer теперь пишет в `{repo}-002.md` (when `{repo}.md` ≥50 KB) →
оракул не найдёт receipt → MEMORY фаза зависает
ТЗ на fix:
- Что: `get_memory_file_path()` должен сканировать `{repo}*.md` glob, не
только `{repo}.md`
- Где: `.opencode/scripts/pipeline-status.py:get_memory_file_path()`
- Контракт: returns list of paths matching `{repo}*.md`, sorted
- Тесты: `test_pipeline_status.py` — add test for multi-file scan
Добавьте fix в этот PR. ~20 строк в pipeline-status.py + ~30 строк тестов.
```
Do NOT attempt merge. Stop and wait for fixes.
After this call, you MUST respond with your review text only. Do NOT call any more tools.
@ -397,36 +314,6 @@ If `post-review` returns a string starting with `⚠️ ...failed` (e.g. `⚠️
9. Для получения login автора PR используй `gh pr view --json author` (НЕ `gh api user` — broad API call, не в allow-list, вызывает doom-loop).
10. Для debug-вывода используй `pwd`/`ls`/`cat`НЕ `echo` (не в allow-list).
## Cross-file impact examples
### #238#244: memory-syncer ↔ pipeline-status
- PR #239 changed `memory-syncer.md` (file-rotation: writes to `{repo}-002.md`)
- `pipeline-status.py:check_memory` read only `{repo}.md` → broke (MEMORY
phase hung)
- Reviewer APPROVE → 69 min later #245 fix-up merged
- Prevention: reviewer должен был `rg "memory" .opencode/scripts/` → найти
`pipeline-status.py:check_memory` → REQUEST_CHANGES + ТЗ
## Known deterministic links
Reference list writer↔reader в этом репо. Обновляется при появлении новых
детерминированных связей.
- `memory-syncer.md` (writer of `{repo}*.md`) ↔ `pipeline-status.py:check_memory`
/ `get_memory_files` (reader) — MEMORY phase expects `PR#N` literal in
memory files.
- `pipeline-status.py` (parses `### Verdict:` line) ↔ `post-review.ts` (writes
`### Verdict: <verdict>` heading in PR comments).
- `pipeline-status.py` (MEMORY phase expects `PR#N` literal) ↔
`memory-syncer.md` (must write `PR#N` without space — otherwise receipt
not found).
- `spec-status.py` (reader of `docs/spec/*.md`) ↔ `spec` skill /
`project-template` skill (writers of spec phases).
- `project-status.py:check_readme` (oracle, validator) ↔ `create-readme.ts`
(writer of README with delimiter tags) ↔ `repo-readme` skill.
- `opencode.json` deny-rules ↔ `*-status.ts` native tools (must exist as
alternatives — see ADR-019; deny on direct `python3 .../pipeline-status.py`).
## Bug Discovery
If you find a bug outside the current PR/task scope — you MUST load skill `bug-discovery` via `skill("bug-discovery")` tool and follow its protocol. Do NOT fix the bug yourself. Report to orchestrator: "Created issue #N: ...".

View file

@ -1,5 +0,0 @@
---
description: Audit existing project — verdict + auto-create issues for refactor
agent: build
---
Load the `audit` skill via `skill({name: "audit"})` and follow its ПРОТОКОЛ strictly. Главный агент — оркестратор: `project-status` tool (read-only) + `explore` subagent (code-standards) + вопрос юзеру + `task(general)` для create-issue. Не делает edit/read/`gh issue create` сам.

View file

@ -1,61 +0,0 @@
---
description: Generate a branded 1024×1024 cover (Lime style) via draw-image
agent: build
---
Generate a repository cover image following the **hardcoded slaid098 Lime standard**.
Style is NEVER asked of the user and NEVER overridden — only content questions are asked.
## Hardcoded standard (НЕ спрашивается, НЕ переопределяется)
- `template` = `cover` (1024×1024, единственный доступный шаблон в `.opencode/draw-image/templates/cover.svg`).
- Palette = **Lime** from `.opencode/draw-image/brand.json`: base `#0a0a0a`, surface `#121212`, accent `#ccff00` (icons), fg `#ededed` (text), muted `#a1a1aa`.
- `title`: ровно ОДИН заголовок, **lowercase English**, без исключений. Авто-деривация из имени репо (см. правила ниже).
- `subtitle`: **ОТСУТСТВУЕТ**. Флаг `--subtitle` / параметр `subtitle` запрещён стандартом. Передача subtitle = ошибка команды.
- `out` = `assets/cover.png` (относительно корня репо). Глобальный путь для витрины slaid098.dev — без исключений.
## Slots
- `icon`**обязательный**. Lucide-иконка из `.opencode/draw-image/icons/lucide/` (2007 шт.) ИЛИ brand-logo из `.opencode/draw-image/brand-logos/`. Передаётся без расширения (напр. `mic`, не `mic.svg`).
- `sub-icon`**опциональный**. Brand-logo из `.opencode/draw-image/brand-logos/` для показа brand-принадлежности.
- `badge`**опциональный**. Путь к SVG-бейджу.
## Title derivation rules
- Имя репо → lowercase, дефисы/подчёркивания → пробелы: `opencode-voice-dictation``opencode voice dictation`.
- camelCase / UPPER → lowercase: `MyRepo``myrepo`.
- Числа сохраняются: `video-uniq-2``video uniq 2`.
- Если имя длиннее ~40 символов → спроси у пользователя сокращение.
## Flow
1. **Read repo** — прочитай `README.md`, манифест (`package.json` / `pyproject.toml` / иной), пробегись по `src/` (топ-уровень). Определи purpose репо.
2. **Infer** — выведи: `title` (по правилам выше), candidate `icon` (по purpose, напр. voice-dictation → `mic`), candidate `sub-icon` (по brand-принадлежности из README/манифеста).
3. **Ask user** — задай 2-3 вопроса ТОЛЬКО по контенту, который не удалось вывести: подтвердить/скорректировать `icon`, `sub-icon`, `title`. НЕ задавай вопросов по стилю — стиль захардкожен.
4. **Pre-check** — если `assets/cover.png` уже существует, спроси «перезаписать?» перед рендером.
5. **Ensure `assets/` dir** — если директории `assets/` нет, создай её автоматически.
6. **Render** — вызови tool `draw-image`:
```
draw-image({ template: "cover", title: "<lowercase english>", slots: "icon=<value>,sub-icon=<value>", out: "assets/cover.png" })
```
НЕ передавай `subtitle`. Если tool `draw-image` недоступен — остановись с ошибкой: «draw-image tool недоступен — невозможно сгенерировать кавер».
7. **Validate README** — проверь, что в `README.md` есть `![Cover](assets/cover.png)`. Если нет — добавь ссылку сразу после заголовка H1 (прямая проверка; опционально — `project-status.py check_readme`). Если `check_readme` падает (README без delimiter tags) — предупреди, но кавер всё равно остаётся отрендеренным (кавер ≠ README).
8. **Done** — сообщи путь к готовому PNG.
## Extension mechanism (НЕ хардкод брендов)
Команда НЕ хардкодит конкретные бренды. **OpenCode упоминается ниже только как ПРИМЕР** — это не special-case в коде. Для любого бренда: если в `.opencode/draw-image/brand-logos/` есть SVG, его можно использовать как `sub-icon`. Расширение = добавление brand-logo SVG в `draw-image/brand-logos/`, а НЕ изменение этой команды.
> Пример: README указывает на принадлежность к OpenCode-ecosystem → предложи `sub-icon=opencode` (файл `brand-logos/opencode.svg`). Для не-OpenCode репо `sub-icon` пропускается.
## Граничные случаи
- **Нет README / нет манифеста** → purpose не выводится → спроси пользователя `icon` + `title` напрямую (без infer).
- **Имя репо camelCase / UPPER** → нормализуется в lowercase (`MyRepo``myrepo`).
- **Имя репо с числами** → сохраняются (`video-uniq-2``video uniq 2`).
- **Имя репо > ~40 символов** → спроси сокращение.
- **Пользователь хочет не-Lime кавер** → отказ: «команда /cover поддерживает только Lime-стиль. Кастомизация вне scope.»
- **Существующий `assets/cover.png`** → спроси «перезаписать?» перед рендером.
- **Lucide-иконка не найдена** → ошибка со списком похожих иконок (fuzzy match по имени в `icons/lucide/`).
- **`check_readme` падает** (README без delimiter tags) → предупреди, но кавер рендерится.
- **`draw-image` tool недоступен** → ясная ошибка, рендер невозможен.
- **`assets/` директория отсутствует** → создаётся автоматически.

View file

@ -1,5 +0,0 @@
---
description: Project template — init new project (cookiecutter + GitHub remote) or check existing (project-status)
agent: build
---
Load the `project-template` skill via `skill({name: "project-template"})` and follow its ПРОТОКОЛ strictly. Два flow: init (cookiecutter по типу + git + gh repo create + branch protection + project-status) или check (project-status → отчёт → рекомендации через subagents). Главный агент — оркестратор: вопросы юзеру + `project-status` tool (read-only) + делегирование. Не делает cookiecutter/git/gh напрямую.

View file

@ -1,5 +1,5 @@
---
description: Standardize repo README + cover image (create)
description: Standardize repo README + cover image (create/validate)
agent: build
---
Load the `repo-readme` skill via `skill({name: "repo-readme"})` and follow its ПРОТОКОЛ strictly. Workflow: create-readme (create) → draw-image (cover) → проверка через project-status check_readme. One command = full README + cover standardization.
Load the `repo-readme` skill via `skill({name: "repo-readme"})` and follow its ПРОТОКОЛ strictly. Workflow: create-readme (generate) → draw-image (cover) → validate → fix cycle. One command = full README + cover standardization.

View file

@ -1,5 +1,5 @@
---
description: Run pipeline — autonomous 6-phase PR pipeline
description: Run pipeline — autonomous 7-phase PR pipeline
agent: build
---
Load the `run-pipeline` skill via `skill({name: "run-pipeline"})` and follow its ПРОТОКОЛ strictly. Each iteration: call `pipeline-status` tool, execute the `NEXT:` action it returns, repeat until COMPLETE or STOP. Полностью автономно — 1 строка прогресса после каждой фазы, STOP на AMBIGUOUS/error.

View file

@ -1,7 +1,6 @@
import { writeFileSync, mkdirSync, existsSync, readFileSync, rmSync } from "node:fs"
import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import os from "node:os"
import { spawnSync } from "node:child_process"
import { loadBrand } from "./src/config.ts"
import { loadTemplate, buildSvg, computeHash } from "./src/render.ts"
@ -25,7 +24,7 @@ function parseArgs(argv: string[]): Record<string, string> {
function main() {
const argv = process.argv.slice(2)
if (argv.length < 2 || argv[0] !== "render") {
process.stderr.write('usage: node cli.ts render <template> --title "..." [--slots icon=mic] [--subtitle text] [--out path] [--template-dir dir]\n')
process.stderr.write('usage: node cli.ts render <template> --title "..." [--slots icon=mic] [--out path]\n')
process.exit(2)
}
@ -34,7 +33,6 @@ function main() {
const title = opts.title ?? ""
const subtitle = opts.subtitle
const out = opts.out ?? "./assets/cover.png"
const templateDir = opts["template-dir"]
const slots: Record<string, string> = {}
if (opts.slots) {
@ -45,7 +43,7 @@ function main() {
}
const brand = loadBrand(__dirname)
const templateSvg = loadTemplate(__dirname, template, templateDir)
const templateSvg = loadTemplate(__dirname, template)
const hash = computeHash(brand, templateSvg, { template, title, subtitle, slots, out })
const outResolved = path.resolve(out)
@ -63,9 +61,7 @@ function main() {
}
const svg = buildSvg(templateSvg, brand, { template, title, subtitle, slots, out }, __dirname)
const tmpSvg = path.join(os.tmpdir(), `draw-image-${process.pid}.svg`)
try {
const tmpSvg = path.join(__dirname, ".tmp-render.svg")
writeFileSync(tmpSvg, svg)
const r = spawnSync("node", [path.join(__dirname, "render.mjs"), tmpSvg, outResolved], {
@ -83,9 +79,6 @@ function main() {
}
writeFileSync(metaPath, JSON.stringify(meta, null, 2) + "\n")
console.log(JSON.stringify({ path: outResolved, hash, status: "rendered" }))
} finally {
if (existsSync(tmpSvg)) rmSync(tmpSvg, { force: true })
}
}
main()

View file

@ -70,10 +70,8 @@ export function computeHash(brand: Brand, templateSvg: string, args: RenderArgs)
return createHash("sha256").update(data).digest("hex")
}
export function loadTemplate(drawImageDir: string, name: string, templateDir?: string): string {
const dir = templateDir ?? "templates"
const base = path.isAbsolute(dir) ? dir : path.join(drawImageDir, dir)
const templatePath = path.join(base, `${name}.svg`)
export function loadTemplate(drawImageDir: string, name: string): string {
const templatePath = path.join(drawImageDir, "templates", `${name}.svg`)
return readFileSync(templatePath, "utf-8")
}

View file

@ -1,6 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<!-- slot: name=icon, x=252, y=180, w=520, h=520, fit=contain, recolor=accent -->
<!-- slot: name=icon, x=312, y=180, w=400, h=400, fit=contain, recolor=accent -->
<!-- slot: name=sub-icon, x=412, y=600, w=200, h=200, fit=contain, recolor=accent -->
<!-- slot: name=badge, x=780, y=780, w=180, h=180, fit=contain, recolor=none, bg=surface, border=accent, radius=0.5 -->
<rect width="1024" height="1024" fill="{{base}}" />
<text x="512" y="830" font-family="Geist Sans, sans-serif" font-size="80" font-weight="700" fill="{{fg}}" text-anchor="middle">{{title}}</text>
<text x="512" y="870" font-family="Geist Sans, sans-serif" font-size="72" font-weight="700" fill="{{fg}}" text-anchor="middle">{{title}}</text>
<text x="512" y="930" font-family="Geist Sans, sans-serif" font-size="36" font-weight="400" fill="{{muted}}" text-anchor="middle">{{subtitle}}</text>
</svg>

Before

Width:  |  Height:  |  Size: 470 B

After

Width:  |  Height:  |  Size: 744 B

View file

@ -1,59 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest"
import { existsSync, readdirSync, rmSync, mkdirSync } from "node:fs"
import path from "node:path"
import os from "node:os"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const TMP = path.join(os.tmpdir(), `draw-image-cleanup-${process.pid}`)
beforeAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
mkdirSync(TMP, { recursive: true })
})
afterAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
})
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
encoding: "utf-8",
cwd: DRAW_IMAGE_DIR,
})
}
describe("cleanup — temp SVG after render", () => {
test("no .tmp-render.svg left in draw-image dir after CLI render", () => {
const out = path.join(TMP, "cleanup.png")
const leftoverPath = path.join(DRAW_IMAGE_DIR, ".tmp-render.svg")
if (existsSync(leftoverPath)) rmSync(leftoverPath, { force: true })
const r = runCli(["render", "cover", "--title", "Cleanup", "--out", out, "--template-dir", "tests/fixtures"])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
expect(existsSync(leftoverPath), ".tmp-render.svg must not remain in package dir").toBe(false)
})
test("unique temp SVG uses process pid under os.tmpdir and is removed", () => {
const expectedTmp = path.join(os.tmpdir(), `draw-image-${process.pid}.svg`)
if (existsSync(expectedTmp)) rmSync(expectedTmp, { force: true })
const out = path.join(TMP, "pid.png")
const r = runCli(["render", "cover", "--title", "Pid Check", "--out", out, "--template-dir", "tests/fixtures"])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
expect(existsSync(expectedTmp), `temp svg at ${expectedTmp} must be cleaned up`).toBe(false)
})
test("package dir contains no stray .svg files after render", () => {
const out = path.join(TMP, "no-stray.png")
const r = runCli(["render", "cover", "--title", "No Stray", "--out", out, "--template-dir", "tests/fixtures"])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
const stray = readdirSync(DRAW_IMAGE_DIR).filter((f) => f.startsWith(".tmp") && f.endsWith(".svg"))
expect(stray, `stray temp svg files: ${stray.join(", ")}`).toEqual([])
})
})

View file

@ -1,24 +1,19 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest"
import { describe, test, expect, beforeAll } from "vitest"
import { existsSync, rmSync, mkdirSync, writeFileSync, copyFileSync, readFileSync } from "node:fs"
import path from "node:path"
import os from "node:os"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
import { validateBrand } from "../src/config"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const TMP = path.join(os.tmpdir(), `draw-image-bad-input-${process.pid}`)
const TMP = "/tmp/draw-image-bad-input"
beforeAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
mkdirSync(TMP, { recursive: true })
})
afterAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
})
describe("e2e — bad input (unit-level, no file mutation)", () => {
test("invalid brand.json rejected by validateBrand", () => {
expect(() => validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa" }))

View file

@ -1,23 +1,18 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest"
import { describe, test, expect, beforeAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
import path from "node:path"
import os from "node:os"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const TMP = path.join(os.tmpdir(), `draw-image-e2e-no-icon-${process.pid}`)
const TMP = "/tmp/draw-image-e2e-no-icon"
beforeAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
mkdirSync(TMP, { recursive: true })
})
afterAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
})
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
encoding: "utf-8",
@ -28,7 +23,7 @@ function runCli(args: string[]): { status: number; stdout: string; stderr: strin
describe("e2e — no icon render", () => {
test("exit 0 and valid PNG without icon slot", () => {
const out = path.join(TMP, "no-icon.png")
const r = runCli(["render", "cover", "--title", "Default Brand", "--out", out, "--template-dir", "tests/fixtures"])
const r = runCli(["render", "cover", "--title", "Default Brand", "--out", out])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
expect(existsSync(out)).toBe(true)
const buf = readFileSync(out)

View file

@ -1,26 +1,19 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest"
import { describe, test, expect, beforeAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
import path from "node:path"
import os from "node:os"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
import { loadBrand } from "../src/config"
import { buildSvg } from "../src/render"
import { loadFixture } from "./helpers/fixtures"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const TMP = path.join(os.tmpdir(), `draw-image-optional-e2e-${process.pid}`)
const TMP = "/tmp/draw-image-optional-e2e"
const TMP_SVG = path.join(DRAW_IMAGE_DIR, ".tmp-render.svg")
beforeAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
mkdirSync(TMP, { recursive: true })
})
afterAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
})
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
encoding: "utf-8",
@ -41,13 +34,11 @@ function assertPng(filePath: string) {
describe("e2e — optional subtitle and badge via CLI", () => {
test("render with title only → exit 0, valid PNG, svg has no empty badge and no subtitle", () => {
const out = path.join(TMP, "optional.png")
const r = runCli(["render", "cover", "--title", "Test", "--out", out, "--template-dir", "tests/fixtures"])
const r = runCli(["render", "cover", "--title", "Test", "--out", out])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
assertPng(out)
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "Test", out }, DRAW_IMAGE_DIR)
const svg = readFileSync(TMP_SVG, "utf-8")
expect(svg).not.toContain('x="780" y="780"')
expect(svg).not.toContain('y="930"')
expect(svg).not.toContain("{{subtitle}}")

View file

@ -1,23 +1,18 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
import path from "node:path"
import os from "node:os"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const TMP = path.join(os.tmpdir(), `draw-image-e2e-${process.pid}`)
const TMP = "/tmp/draw-image-e2e"
beforeAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
mkdirSync(TMP, { recursive: true })
})
afterAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
})
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
encoding: "utf-8",
@ -42,7 +37,7 @@ function assertPng1024(filePath: string) {
describe("e2e — full CLI render", () => {
test("render cover with title and icon", () => {
const out = path.join(TMP, "e2e.png")
const r = runCli(["render", "cover", "--title", "E2E", "--slots", "icon=mic", "--out", out, "--template-dir", "tests/fixtures"])
const r = runCli(["render", "cover", "--title", "E2E", "--slots", "icon=mic", "--out", out])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
const result = JSON.parse(r.stdout)
expect(result.status).toBe("rendered")
@ -55,14 +50,14 @@ describe("e2e — full CLI render", () => {
test("render without icon succeeds", () => {
const out = path.join(TMP, "e2e-no-icon.png")
const r = runCli(["render", "cover", "--title", "No Icon", "--out", out, "--template-dir", "tests/fixtures"])
const r = runCli(["render", "cover", "--title", "No Icon", "--out", out])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
assertPng1024(out)
})
test("render with subtitle", () => {
const out = path.join(TMP, "e2e-sub.png")
const r = runCli(["render", "cover", "--title", "Main", "--subtitle", "Sub", "--out", out, "--template-dir", "tests/fixtures"])
const r = runCli(["render", "cover", "--title", "Main", "--subtitle", "Sub", "--out", out])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
assertPng1024(out)
})

View file

@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<!-- slot: name=icon, x=312, y=180, w=400, h=400, fit=contain, recolor=accent -->
<!-- slot: name=sub-icon, x=412, y=600, w=200, h=200, fit=contain, recolor=accent -->
<!-- slot: name=badge, x=780, y=780, w=180, h=180, fit=contain, recolor=none, bg=surface, border=accent, radius=0.5 -->
<rect width="1024" height="1024" fill="{{base}}" />
<text x="512" y="870" font-family="Geist Sans, sans-serif" font-size="72" font-weight="700" fill="{{fg}}" text-anchor="middle">{{title}}</text>
<text x="512" y="930" font-family="Geist Sans, sans-serif" font-size="36" font-weight="400" fill="{{muted}}" text-anchor="middle">{{subtitle}}</text>
</svg>

Before

Width:  |  Height:  |  Size: 744 B

View file

@ -1,13 +0,0 @@
import { readFileSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "..", "fixtures")
export const FIXTURES_DIR_ABS = FIXTURES_DIR
export function loadFixture(name: string): string {
const fixturePath = path.join(FIXTURES_DIR, `${name}.svg`)
return readFileSync(fixturePath, "utf-8")
}

View file

@ -1,25 +1,22 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
import { describe, test, expect, beforeAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "node:fs"
import path from "node:path"
import os from "node:os"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
import { loadBrand } from "../src/config"
import { loadTemplate, buildSvg, computeHash } from "../src/render"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const TMP = path.join(os.tmpdir(), `draw-image-idempotency-${process.pid}`)
const TMP = "/tmp/draw-image-idempotency"
beforeAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
mkdirSync(TMP, { recursive: true })
})
afterAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
})
function renderCli(template: string, title: string, out: string, slots?: string): { status: number; stdout: string; stderr: string } {
const args = ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), "render", template, "--title", title, "--out", out, "--template-dir", "tests/fixtures"]
const args = ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), "render", template, "--title", title, "--out", out]
if (slots) args.push("--slots", slots)
return spawnSync("node", args, { encoding: "utf-8", cwd: DRAW_IMAGE_DIR })
}

View file

@ -1,44 +1,33 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync, mkdtempSync } from "node:fs"
import { describe, test, expect, beforeAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
import path from "node:path"
import os from "node:os"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
import { loadBrand } from "../src/config"
import { buildSvg, computeHash } from "../src/render"
import { loadFixture } from "./helpers/fixtures"
import { loadTemplate, buildSvg, computeHash } from "../src/render"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const TMP = path.join(os.tmpdir(), `draw-image-integration-${process.pid}`)
const TMP = "/tmp/draw-image-integration"
beforeAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
mkdirSync(TMP, { recursive: true })
})
afterAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
})
function renderToPng(svg: string, outPath: string): void {
const tmpDir = mkdtempSync(path.join(os.tmpdir(), `draw-image-test-${process.pid}-`))
const tmpSvg = path.join(tmpDir, "input.svg")
try {
writeFileSync(tmpSvg, svg)
const tmpSvg = path.join(TMP, "input.svg")
require("node:fs").writeFileSync(tmpSvg, svg)
const r = spawnSync("node", [path.join(DRAW_IMAGE_DIR, "render.mjs"), tmpSvg, outPath], {
encoding: "utf-8",
})
if (r.status !== 0) throw new Error(`render.mjs failed: ${r.stderr || r.stdout}`)
} finally {
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true })
}
}
describe("integration — render pipeline", () => {
test("renders valid PNG 1024x1024", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const args = { template: "cover", title: "Test", out: path.join(TMP, "cover.png") }
const hash = computeHash(brand, templateSvg, args)
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
@ -62,7 +51,7 @@ describe("integration — render pipeline", () => {
test("creates output directory recursively", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const args = { template: "cover", title: "Deep", out: path.join(TMP, "a", "b", "c", "cover.png") }
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
const outPath = path.join(TMP, "a", "b", "c", "cover.png")

View file

@ -1,38 +1,27 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync, mkdtempSync } from "node:fs"
import { describe, test, expect, beforeAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "node:fs"
import path from "node:path"
import os from "node:os"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
import { loadBrand } from "../src/config"
import { buildSvg } from "../src/render"
import { loadFixture } from "./helpers/fixtures"
import { loadTemplate, buildSvg } from "../src/render"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const TMP = path.join(os.tmpdir(), `draw-image-optional-integration-${process.pid}`)
const TMP = "/tmp/draw-image-optional-integration"
beforeAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
mkdirSync(TMP, { recursive: true })
})
afterAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
})
function renderToPng(svg: string, outPath: string): void {
const tmpDir = mkdtempSync(path.join(os.tmpdir(), `draw-image-test-${process.pid}-`))
const tmpSvg = path.join(tmpDir, "input.svg")
try {
const tmpSvg = path.join(TMP, "input.svg")
writeFileSync(tmpSvg, svg)
const r = spawnSync("node", [path.join(DRAW_IMAGE_DIR, "render.mjs"), tmpSvg, outPath], {
encoding: "utf-8",
})
if (r.status !== 0) throw new Error(`render.mjs failed: ${r.stderr || r.stdout}`)
} finally {
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true })
}
}
function assertPng(filePath: string) {
@ -51,7 +40,7 @@ function assertPng(filePath: string) {
describe("integration — optional subtitle and badge", () => {
test("render without subtitle and without badge → valid PNG", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const args = { template: "cover", title: "Bare" }
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
const outPath = path.join(TMP, "bare.png")
@ -61,7 +50,7 @@ describe("integration — optional subtitle and badge", () => {
test("render with subtitle and badge → valid PNG", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const args = {
template: "cover",
title: "Full",

View file

@ -2,8 +2,7 @@ import { describe, test, expect } from "vitest"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { loadBrand } from "../src/config"
import { buildSvg } from "../src/render"
import { loadFixture } from "./helpers/fixtures"
import { loadTemplate, buildSvg } from "../src/render"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
@ -14,7 +13,7 @@ const SUBTITLE_Y = 'y="930"'
describe("unit — optional badge background", () => {
test("empty badge slot renders no bg/border rect", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Badge" }, DRAW_IMAGE_DIR)
expect(svg).not.toContain(BADGE_RECT)
expect(svg).not.toContain('x="780" y="780"')
@ -22,7 +21,7 @@ describe("unit — optional badge background", () => {
test("filled badge slot renders bg/border rect", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const svg = buildSvg(templateSvg, brand, {
template: "cover",
title: "With Badge",
@ -37,7 +36,7 @@ describe("unit — optional badge background", () => {
describe("unit — optional subtitle", () => {
test("empty subtitle removes the subtitle text line", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Sub" }, DRAW_IMAGE_DIR)
expect(svg).not.toContain(SUBTITLE_Y)
expect(svg).not.toContain("{{subtitle}}")
@ -45,7 +44,7 @@ describe("unit — optional subtitle", () => {
test("set subtitle keeps the subtitle text line", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const svg = buildSvg(templateSvg, brand, {
template: "cover",
title: "Main",

View file

@ -3,10 +3,9 @@ import { existsSync, rmSync, mkdirSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { loadBrand } from "../src/config"
import { buildSvg } from "../src/render"
import { loadTemplate, buildSvg } from "../src/render"
import { parseSlots } from "../src/slot-parser"
import { resolveSlotContent } from "../src/resolve"
import { loadFixture } from "./helpers/fixtures"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
@ -20,7 +19,7 @@ beforeAll(() => {
describe("integration — slot resolution", () => {
test("icon=mic resolves lucide icon and recolors to accent", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const slots = parseSlots(templateSvg)
const iconSlot = slots.find((s) => s.name === "icon")!
expect(iconSlot).toBeDefined()
@ -35,7 +34,7 @@ describe("integration — slot resolution", () => {
test("icon=play resolves lucide icon", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const slots = parseSlots(templateSvg)
const iconSlot = slots.find((s) => s.name === "icon")!
const resolved = resolveSlotContent(iconSlot, brand, DRAW_IMAGE_DIR, "play")
@ -44,7 +43,7 @@ describe("integration — slot resolution", () => {
})
test("badge slot has bg=surface and border=accent", () => {
const templateSvg = loadFixture("cover")
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const slots = parseSlots(templateSvg)
const badgeSlot = slots.find((s) => s.name === "badge")!
expect(badgeSlot.bg).toBe("surface")
@ -54,7 +53,7 @@ describe("integration — slot resolution", () => {
test("buildSvg inserts slot content into final SVG", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const svg = buildSvg(templateSvg, brand, {
template: "cover",
title: "With Icon",
@ -68,7 +67,7 @@ describe("integration — slot resolution", () => {
test("buildSvg without slots renders template defaults", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Icon" }, DRAW_IMAGE_DIR)
expect(svg).toContain("No Icon")
expect(svg).not.toContain("<!-- slot:")

View file

@ -109,53 +109,6 @@
}
}
}
},
"ollama-cloud": {
"models": {
"deepseek-v4-flash:0731": {
"name": "DS4F-0731",
"reasoning": true,
"modalities": {
"input": [
"text"
],
"output": [
"text"
]
},
"limit": {
"context": 200000,
"input": 200000,
"output": 65536
},
"options": {
"reasoningEffort": "max"
},
"variants": {
"max": {
"reasoningEffort": "max"
},
"none": {
"disabled": true
},
"minimal": {
"disabled": true
},
"low": {
"disabled": true
},
"medium": {
"disabled": true
},
"high": {
"disabled": true
},
"xhigh": {
"disabled": true
}
}
}
}
}
},
"permission": {
@ -236,11 +189,6 @@
"python3 */spec-status.py*": "deny",
"python *spec-status.py*": "deny",
"python */spec-status.py*": "deny",
"python3 *project-status.py*": "deny",
"python3 .opencode/scripts/project-status.py*": "deny",
"python3 */project-status.py*": "deny",
"python *project-status.py*": "deny",
"python */project-status.py*": "deny",
"mkdir*": "allow",
"git branch -D *": "ask",
"git branch -d *": "ask",
@ -328,26 +276,7 @@
"git commit *": "deny",
"gh pr create *": "deny",
"gh pr merge *": "deny",
"gh issue create *": "deny",
"gh api * -X DELETE *": "deny",
"gh api -X DELETE *": "deny",
"gh api * --method DELETE *": "deny",
"gh api * --method delete *": "deny",
"gh repo transfer *": "deny",
"git push --force*": "ask",
"git push -f*": "ask",
"git push * --force*": "ask",
"git push * -f*": "ask",
"git push * :*": "ask",
"git tag -d *": "ask",
"git -C * push --force*": "ask",
"git -C * push -f*": "ask",
"git -C * push * --force*": "ask",
"git -C * push * -f*": "ask",
"git -C * push * :*": "ask",
"git -C * tag -d *": "ask"
"gh issue create *": "deny"
}
},
"agent": {
@ -358,7 +287,8 @@
"create_pr": true,
"create_issue": true,
"merge_pr": false,
"post_review": false
"post_review": false,
"post_docs_review": false
}
},
"reviewer": {
@ -367,7 +297,18 @@
"create_pr": false,
"create_issue": true,
"merge_pr": false,
"post_review": true
"post_review": true,
"post_docs_review": false
}
},
"docs-reviewer": {
"tools": {
"commit": true,
"create_pr": false,
"create_issue": true,
"merge_pr": false,
"post_review": false,
"post_docs_review": true
}
},
"memory-syncer": {
@ -376,7 +317,8 @@
"create_pr": false,
"create_issue": true,
"merge_pr": false,
"post_review": false
"post_review": false,
"post_docs_review": false
}
}
},

View file

@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Check ADR references in markdown files for dangling pointers.
Scans ``.md`` files in the repo for ``ADR-NNN`` references and verifies
that a corresponding ``docs/decisions/NNN-*.md`` file exists. Dangling
references (typos, forward-refs) fail CI symmetric to
``check-permissions.py`` (ADR-006 pattern).
Self-reference is excluded: if the current file's name starts with
``NNN-``, a reference to ``ADR-NNN`` inside it is OK (an ADR file may
mention its own number).
What is NOT caught: wrong existing refs (``ADR-017`` exists but is
semantically wrong for a given PR) that requires semantic analysis.
"""
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
ADR_DIR = REPO_ROOT / "docs" / "decisions"
ADR_REF_RE = re.compile(r"\bADR-(\d{3})\b")
EXCLUDE_DIRS = {"node_modules", ".git", "app_data", ".opencode"}
def _is_excluded(path: Path) -> bool:
"""True if ``path`` is inside an excluded directory (node_modules, .git, ...)."""
try:
rel = path.relative_to(REPO_ROOT)
except ValueError:
return True
parts = rel.parts
return any(excl in parts for excl in EXCLUDE_DIRS)
def find_md_files() -> list[Path]:
"""Return all ``.md`` files under REPO_ROOT, excluding node_modules/.git/etc."""
results: list[Path] = []
for md_file in REPO_ROOT.rglob("*.md"):
if _is_excluded(md_file):
continue
results.append(md_file)
return sorted(results)
def find_adr_refs(md_file: Path) -> list[tuple[int, str]]:
"""Return ``[(line_number, adr_number), ...]`` for every ``ADR-NNN`` in ``md_file``."""
refs: list[tuple[int, str]] = []
text = md_file.read_text(encoding="utf-8", errors="replace")
for line_no, line in enumerate(text.splitlines(), start=1):
for match in ADR_REF_RE.finditer(line):
refs.append((line_no, match.group(1)))
return refs
def is_self_reference(md_file: Path, adr_number: str) -> bool:
"""True if ``md_file``'s name starts with ``NNN-`` where NNN == adr_number."""
return md_file.name.startswith(f"{adr_number}-")
def adr_exists(adr_number: str) -> bool:
"""True if ``docs/decisions/NNN-*.md`` exists for the given number."""
if not ADR_DIR.exists():
return False
return any(ADR_DIR.glob(f"{adr_number}-*.md"))
def main() -> None:
all_violations: list[str] = []
for md_file in find_md_files():
refs = find_adr_refs(md_file)
for ref_line, ref_number in refs:
if is_self_reference(md_file, ref_number):
continue
if not adr_exists(ref_number):
all_violations.append(
f" {md_file.relative_to(REPO_ROOT)}:{ref_line}: "
f"ADR-{ref_number} reference, but "
f"docs/decisions/{ref_number}-*.md does not exist"
)
if not all_violations:
print("OK: No dangling ADR references.")
sys.exit(0)
print("FAIL: Dangling ADR references:\n")
for v in all_violations:
print(v)
print(f"\nTotal: {len(all_violations)} violation(s)")
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Pipeline-status oracle: determine PR phase in 6-phase PR pipeline.
"""Pipeline-status oracle: determine PR phase in 7-phase PR pipeline.
Reads facts from GitHub (gh CLI), git, and memory files to deterministically
derive the current pipeline phase of a PR no state file, like ``git status``
@ -10,26 +10,24 @@ Usage:
python3 config/scripts/pipeline-status.py <PR_NUMBER> # single PR status
python3 config/scripts/pipeline-status.py # table of open PRs
Six phases:
Seven phases:
1. ISSUE GitHub issue exists and linked via Closes/Fixes #N
2. IMPLEMENT PR exists + PR body has 4 required headings
3. CI all checks on PR head SHA completed & success (statusCheckRollup)
4. REVIEW APPROVE found in PR comments
5. MERGE PR state is MERGED
6. MEMORY PR#N distilled into repos/{host}/{org}/{repo}.md
2. IMPLEMENT PR exists + handoff file docs/handoff/pr-N-slug.md in diff
3. DOCS handoff valid (4 sections) + mandatory ADR
4. CI all checks on PR head SHA completed & success (statusCheckRollup)
5. REVIEW APPROVE found in PR comments
6. MERGE PR state is MERGED
7. MEMORY PR#N distilled into repos/{host}/{org}/{repo}.md
"""
from __future__ import annotations
import functools
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
@ -51,8 +49,13 @@ _MEMORY_BASE = os.environ.get(
str(REPO_ROOT / "app_data" / "opencode-memory"),
)
MEMORY_DIR = Path(_MEMORY_BASE) / "repos"
HANDOFF_DIR = REPO_ROOT / "docs" / "handoff"
ADR_DIR = REPO_ROOT / "docs" / "decisions"
PROJECT_MAP_DIR = REPO_ROOT / "docs" / "project-map"
PHASE_NAMES = ["ISSUE", "IMPLEMENT", "CI", "REVIEW", "MERGE", "MEMORY"]
REQUIRED_SECTIONS = ["## Что сделано", "## Почему", "## Pending", "## Watch out"]
PHASE_NAMES = ["ISSUE", "IMPLEMENT", "DOCS", "CI", "REVIEW", "MERGE", "MEMORY"]
CI_WAIT_TIMEOUT = 300
CI_POLL_INTERVAL = 10
@ -68,6 +71,7 @@ REVIEW_VERDICT_RE = re.compile(
r"## Code Review Summary.*?###\s*Verdict:\s*(\w+)",
re.IGNORECASE | re.DOTALL,
)
DOCS_REVIEW_RE = re.compile(r"Docs Review", re.IGNORECASE)
JSON_FIELD_RE = re.compile(r'"(\w+)"\s*:\s*"?([^",}]*)"?', re.IGNORECASE)
@ -87,135 +91,10 @@ class PhaseResult:
detail: str
def _forgejo_request(method: str, path: str, body: dict | None = None) -> tuple[int, str, str]:
"""Forgejo REST API call. Returns (status_code, body_text, error).
On non-2xx returns (status_code, body_text, ""). ``urlopen`` raises
HTTPError for non-2xx which carries the body; we surface it so callers
can branch on the HTTP status. Network/parse errors return (0, "", err).
"""
base = os.environ.get("FORGEJO_URL")
token = os.environ.get("FORGEJO_TOKEN")
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
data = None
if body is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(body).encode()
req = urllib.request.Request( # noqa: S310 - base URL is operator-configured
f"{base}/api/v1{path}", method=method, headers=headers, data=data
)
try:
with urllib.request.urlopen(req) as r: # noqa: S310 - base URL is operator-configured
return r.status, r.read().decode("utf-8", "replace"), ""
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace"), ""
except OSError as e:
return 0, "", str(e)
def _forgejo_ci_rollup(repo: str, sha: str) -> tuple[int, str, str]:
"""Build a GitHub-style statusCheckRollup from the Forgejo commit status."""
sc, text, err = _forgejo_request("GET", f"/repos/{repo}/commits/{sha}/status")
if sc != 200:
return 1, "", err or f"commit status HTTP {sc}"
combined = json.loads(text)
rollup: list[dict] = []
for s in combined.get("statuses", []):
st = s.get("status", "").lower()
if st == "success":
rollup.append({"status": "COMPLETED", "conclusion": "SUCCESS"})
elif st == "pending":
rollup.append({"status": "IN_PROGRESS", "conclusion": ""})
elif st in ("failure", "error"):
rollup.append({"status": "COMPLETED", "conclusion": "FAILURE"})
else:
rollup.append({"status": "QUEUED", "conclusion": ""})
return 0, json.dumps({"statusCheckRollup": rollup}), ""
def _forgejo_pr_view(repo: str, n: str, fields: str) -> tuple[int, str, str]: # noqa: PLR0911
"""Translate ``gh pr view N --json <fields>`` to Forgejo API calls."""
rc, pr_text, err = _forgejo_request("GET", f"/repos/{repo}/pulls/{n}")
if rc != 200:
return 1, "", err or f"PR API HTTP {rc}"
pr = json.loads(pr_text)
if fields == "statusCheckRollup":
return _forgejo_ci_rollup(repo, pr["head"]["sha"])
if fields == "comments":
sc, comments_text, ce = _forgejo_request("GET", f"/repos/{repo}/issues/{n}/comments")
if sc != 200:
return 1, "", ce or f"comments HTTP {sc}"
comments = json.loads(comments_text)
return 0, json.dumps({"comments": [{"body": c.get("body", "")} for c in comments]}), ""
if fields == "state":
state = "MERGED" if pr.get("merged") else pr.get("state", "").upper()
return 0, json.dumps({"state": state}), ""
if fields:
return 0, json.dumps({fields: pr.get(fields, "")}), ""
return 0, pr_text, ""
def _forgejo_pr_dispatch(repo: str, args: list[str]) -> tuple[int, str, str]:
"""Translate ``gh pr ...`` argv to Forgejo API calls."""
action = args[1] if len(args) > 1 else ""
num = args[2] if len(args) > 2 and args[2].lstrip("-").isdigit() else None
if action == "view" and num is not None:
fields_idx = args.index("--json") if "--json" in args else -1
fields = args[fields_idx + 1] if fields_idx >= 0 else ""
return _forgejo_pr_view(repo, num, fields)
if action == "list":
sc, out_text, e = _forgejo_request("GET", f"/repos/{repo}/pulls?state=open")
if sc != 200:
return 1, "", e or f"pr list HTTP {sc}"
pulls = json.loads(out_text)
return 0, json.dumps([{"number": p["number"]} for p in pulls]), ""
return 1, "", f"gh pr argv {args!r} not supported in Forgejo mode"
def _forgejo_gh_dispatch(args: list[str]) -> tuple[int, str, str] | None: # noqa: PLR0911
"""Translate a ``gh`` argv to a Forgejo API call. Returns None to defer.
Returns ``(rc, stdout, stderr)`` shaped like ``run_cmd`` so callers stay
unchanged. Returns ``None`` if ``FORGEJO_URL`` is unset (defer to gh) or
the argv is not a supported gh subcommand.
"""
if not os.environ.get("FORGEJO_URL"):
return None
if not os.environ.get("FORGEJO_TOKEN"):
return 1, "", "Forgejo mode requires FORGEJO_TOKEN"
if not args or args[0] != "gh":
return None
repo_idx = args.index("--repo") if "--repo" in args else -1
repo = args[repo_idx + 1] if repo_idx >= 0 else None
if repo is None:
return 1, "", "Forgejo mode requires --repo owner/name"
sub = args[1] if len(args) > 1 else ""
if sub == "auth" and "status" in args:
return 0, "", ""
if sub == "pr":
return _forgejo_pr_dispatch(repo, args)
if sub == "issue" and len(args) > 2 and args[2].lstrip("-").isdigit():
sc, out_text, e = _forgejo_request("GET", f"/repos/{repo}/issues/{args[2]}")
if sc == 200:
return 0, out_text, ""
return 1, "", e or f"issue HTTP {sc}"
return 1, "", f"gh argv {args!r} not supported in Forgejo mode"
def run_cmd(args: list[str]) -> tuple[int, str, str]:
"""Run a command, return (returncode, stdout, stderr).
Forgejo dispatch (ADR-forgejo): when ``FORGEJO_URL`` is set, ``gh`` argv
is translated to a Forgejo REST API call instead of spawning ``gh``.
GitHub users (no ``FORGEJO_URL``) see byte-identical behaviour the
``gh`` / ``git`` subprocess path is untouched.
"""
if args and args[0] == "gh":
result = _forgejo_gh_dispatch(args)
if result is not None:
return result
proc = subprocess.run(args, capture_output=True, text=True, check=False)
return proc.returncode, proc.stdout, proc.stderr
"""Run a command, return (returncode, stdout, stderr)."""
result = subprocess.run(args, capture_output=True, text=True, check=False)
return result.returncode, result.stdout, result.stderr
@dataclass(frozen=True)
@ -300,23 +179,13 @@ def parse_remote_url(url: str) -> tuple[str, str, str]:
raise ValueError(f"Cannot parse remote URL: {url}")
def _resolve_memory_base() -> tuple[Path, str]:
"""Derive (memory_dir, repo_name) from ``git remote get-url origin``."""
def get_memory_file_path() -> Path:
"""Derive memory file path from ``git remote get-url origin``."""
rc, out, err = run_cmd(["git", "remote", "get-url", "origin"])
if rc != 0:
raise RuntimeError(f"Cannot get git remote URL: {err.strip()}")
host, org, repo = parse_remote_url(out.strip())
return MEMORY_DIR / host / org, repo
def get_memory_files() -> list[Path]:
"""All ``repo*.md`` sorted by mtime descending (newest first)."""
base_dir, repo = _resolve_memory_base()
if not base_dir.exists():
return []
rot_pattern = re.compile(rf"^{re.escape(repo)}(-\d+)?$")
files = [f for f in base_dir.glob("*.md") if rot_pattern.fullmatch(f.stem)]
return sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)
return MEMORY_DIR / host / org / f"{repo}.md"
@functools.cache
@ -379,29 +248,114 @@ def check_issue(pr_number: int) -> PhaseResult:
def check_implement(pr_number: int) -> PhaseResult:
"""Phase 2: IMPLEMENT — PR exists + PR body has 4 required headings."""
"""Phase 2: IMPLEMENT — PR exists + handoff file in diff."""
rc, out, _ = run_cmd(
["gh", "pr", "view", str(pr_number), "--json", "body", "--repo", get_repo_full_name()]
["gh", "pr", "view", str(pr_number), "--json", "files", "--repo", get_repo_full_name()]
)
if rc != 0:
return PhaseResult(PhaseStatus.NOT_DONE, f"PR #{pr_number} не существует")
try:
pr_body = json.loads(out).get("body", "") or ""
except (json.JSONDecodeError, ValueError):
return PhaseResult(
PhaseStatus.NOT_DONE, f"PR #{pr_number}: не удалось распарсить JSON body"
)
required = ["## Что сделано", "## Почему", "## Watch out", "## Pending"]
missing = [h for h in required if h not in pr_body]
files = re.findall(r'"path"\s*:\s*"([^"]+)"', out)
pattern = f"docs/handoff/pr-{pr_number}-"
handoff_files = [f for f in files if pattern in f]
if not handoff_files:
return PhaseResult(PhaseStatus.NOT_DONE, f"handoff {pattern}*.md не найден в diff")
return PhaseResult(PhaseStatus.DONE, f"handoff: {Path(handoff_files[0]).name}")
def check_docs(pr_number: int) -> PhaseResult:
"""Phase 3: DOCS — handoff valid (4 sections) + mandatory ADR + project map
+ docs-reviewer marker.
Deterministic checks:
1. Handoff file `docs/handoff/pr-N-*.md` exists.
2. 4 sections present in handoff content.
3. ADR file `docs/decisions/*-pr-N-*.md` exists.
4. Project map `docs/project-map/README.md` exists (ADR-028 contract).
5. PR comment with heading `## Docs Review Summary` from docs-reviewer (proves it ran).
Without the docs-reviewer comment, DOCS is NOT_DONE subagent may have written
handoff+ADR without docs-reviewer actually validating them. Symmetric to
``check_review`` which looks for ``## Code Review Summary`` in PR comments.
"""
handoff_files = sorted(HANDOFF_DIR.glob(f"pr-{pr_number}-*.md"))
if not handoff_files:
return PhaseResult(PhaseStatus.NOT_DONE, f"handoff pr-{pr_number}-*.md не найден")
content = handoff_files[0].read_text()
missing = [s for s in REQUIRED_SECTIONS if s not in content]
if missing:
return PhaseResult(
PhaseStatus.NOT_DONE, f"PR body не содержит heading'и: {', '.join(missing)}"
return PhaseResult(PhaseStatus.NOT_DONE, f"отсутствуют секции: {', '.join(missing)}")
adr_result = check_adr(pr_number)
if adr_result.status != PhaseStatus.DONE:
return adr_result
pm_result = check_project_map()
if pm_result.status != PhaseStatus.DONE:
return pm_result
return _check_docs_reviewer_comment(pr_number)
def _check_docs_reviewer_comment(pr_number: int) -> PhaseResult:
"""Phase 3 part: PR comment with 'Docs Review' heading proves docs-reviewer ran."""
rc, out, _ = run_cmd(
["gh", "pr", "view", str(pr_number), "--json", "comments", "--repo", get_repo_full_name()]
)
if rc != 0:
return PhaseResult(PhaseStatus.AMBIGUOUS, "не удалось получить комментарии PR")
comment_bodies = _extract_comment_bodies(out)
for body in comment_bodies:
if DOCS_REVIEW_RE.search(body):
return PhaseResult(PhaseStatus.DONE, "handoff валиден, ADR, docs-review отработал")
return PhaseResult(
PhaseStatus.NOT_DONE,
"docs-reviewer не запущен — запусти @docs-reviewer (pre-merge)",
)
def check_adr(pr_number: int) -> PhaseResult:
"""Phase 3 part: ADR is mandatory for every PR. Find by PR# in filename."""
pattern = f"*-pr-{pr_number}-*.md"
adr_files = sorted(ADR_DIR.glob(pattern)) if ADR_DIR.exists() else []
if adr_files:
return PhaseResult(PhaseStatus.DONE, f"ADR: {adr_files[0].name}")
return PhaseResult(
PhaseStatus.NOT_DONE,
f"ADR *-pr-{pr_number}-*.md не найден. "
f"Создай через bash .opencode/scripts/scaffold-handoff.sh {pr_number} <slug>",
)
def check_project_map() -> PhaseResult:
"""Phase 3 part: docs/project-map/README.md exists (mandatory project map).
Minimal guard against silent docs-reviewer failure. Does NOT validate
frontmatter or module-level files see ADR-028 for the contract.
"""
if not PROJECT_MAP_DIR.exists():
return PhaseResult(
PhaseStatus.NOT_DONE,
f"директория {PROJECT_MAP_DIR} не найдена — "
"docs-reviewer должен создать initial README.md (docs-reviewer.md:82-84)",
)
readme = PROJECT_MAP_DIR / "README.md"
if readme.exists():
size = readme.stat().st_size
return PhaseResult(PhaseStatus.DONE, f"project map: README.md ({size} bytes)")
return PhaseResult(
PhaseStatus.NOT_DONE,
"docs/project-map/README.md не найден — "
"docs-reviewer должен создать initial map (docs-reviewer.md:244)",
)
return PhaseResult(PhaseStatus.DONE, "PR body: 4 heading'а валидны")
def check_ci(pr_number: int) -> PhaseResult:
"""Phase 3: CI — all checks on PR head SHA completed & success.
"""Phase 4: CI — all checks on PR head SHA completed & success.
Uses ``gh pr view --json statusCheckRollup`` which aggregates ALL
workflows for the PR head SHA (CI, CI (always), ADR check, etc.).
@ -545,7 +499,7 @@ def _extract_comment_bodies(json_str: str) -> list[str]:
def check_review(pr_number: int) -> PhaseResult:
"""Phase 5: REVIEW — APPROVE found in PR comments from code reviewer.
Looks for '## Code Review Summary' heading
Looks for '## Code Review Summary' heading (NOT '## Docs Review Summary')
with '### Verdict: APPROVE'. Only the latest reviewer comment counts
if reviewer changed from APPROVE to REQUEST_CHANGES, NOT_DONE.
"""
@ -595,24 +549,24 @@ def check_merge(pr_number: int) -> PhaseResult:
def check_memory(pr_number: int) -> PhaseResult:
"""Phase 7: MEMORY — PR#N distilled into memory file."""
try:
files = get_memory_files()
memory_file = get_memory_file_path()
except (RuntimeError, ValueError) as exc:
return PhaseResult(PhaseStatus.NOT_DONE, str(exc))
if not files:
if not memory_file.exists():
return PhaseResult(
PhaseStatus.NOT_DONE,
"memory files не найдены",
f"memory file не существует: {memory_file.name}",
)
content = memory_file.read_text()
pattern = f"PR#{pr_number}"
for f in files:
if pattern in f.read_text():
return PhaseResult(PhaseStatus.DONE, f"{pattern} в {f.name}")
if pattern in content:
return PhaseResult(PhaseStatus.DONE, f"{pattern} в {memory_file.name}")
return PhaseResult(
PhaseStatus.NOT_DONE,
f"{pattern} не найден в {len(files)} файл(ах)",
f"{pattern} не найден в {memory_file.name}",
)
@ -630,6 +584,7 @@ def get_pr_title(pr_number: int) -> str:
NEXT_ACTIONS: dict[str, str] = {
"ISSUE": "dispatch subagent (subagent_type=general, template=A) for PR #N",
"IMPLEMENT": "dispatch subagent (subagent_type=general, template=A) for PR #N",
"DOCS": "dispatch subagent (subagent_type=docs-reviewer, template=B) for PR #N",
"CI": "проверь статус CI вручную (gh run view)",
"REVIEW": "dispatch subagent (subagent_type=reviewer, template=C) for PR #N",
"MERGE": "call merge_pr tool with pr_number=N",
@ -667,10 +622,11 @@ def get_next_action_review(result: PhaseResult) -> str:
def run_all_checks(pr_number: int) -> list[PhaseResult]:
"""Run all 6 phase checks, return results in order."""
"""Run all 7 phase checks, return results in order."""
return [
check_issue(pr_number),
check_implement(pr_number),
check_docs(pr_number),
check_ci(pr_number),
check_review(pr_number),
check_merge(pr_number),

File diff suppressed because it is too large Load diff

View file

@ -1,96 +0,0 @@
#!/usr/bin/env python3
"""Project contract: single source of truth for project types, stacks,
expected structure, frontend markers.
Imported by both ``spec-status.py`` and ``project-status.py`` via
``importlib.util.spec_from_file_location`` (no ``sys.path`` mutation).
Stdlib-only imports.
Contract symbols:
ProjectType StrEnum with 7 members (incl. MCP_SERVER + UNKNOWN)
VALID_TYPES set[str] excluding "unknown"
STACK_REQUIRED dict[type -> list[str]] of mandatory stack items
STRUCTURE_EXPECTED dict[type -> list[str]] of expected top-level dirs/files
FRONTEND_STACK_MARKERS dict with keys for fullstack frontend detection
"""
from __future__ import annotations
from enum import StrEnum
class ProjectType(StrEnum):
"""Project type enum (auto-detected or spec-declared)."""
FULLSTACK = "fullstack"
BACKEND = "backend"
CLI = "cli"
BOT = "bot"
WORKER = "worker"
MCP_SERVER = "mcp-server"
UNKNOWN = "unknown"
# Excludes "unknown" — used by spec-status PROJECT_TYPE phase validation.
VALID_TYPES: set[str] = {t.value for t in ProjectType if t != ProjectType.UNKNOWN}
# Mandatory stack items per project type (spec-status Phase 2 STACK).
# `uv` stays in ALL stacks (build-tool, mentioned in stack.md).
STACK_REQUIRED: dict[str, list[str]] = {
"backend": ["fastapi", "tortoise", "uv", "pytest", "ruff", "mypy", "loguru", "pydantic"],
"fullstack": [
"fastapi",
"tortoise",
"svelte",
"sveltekit",
"biome",
"uv",
"ruff",
"mypy",
"pytest",
"tailwind",
"shadcn",
"typescript",
"mobile-first",
],
"mcp-server": ["fastapi", "mcp", "patchright", "uv"],
"cli": ["typer", "uv", "hatchling", "ruff", "mypy", "pytest"],
"bot": ["aiogram", "fastapi", "uv", "ruff", "mypy", "pytest"],
"worker": ["prefect", "uv", "ruff", "mypy", "pytest"],
}
# Expected top-level structure per type (project-status check_structure).
# BACKEND is resolved dynamically by ``_expected_backend_paths`` (src/<pkg>/...),
# so it is NOT in this dict. mcp-server structure is TBD out of scope.
# Keys are strings (project type values), NOT ProjectType enum members —
# kept as plain strings for portability across both oracles.
STRUCTURE_EXPECTED: dict[str, list[str]] = {
"fullstack": ["backend", "frontend"],
"cli": ["src"], # src/<package>/ — checked generically
"bot": ["src/bot.py"],
"worker": ["src/flow.py"],
"unknown": [],
}
# Fullstack frontend stack markers (project-status _check_type_specific_structure).
# `tailwindcss` + `bits-ui` (shadcn-svelte proxy) in package.json deps, plus
# `components.json` (shadcn config) and `tsconfig.json` (TypeScript) existence.
FRONTEND_STACK_MARKERS: dict[str, list[str]] = {
"fullstack_package_deps": ["tailwindcss", "bits-ui"],
"fullstack_files": ["frontend/components.json", "frontend/tsconfig.json"],
}
# Fullstack mobile-first markers (project-status _check_mobile_first, issue #278).
# Distinct from FRONTEND_STACK_MARKERS so the stack check stays focused on the
# Tailwind/shadcn/TS trio. Keys:
# fullstack_files — paths (relative to repo root) that must exist
# for PWA + mobile Playwright + a11y to be present.
# fullstack_app_html_markers — substrings that must appear in app.html <head>.
MOBILE_FIRST_MARKERS: dict[str, list[str]] = {
"fullstack_files": [
"frontend/static/manifest.webmanifest",
"frontend/tests/e2e/mobile.spec.ts",
"frontend/tests/e2e/accessibility.spec.ts",
],
"fullstack_app_html_markers": ["viewport", "manifest"],
}

View file

@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Create handoff + ADR templates for a PR.
# Usage: bash config/scripts/scaffold-handoff.sh <PR#> <slug>
# Idempotent: does not overwrite existing files.
set -euo pipefail
PR="${1:?Usage: scaffold-handoff.sh <PR#> <slug>}"
SLUG="${2:?Usage: scaffold-handoff.sh <PR#> <slug>}"
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
if [ -z "$REPO_ROOT" ]; then
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
fi
HANDOFF_DIR="$REPO_ROOT/docs/handoff"
ADR_DIR="$REPO_ROOT/docs/decisions"
mkdir -p "$HANDOFF_DIR" "$ADR_DIR"
HANDOFF="$HANDOFF_DIR/pr-${PR}-${SLUG}.md"
TODAY="$(date +%Y-%m-%d)"
if [ -f "$HANDOFF" ]; then
echo "handoff already exists: $HANDOFF"
else
cat > "$HANDOFF" <<EOF
---
pr: ${PR}
title: <заполни>
---
## Что сделано
<заполни>
## Почему
<заполни>
## Pending
<заполни, или «—»>
## Watch out
<заполни, или «—»>
EOF
echo "created: $HANDOFF"
fi
EXISTING_ADR="$(ls "$ADR_DIR"/*-pr-${PR}-${SLUG}.md 2>/dev/null | head -n1 || true)"
if [ -n "$EXISTING_ADR" ]; then
ADR="$EXISTING_ADR"
echo "ADR already exists: $ADR"
else
NEXT_N="$(ls "$ADR_DIR" 2>/dev/null | grep -E '^[0-9]{3}-' | wc -l | awk '{print $1+1}')"
NN="$(printf "%03d" "$NEXT_N")"
ADR="$ADR_DIR/${NN}-pr-${PR}-${SLUG}.md"
cat > "$ADR" <<EOF
# ADR-${NN}: <title>
## Статус
Accepted (${TODAY})
## Контекст
<заполни, или «—» если архитектурных решений не было>
## Решение
<заполни, или «—»>
## Альтернативы
<заполни, или «—»>
EOF
echo "created: $ADR"
fi
echo ""
echo "handoff: $HANDOFF"
echo "adr: $ADR"

View file

@ -37,23 +37,13 @@ Nine phases:
from __future__ import annotations
import functools
import importlib.util
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
# Load project_contract.py via importlib.util (no sys.path mutation).
_contract_path = Path(__file__).resolve().parent / "project_contract.py"
_pc_spec = importlib.util.spec_from_file_location("project_contract", _contract_path)
project_contract = importlib.util.module_from_spec(_pc_spec) # type: ignore[arg-type]
_pc_spec.loader.exec_module(project_contract) # type: ignore[union-attr]
def _resolve_repo_root() -> Path:
"""Resolve repo root via git (cwd-aware), fallback to script location."""
@ -77,10 +67,16 @@ PHASE_FILES: dict[int, str] = {
6: "roadmap.md",
}
# Re-exported from project_contract.py for backward compatibility
# (tests use ``ss.VALID_TYPES`` / ``ss.STACK_REQUIRED``).
VALID_TYPES = project_contract.VALID_TYPES
STACK_REQUIRED = project_contract.STACK_REQUIRED
VALID_TYPES = {"backend", "fullstack", "mcp-server", "cli", "bot", "worker"}
STACK_REQUIRED: dict[str, list[str]] = {
"backend": ["fastapi", "tortoise", "uv", "pytest", "ruff", "mypy"],
"fullstack": ["fastapi", "tortoise", "react", "vite", "biome", "uv"],
"mcp-server": ["fastapi", "mcp", "patchright", "uv"],
"cli": ["typer", "uv", "hatchling"],
"bot": ["aiogram", "fastapi", "uv"],
"worker": ["prefect", "uv"],
}
PHASE_NAMES = [
"DETECT",
@ -94,7 +90,7 @@ PHASE_NAMES = [
"EXECUTE",
]
FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n?", re.DOTALL)
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
KV_RE = re.compile(r"^(\w+):\s*(.*?)$", re.MULTILINE)
@ -114,47 +110,8 @@ class PhaseResult:
detail: str
def _forgejo_get(path: str) -> tuple[int, str, str]:
"""Forgejo REST GET. Returns (status_code, body_text, error)."""
base = os.environ.get("FORGEJO_URL")
token = os.environ.get("FORGEJO_TOKEN")
req = urllib.request.Request( # noqa: S310 - operator-configured base URL
f"{base}/api/v1{path}",
headers={"Authorization": f"token {token}", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req) as r: # noqa: S310 - operator-configured base URL
return r.status, r.read().decode("utf-8", "replace"), ""
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace"), ""
except OSError as e:
return 0, "", str(e)
def run_cmd(args: list[str]) -> tuple[int, str, str]:
"""Run a command, return (returncode, stdout, stderr).
Forgejo dispatch: when ``FORGEJO_URL`` is set, ``gh issue view`` is routed
to the Forgejo REST API instead of spawning ``gh``. GitHub users (no
``FORGEJO_URL``) see byte-identical behaviour the gh/git subprocess path
is untouched.
"""
if args and args[0] == "gh" and os.environ.get("FORGEJO_URL"):
if not os.environ.get("FORGEJO_TOKEN"):
return 1, "", "Forgejo mode requires FORGEJO_TOKEN"
repo_idx = args.index("--repo") if "--repo" in args else -1
repo = args[repo_idx + 1] if repo_idx >= 0 else None
if (
args[1:3] == ["issue", "view"]
and repo
and len(args) > 2
and args[2].lstrip("-").isdigit()
):
sc, out_text, err = _forgejo_get(f"/repos/{repo}/issues/{args[2]}")
if sc == 200:
return 0, out_text, ""
return 1, "", err or f"issue HTTP {sc}"
return 1, "", f"gh argv {args!r} not supported in Forgejo mode"
"""Run a command, return (returncode, stdout, stderr)."""
result = subprocess.run(args, capture_output=True, text=True, check=False)
return result.returncode, result.stdout, result.stderr
@ -195,34 +152,22 @@ def get_repo_full_name() -> str:
def parse_frontmatter(content: str) -> dict[str, str]:
"""Parse simple key: value frontmatter (no nested structures).
Strips one balanced pair of surrounding single/double quotes from each
value (e.g. ``type: 'fullstack'`` -> ``fullstack``). Unbalanced quotes
are preserved verbatim.
"""
"""Parse simple key: value frontmatter (no nested structures)."""
match = FRONTMATTER_RE.search(content)
if not match:
return {}
fm_text = match.group(1)
parsed: dict[str, str] = {}
for key, raw in KV_RE.findall(fm_text):
value = raw.strip()
if len(value) >= 2 and value[0] in ('"', "'") and value[-1] == value[0]:
value = value[1:-1]
parsed[key] = value
return parsed
return dict(KV_RE.findall(fm_text))
def read_meta() -> tuple[str, dict[str, str]]:
"""Read docs/spec/meta.md content + parsed frontmatter.
Returns ``("", {})`` if meta.md is missing. Uses ``utf-8-sig`` to
transparently strip a leading BOM if present.
Returns ``("", {})`` if meta.md is missing.
"""
if not META_FILE.exists():
return "", {}
content = META_FILE.read_text(encoding="utf-8-sig")
content = META_FILE.read_text()
return content, parse_frontmatter(content)
@ -284,13 +229,8 @@ def check_stack() -> PhaseResult:
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/stack.md не заполнен")
required = STACK_REQUIRED[ptype]
stack_body = stack_file.read_text()
# Word-boundary regex: ``uv`` does NOT match ``uvicorn``, ``tailwind``
# does NOT match ``tailwindcss``. Case-insensitive.
missing = [
item
for item in required
if not re.search(rf"\b{re.escape(item)}\b", stack_body, re.IGNORECASE)
]
stack_lower = stack_body.lower()
missing = [item for item in required if item not in stack_lower]
if missing:
return PhaseResult(
PhaseStatus.NOT_DONE,
@ -320,7 +260,7 @@ def check_modules() -> PhaseResult:
def check_db_schema() -> PhaseResult:
"""Phase 4: DB_SCHEMA — no_db: true OR db-schema.md filled."""
_content, fm = read_meta()
if fm.get("no_db", "").strip().lower() == "true":
if fm.get("no_db", "").strip().lower() in {"true", '"true"'}:
return PhaseResult(PhaseStatus.DONE, "no_db: true (DB не нужна)")
db_file = SPEC_DIR / PHASE_FILES[4]
if not file_filled(db_file):
@ -364,7 +304,7 @@ def check_confirm() -> PhaseResult:
"""Phase 7: CONFIRM — confirmed: true in meta.md frontmatter."""
_content, fm = read_meta()
val = fm.get("confirmed", "").strip().lower()
if val != "true":
if val not in {"true", '"true"'}:
return PhaseResult(PhaseStatus.NOT_DONE, "confirmed: true отсутствует в frontmatter")
return PhaseResult(PhaseStatus.DONE, "spec подтверждён юзером")
@ -396,7 +336,7 @@ def check_execute() -> PhaseResult:
"""Phase 8: EXECUTE — executed: true + issues created (gh view --repo)."""
_content, fm = read_meta()
val = fm.get("executed", "").strip().lower()
if val != "true":
if val not in {"true", '"true"'}:
return PhaseResult(PhaseStatus.NOT_DONE, "executed: true отсутствует в frontmatter")
roadmap_file = SPEC_DIR / PHASE_FILES[6]
if not roadmap_file.exists():

View file

@ -49,7 +49,7 @@ description: <when to load this skill, in English. Example: Use when ... Also wh
├── memory/SKILL.md
├── python-development/SKILL.md
├── release/SKILL.md
├── project-template/SKILL.md
├── repo-init/SKILL.md
├── run-pipeline/SKILL.md
├── run-tests/SKILL.md
├── spec/SKILL.md

View file

@ -1,153 +0,0 @@
---
name: audit
description: One-command project audit. project-status tool + explore subagent (code-standards) → binary verdict → ask user → create-issue for each problem. Read-only (creates issues, NOT fixes). Also when user says "аудит проекта", "проверь проект", "audit", "проверь архитектуру".
---
# Audit
Линейный flow для аудита существующего проекта. В отличие от
`project-template` check flow (который чинит через FIX subagents напрямую),
audit создаёт **GitHub issues** для рефакторинга — дальше юзер запускает
`/run-pipeline` на каждом issue (pipeline-подход: ISSUE → IMPLEMENT → CI →
REVIEW → MERGE).
Источники находок:
- **`project-status` tool** — детерминированные проблемы (нет `conftest.py`,
нет `ci.yml`, thin routes, centralized models). Agent парсит текстовый
отчёт (НЕ JSON — `project-status.py` не трогаем).
- **`explore` subagent + `code-standards` skill** — качественные проблемы
(`project-status` НЕ ловит): schemas смешаны с models, бизнес-логика в
роутах, нарушение layering, service-слой пропущен.
## ПРОТОКОЛ (ЖЁСТКО)
1. **`project-status({})`** — оркестратор вызывает tool напрямую (read-only
oracle, ALLOWED — как `pipeline-status` / `spec-status`). Если вернул
`⚠️ ...failed` → WARN, продолжай без детерминированных находок (explore
всё равно работает).
2. **Парсит отчёт** (текст): `Итог:` (OK/WARN counts — issue #275: FAIL убран,
exit code всегда 0) + `Рекомендации:` (список WARN с путями). Источник
находок = секция `Рекомендации:` (каждая строка `- <group> / <name>: <detail>`).
3. **`skill({ name: "code-standards" })`** — load skill (НЕ хардкод правил в
audit skill — `code-standards` источник правды).
4. **Delegate `explore` subagent** (Template EXPLORE) — проверяет структуру
и код против `code-standards`, возвращает
`[{category, problem, path, severity}, ...]`.
5. **Комбинирует** находки: WARN из `project-status` + качественные из
explore. **Дедупликация**: если `project-status` WARN и explore нашли
одну и ту же проблему → 1 issue (не 2).
6. **Бинарный вердикт** (issue #275: парсит WARN, не exit code — exit всегда 0):
- `≥1 WARN` ИЛИ `≥1 qualitative finding``❌ Найдено N проблем`
- `0 WARN` + `0 qualitative``✅ Проект здоров` → STOP
7. **Список проблем** (сгруппированный: Структура / Качество / Тесты / Infra
/ Code-standards) — покажи юзеру.
8. **Вопрос юзеру**:
```
Найдено N проблем. Создать issues для рефакторинга?
[1] да — для каждой проблемы create-issue (subagent)
[2] нет — STOP, отчёт у юзера
```
9. Если `да` → для **каждой** проблемы (последовательно, НЕ параллельно —
Linear Execution из AGENTS.md) — delegate `task(general)` с Template
ISSUE_CREATE. Если `create-issue` валидация упала → subagent сообщает
ошибку, continue к следующей. Если gh недоступен → STOP + report.
10. **Финальный репорт**:
```
Audit complete. Создано N issues:
- #M1: <title><url>
Запусти /run-pipeline на каждом issue для рефакторинга.
```
### ЗАПРЕЩЕНО
- Чинить код напрямую (audit = read-only, только issues). FIX flow остаётся
в `project-template` check flow.
- Параллелить create-issue subagents (Linear Execution).
- Хардкодить правила из `code-standards` — загружай через `skill()`.
- Трогать `project-status.py` (агент парсит текст — JSON не нужен).
- Создавать `audit-status` tool (audit — линейный, не фазный loop).
- Группировать проблемы в один issue (1 проблема = 1 issue для `/run-pipeline`).
- Парсить exit code `project-status` для вердикта (issue #275: exit всегда 0,
парси WARN в `Рекомендации:`).
## Граничные случаи
- **Репо UNKNOWN типа** → explore всё равно проверяет против
`code-standards`, вердикт по качественным находкам.
- **Репо без `src/<pkg>/`** (flat layout) → `project-status` WARNs, explore
проверяет по `code-standards` (если применимо).
- **Только WARN** (0 qualitative) → `❌ Найдено N проблем`, вопрос (да/нет —
на усмотрение юзера).
- **Юзер "нет"** → STOP, отчёт у юзера.
- **create-issue валидация упала** → subagent сообщает, continue к следующей.
- **Дублирующие проблемы** → дедупликация оркестратором (1 issue, не 2).
## Prompt templates
### Template EXPLORE (code-standards qualitative audit)
```
Прочитай .opencode/skills/code-standards/SKILL.md.
Проверь структуру и код проекта в <cwd> против правил из skill.
Найди качественные проблемы, которые project-status НЕ ловит:
- schemas смешаны с models (Pydantic DTO в db/models/)
- бизнес-логика в роутах (Tortoise queries в api/)
- нарушение layering (routes импортируют db/models напрямую, минуя services)
- service-слой пропущен (routes → db/models без services/)
- файлы длиннее 200-300 строк (декомпозиция)
- mixing concerns (бизнес-логика ≠ транспорт ≠ представление)
- mobile-first missing (fullstack): нет PWA manifest, нет Playwright mobile spec, нет axe a11y spec, нет viewport meta — `STACK_REQUIRED["fullstack"]` требует "mobile-first", но качественно проверь что mobile-first реален, а не просто слово в stack.md
Для каждой находки верни:
{category: "Code-standards", problem: "<name>: <detail>", path: "<file:line>", severity: "warn"|"fail"}
Верни массив находок. Если находок нет — пустой массив [].
НЕ редактируй код — audit read-only. Только отчёт.
```
### Template ISSUE_CREATE (create-issue для одной проблемы)
```
Создай GitHub issue для проблемы из audit.
Категория: <category>
Проблема: <name>: <detail>
Путь: <path>
Источник: <project-status | code-standards explore>
Severity: <warn|fail>
1. Load `issue` skill via `skill({ name: "issue" })`.
2. Сформируй issue body (8 headings: ## Контекст, ## Задача, ## Контракты,
## Инварианты, ## Граничные случаи, ## Влияние на связанные компоненты,
## Вне scope, ## Критерии приемки).
- Контекст: проблема из audit (<repo>) — <name>: <detail>. Путь: <path>.
Источник: <source>. Severity: <severity>.
- Задача: что починить (конкретно, с путями к файлам).
- Контракты: конкретные изменения (что должно стать после фикса).
- Инварианты: что НЕ ломать при фиксе.
- Граничные случаи: edge cases при фиксе.
- Влияние на связанные компоненты: зависящие audit-категории/оракулы/промпты; paired updates; «нет связанных компонентов» для тривиальных фиксов.
- Вне scope: что НЕ делаем в этом issue.
- Критерии приемки: чек-лист (включая `project-status` проходит эту
категорию после фикса).
3. `create-issue({ title: "fix(<scope>): <description>", body, labels: ["tech-debt", "from-audit"] })`
tool (НЕ raw `gh issue create` — заблокирован deny). tool валидирует
conventional title + 8 headings + Cyrillic.
4. Верни: issue URL (или ошибку валидации для оркестратора).
Если gh недоступен → верни: "gh unavailable: <reason>". НЕ retry, НЕ fallback.
```
## Rules
- Main agent = оркестратор: `project-status` tool + `explore` subagent +
вопрос юзеру + `task(general)` для create-issue. Не делает
edit/read/`gh issue create` сам.
- `project-status` — read-only oracle, ALLOWED для оркестратора.
- Audit — read-only (НЕ редактирует код, только создаёт issues).
- WARN из `project-status` → issues, НЕ FIX напрямую.
- 1 проблема = 1 issue (для отдельного `/run-pipeline`).
- Issue body self-contained (8 headings, `create-issue` валидация).
- `code-standards` — через `skill()` tool, НЕ хардкод.
- Audit — линейный flow (не `audit-status` tool).
- create-issue subagents — последовательно (Linear Execution).
- Subagent error → 1 retry, потом STOP + report.

View file

@ -10,6 +10,6 @@ If a bug is found during work that is outside the scope of the current task:
1. Check `gh issue list` for duplicates.
2. Create a GitHub issue via `create-issue` tool (NOT raw `gh issue create`).
3. Title: `fix(scope): short description` in English.
4. Body: `## Контекст` / `## Задача` / `## Контракты` / `## Инварианты` / `## Граничные случаи` / `## Влияние на связанные компоненты` / `## Вне scope` / `## Критерии приемки` (in Russian).
4. Body: `## Контекст` / `## Задача` / `## Контракты` / `## Инварианты` / `## Граничные случаи` / `## Вне scope` / `## Критерии приемки` (in Russian).
5. Continue the current task. Do NOT fix the bug yourself.
6. Report to orchestrator: "Created issue #N: ...".

View file

@ -25,113 +25,3 @@ description: Universal code standards for any language. Use when writing, refact
- AGENTS.md правило «No comments unless requested» — это **default**: код без комментариев
- Этот skill описывает **исключение**: Google-style docstrings на английском для публичных API — когда контракт warrants (библиотечный API, public surface)
- Описывай **зачем**, а не **что** — код и так говорит что делает
## 5. Architecture: good vs bad
Слои для **backend**: `routes → schemas → services → db/models` (4-tier, однонаправленный). Роуты тонкие (импортируют только `services` + `schemas`), сервисы работают с `db/models`, бизнес-логика здесь. `project-status.py` enforces subset (thin routes, centralized models); этот раздел объясняет «почему».
### Backend (подробно)
**GOOD tree (синтетический):**
```
src/<package>/
├── api/
│ ├── v1/
│ │ ├── routes/users.py ← тонкие роуты, импортируют только services + schemas
│ │ ├── dependencies.py ← Depends(), get_current_user
│ │ └── router.py
│ └── router.py
├── config/
│ ├── settings.py ← pydantic-settings
│ └── logger.py ← loguru setup
├── db/
│ ├── connection.py ← Tortoise.init
│ └── models/ ← ВСЕ ORM-модели здесь (centralized)
│ ├── user.py
│ ├── post.py
│ └── comment.py
├── schemas/ ← Pydantic DTO (НЕ Tortoise models)
│ ├── base.py
│ ├── user.py
│ └── post.py
├── services/ ← бизнес-логика (работает с db/models)
│ ├── user_service.py
│ └── post_service.py
└── utils/
└── metadata.py
```
**Правила GOOD:**
1. Все ORM-модели в `db/models/` (centralized)
2. Schemas (Pydantic) отдельно от models (Tortoise) — НЕ смешивать
3. Роуты тонкие — импортируют только `services` и `schemas`
4. Сервисы работают с `db/models` — бизнес-логика здесь
5. Слои: routes → schemas → services → db/models (4-tier, однонаправленный)
**BAD tree 1 — feature-scatter:**
```
src/<package>/
├── channels/
│ ├── models.py ← ❌ модель здесь (scatter)
│ ├── routes.py
│ └── service.py
├── monitor/
│ ├── models.py ← ❌ ещё модель здесь
│ └── routes.py
├── logs/
│ └── models.py ← ❌ и здесь
├── models.py ← ❌ root-level модель
├── db.py ← ❌ connection flat (не db/connection.py)
└── main.py
```
Проблемы BAD 1: модели раскиданы по feature-папкам; `models.py` в root; `db.py` flat; не publishable; Tortoise `modules` должен перечислять 4+ файла вручную.
**BAD tree 2 — mixed-layers:**
```
src/<package>/
├── api/
│ ├── v1/
│ │ └── users.py ← ❌ роут содержит бизнес-логику + Tortoise queries
│ └── models.py ← ❌ модели в api/ (не в db/models/)
├── services/
│ └── user_service.py
│ └── schemas.py ← ❌ schemas в services/ (не в schemas/)
└── main.py
```
Проблемы BAD 2: роут делает Tortoise queries напрямую (не тонкий); модели в `api/models.py` (не `db/models/`); schemas внутри services (не отдельный слой).
### Fullstack (кратко)
Backend as above (in `backend/` + `frontend/` separation). Frontend: SvelteKit co-located `*.test.ts` в `src/lib/`, `e2e/*.spec.ts` для Playwright. НЕ смешивать backend код в `frontend/` и наоборот. + mobile-first (PWA + Playwright mobile + axe a11y) — silent enforcement через `STACK_REQUIRED["fullstack"]`.
### CLI (кратко)
`cli.py` (Typer commands) + `core.py` (business logic). Нет api/db/schemas layers. `tests/test_cli.py` + `tests/test_core.py`.
## 6. Tests
Что писать (как запускать — в `run-tests` skill). `project-status.py` enforces subset (conftest required, anti-stub, mirror structure); этот раздел объясняет «почему».
### Типы тестов
- **Regression** — воспроизводит конкретный баг, который был исправлен. Ссылается на issue/PR (`test_parser_handles_crlf_regression_#227`).
- **Integration** — пересекает слои (DB+API, scheduler+DB). Имеет `pytest.mark.integration` + `skipif` opt-in. В `tests/integration/`.
- **Unit** — чистая функция/сервис, без DB/сети. В `tests/unit/`.
### Антипаттерны
- **Stub files**`test_*.py` без `def test_*`/`async def test_*` (digital_factory 50/62 файлов). `project-status` WARNs.
- **Тесты без assertions** — только `print`/`logger.info`. Каждый тест должен иметь минимум 1 `assert`.
- **Тесты ради тестов** — coverage ради coverage, без реальной проверки поведения.
### Mirror structure (backend)
- `tests/unit/``src/<pkg>/services/` (unit-тесты сервисов)
- `tests/api/``src/<pkg>/api/v1/routes/` (route-тесты через TestClient)
- `tests/integration/` ↔ cross-cutting flows (opt-in)
### conftest.py
Обязателен (backend). Shared fixtures: `mock_settings`, `client`, `auth_client`, `create_<entity>` factories.

View file

@ -16,30 +16,6 @@ description: Use when adding, changing, or removing MCP servers, providers, perm
- **Исключение:** явный override-сценарий (project-local конфиг нужен для изоляции в конкретном проекте) — тогда указать явно в комментарии к изменению.
- **Docker (опционально):** для контейнерного запуска можно bind-mount `.opencode/``~/.config/opencode/` (см. `docker-compose.yml`) — но это optional path, не канон. Репо обязан работать и bare-`opencode` в клона.
## 1.1. Scope правила «куда писать» (не только `opencode.json`)
Каноническое правило «всегда workspace clone `slaid098/opencode-config`, никогда `~/.config/opencode/`» распространяется **на все артефакты конфига opencode**, а не только на `opencode.json`:
- `.opencode/opencode.json` — конфиг (MCP, providers, permissions, …).
- `AGENTS.md` (корень репо) — глобальные правила/инструкции.
- `.opencode/skills/<name>/SKILL.md` — скиллы.
- `.opencode/agents/<name>.md` — сабагенты.
- `.opencode/commands/<name>.md` — слэш-команды.
**Архитектура bind-mount (Docker-сетап):**
- `~/.config/opencode/` внутри контейнера — это bind-mount из хост-сорса `/root/dockers/opencode-config/.opencode/` (НЕ `config/`).
- Глобальный `AGENTS.md` (`~/.config/opencode/AGENTS.md`) — отдельный ro-mount из корня репо.
- Любая правка файлов в `~/.config/opencode/` напрямую физически меняет файлы на хост-сорсе **в обход git**. Другой агент коммитит из workspace clone и пушит → на хосте `git pull` → конфликт (host-source уже имеет uncommitted изменения).
**Правильно (sync процедура):**
1. Правка в workspace clone `/root/workspace/opencode-config/``commit` + `git push`.
2. На хосте: `git pull` (обновляет `/root/dockers/opencode-config/.opencode/`).
3. `docker compose restart opencode` (MCP/skills/agents грузятся при старте контейнера — до рестарта правки не видны).
**Запрещено:** редактировать `~/.config/opencode/` внутри контейнера напрямую (это bind-mount, ro `AGENTS.md`, bypass git → конфликты при `git pull` на хосте).
## 2. Применение изменений
- `commit` + `push` в репо `slaid098/opencode-config` (через `commit` tool).

View file

@ -1,6 +1,6 @@
---
name: feature-spec
description: Lightweight SDD-style Q&A skill for feature planning. Guides the agent through Spec-Driven Development questions before implementation. Runs an explore subagent to find related/linked components (oracle scripts, validators, agents, prompts) before Q&A, then produces a structured plan with 8 SDD sections (Контекст, Задача, Контракты, Инварианты, Граничные случаи, Влияние на связанные компоненты, Вне scope, Критерии приемки). Also when user says "спека фичи", "feature spec", "план фичи", "обсудим фичу", "spec feature", "спецификация фичи".
description: Lightweight SDD-style Q&A skill for feature planning. Guides the agent through Spec-Driven Development questions before implementation. Produces a structured plan with 7 SDD sections (Контекст, Задача, Контракты, Инварианты, Граничные случаи, Вне scope, Критерии приемки). Also when user says "спека фичи", "feature spec", "план фичи", "обсудим фичу", "spec feature", "спецификация фичи".
---
# Feature Spec
@ -21,21 +21,6 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
Юзер описывает фичу. Агент читает SDD-шаблон (ниже) и определяет, каких данных не хватает.
### 1.5. Поиск связанных компонентов
ДО Q&A — автоматический search по репо, чтобы найти связанные компоненты и дать вводные для 8-й SDD-секции.
- Запустить `explore` subagent с `rg` по репо.
- Найти: кто ссылается на изменяемый файл/функцию/формат/литерал/frontmatter key.
- Категории для поиска:
- oracle-скрипты (`pipeline-status`, `spec-status`, `project-status`)
- валидаторы (`create-issue`, `create-readme`)
- агенты (`memory-syncer`, `reviewer`)
- промпты (skills)
- Для каждого найденного компонента — отметить: как изменение повлияет, нужен ли paired update.
- Результат — вводные для 8-й секции SDD (`## Влияние на связанные компоненты`).
- Если связанных компонентов нет — явно отметить (explore search не нашёл).
### 2. Q&A
Агент задаёт вопросы списком — НЕ гадает. Пример:
@ -45,11 +30,10 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
1. Контракты: какой формат запроса/ответа? Какие коды ошибок?
2. Инварианты: какие лимиты? Какой TTL? Какая модель/библиотека?
3. Граничные случаи: что если внешний сервис недоступен? Что если данных нет?
4. Влияние на связанные компоненты: какие детерминированные связи (oracle-скрипты, валидаторы, парсеры, промпты-агенты) зависят от этого изменения? Что может сломаться если поменять X? Нужны ли paired updates в других файлах? (explore subagent уже нашёл candidates на шаге 1.5 — юзер подтверждает/дополняет)
5. Вне scope: что точно НЕ делаем в этой итерации?
4. Вне scope: что точно НЕ делаем в этой итерации?
```
Юзер отвечает. Если ответ неполный — агент уточняет. Q&A продолжается пока все 8 секций не заполнены.
Юзер отвечает. Если ответ неполный — агент уточняет. Q&A продолжается пока все 7 секций не заполнены.
### 3. План
@ -72,9 +56,6 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
## Граничные случаи
[Что при ошибках: невалидный вход, отказ сервиса, превышение лимита]
## Влияние на связанные компоненты
[Файлы/оракулы/агенты/промпты/валидаторы, которые зависят от изменения; нужен ли paired update. Если нет — явно «нет связанных компонентов»]
## Вне scope
[Что НЕ делаем]
@ -87,7 +68,7 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
После готовности плана:
- Агент: "План готов. Скажи 'создай issue' чтобы создать issue, потом запусти /run-pipeline."
- Юзер: "создай issue" → загружается `issue` скилл → `create-issue` tool (8 секций) → issue создан
- Юзер: "создай issue" → загружается `issue` скилл → `create-issue` tool (7 секций) → issue создан
- Юзер: `/run-pipeline` → реализация
## Правила
@ -102,9 +83,9 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
- **Не создавай файлы** — план живёт в чате, потом в issue
- **1 issue = 1 PR** — если фича большая, предложи разбить на подзадачи
## SDD-шаблон (8 секций)
## SDD-шаблон (7 секций)
Совпадает с `create-issue` validation (PR #169, расширено в #249):
Совпадает с `create-issue` validation (PR #169):
| # | Секция | Что содержит |
|---|--------|-------------|
@ -113,29 +94,5 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
| 3 | `## Контракты` | Ожидаемое поведение: API, форматы, коды ошибок |
| 4 | `## Инварианты` | Правила без исключений: лимиты, ограничения, технологии |
| 5 | `## Граничные случаи` | Что при ошибках: edge cases, отказы сервисов |
| 6 | `## Влияние на связанные компоненты` | Файлы/оракулы/агенты/промпты/валидаторы, зависящие от изменения; paired updates; «нет связанных компонентов» для тривиальных фич |
| 7 | `## Вне scope` | Что НЕ делаем в этой итерации |
| 8 | `## Критерии приемки` | Проверяемые сценарии: "X → видит Y" |
## Пример: кейс #238 (memory-syncer ↔ pipeline-status)
Реальный кейс, который мотивировал 8-ю секцию. PR #239 изменил `memory-syncer.md` (файл-ротация: пишет в `{repo}-002.md` при заморозке). `pipeline-status.py::check_memory()` читал только `{repo}.md` → не нашёл receipt → pipeline завис. Связь writer↔reader детерминированная, но feature-spec её не увидел.
Шаг 1.5 (explore subagent) нашёл бы:
- `pipeline-status.py::check_memory()` / `get_memory_file_path()` — читает `{repo}.md` для поиска `PR#N` receipt. Если memory-syncer пишет в `{repo}-002.md` → оракул не найдёт receipt → MEMORY фаза зависает. Нужен paired update: `get_memory_file_path()` должен сканировать `{repo}*.md` glob.
- `memory` skill — ссылается на формат memory-файлов, обновить примеры ротации.
8-я секция SDD для такой фичи:
```
## Влияние на связанные компоненты
- `pipeline-status.py:check_memory()` / `get_memory_file_path()` — читает `{repo}.md` для поиска `PR#N` receipt. Если memory-syncer пишет в `{repo}-002.md` → оракул не найдёт receipt → MEMORY фаза зависает. Нужен paired update: `get_memory_file_path()` должен сканировать `{repo}*.md` glob.
- `memory` skill — ссылается на формат memory-файлов, обновить примеры ротации.
```
Для тривиальной фичи (cosmetic README update):
```
## Влияние на связанные компоненты
Нет связанных компонентов (cosmetic README update).
```
| 6 | `## Вне scope` | Что НЕ делаем в этой итерации |
| 7 | `## Критерии приемки` | Проверяемые сценарии: "X → видит Y" |

View file

@ -1,6 +1,6 @@
---
name: get-project-map
description: Use when you need to view or update the current project folder/file structure (especially after creating/deleting files or switching branches), or understand package layout in the workspace. Also when user says "структура проекта", "project map", "дерево файлов".
description: Use when you need to view or update the current project folder/file structure (especially after creating/deleting files or switching branches), or understand package layout in the workspace. Also contains a template for maintaining docs/project-map/. Also when user says "структура проекта", "project map", "дерево файлов".
---
# Навык получения карты проекта (Project Map)
@ -17,3 +17,113 @@ description: Use when you need to view or update the current project folder/file
1. Запусти указанную команду в терминале. Она выведет дерево каталогов и список файлов с их размерами прямо в stdout.
2. Изучи полученную структуру воркспейсов, чтобы точно знать расположение файлов и пакетов.
3. Не сохраняй вывод в файлы на диск — читай его напрямую из вывода терминала.
## Персистентная карта проекта (docs/project-map/)
Помимо живого дерева через `repomix`, в репозитории может быть персистентная карта в `docs/project-map/`. Эта карта обновляется docs-reviewer агентом перед каждым code review.
### Структура
- `docs/project-map/README.md` — индекс, общая структура, список модулей
- `docs/project-map/<module>.md` — один файл на модуль/директорию верхнего уровня
### Шаблон MD-файла модуля
```markdown
---
module: <путь к модулю>
purpose: <назначение в одну строку>
key_files:
- <путь><роль>
- <путь><роль>
dependencies: [<зависимости>]
last_updated: <YYYY-MM-DD>
---
# <имя модуля>
## Структура
- `<файл>`<описание>
- `<файл>`<описание>
## Паттерны
- <используемые паттерны/конвенции>
```
### Что включать
- Структуру директорий (дерево модуля)
- Назначение модуля/директории
- Ключевые файлы и их роли
- Зависимости между модулями
### Что НЕ включать
- Implementation details
- API signatures
- Внутреннюю логику
### Когда обновлять
- Добавлены новые файлы или директории
- Удалены файлы или директории
- Переименованы файлы или директории
- Новые модули верхнего уровня
## Handoff файлы (docs/handoff/)
Контекст передаётся между сессиями через handoff-файлы — один файл на PR.
### Структура
- `docs/handoff/pr-<N>-<slug>.md` — handoff для PR #N
### Шаблон
```markdown
---
pr: <N>
title: <PR title>
---
## Что сделано
<2-3 строки>
## Почему
<1-2 строки>
## Pending
<что осталось, или "">
## Watch out
<gotchas, или "">
```
## ADR файлы (docs/decisions/)
Архитектурные решения сохраняются в ADR (Architecture Decision Records).
### Структура
- `docs/decisions/<NN>-pr-<N>-<slug>.md` — один файл на решение
- Numbering: `001`, `002`, `003`, ... (zero-padded, sequential)
### Шаблон
```markdown
# ADR-<NN>: <title>
## Статус
Accepted (<YYYY-MM-DD>)
## Контекст
<почему нужно было решение>
## Решение
<что решили>
## Альтернативы
- <вариант>: <почему не подошёл>
```
### Когда создавать ADR
- Новый паттерн или конвенция
- Архитектурное изменение (новый модуль, изменённые зависимости)
- Неочевидное решение (почему X, а не Y)
### Когда НЕ создавать ADR
- Bug fixes
- Refactoring without architectural change
- Documentation updates

View file

@ -36,9 +36,6 @@ Issue должно содержать всё необходимое, чтобы
## Граничные случаи
[Что при ошибках: невалидный вход, отказ внешнего сервиса, превышение лимита]
## Влияние на связанные компоненты
[Связанные файлы/оракулы/агенты/промпты/валидаторы; paired updates; «нет связанных компонентов» для тривиальных задач]
## Вне scope
[Что НЕ делаем в этой итерации]
@ -70,8 +67,8 @@ Issue создаёт **subagent** (general type), а не основной аг
**Subagent (полная ответственность):**
1. Загрузи навык `issue`
2. Собери контекст — прочитай файлы из intent summary, пойми задачу, оцени объём (правило дробления ниже)
3. Составь self-contained body по шаблону (Контекст → Задача → Контракты → Инварианты → Граничные случаи → Влияние на связанные компоненты → Вне scope → Критерии приемки)
4. Запусти `create-issue({ title: "...", body: "...", labels: ["..."] })` tool (НЕ raw `gh issue create` — заблокирован deny; tool валидирует conventional title format и headings `## Контекст`/`## Задача`/`## Контракты`/`## Инварианты`/`## Граничные случаи`/`## Влияние на связанные компоненты`/`## Вне scope`/`## Критерии приемки`)
3. Составь self-contained body по шаблону (Контекст → Задача → Контракты → Инварианты → Граничные случаи → Вне scope → Критерии приемки)
4. Запусти `create-issue({ title: "...", body: "...", labels: ["..."] })` tool (НЕ raw `gh issue create` — заблокирован deny; tool валидирует conventional title format и headings `## Контекст`/`## Задача`/`## Контракты`/`## Инварианты`/`## Граничные случаи`/`## Вне scope`/`## Критерии приемки`)
5. Верни URL созданного issue основному агенту
Main agent НЕ пишет body и НЕ запускает `create-issue` — всё через subagent. Это согласовано с `run-pipeline` skill (Phase 0: "через subagent с `issue` skill") и `AGENTS.md` (Dev Workflow, step 2: "delegate to `task` subagent").
@ -104,10 +101,6 @@ Main agent НЕ пишет body и НЕ запускает `create-issue` — в
- Кеш содержит устаревший формат → invalidate, пересчитать
- Конкурентные запросы на одно видео → первый пишет в кеш, последующие берут из кеша
## Влияние на связанные компоненты
- AnalysisController зависит от AnalysisService — без изменений (API сохранён)
- «Нет связанных компонентов» для тривиальных задач
## Вне scope
- ❌ Кеширование субтитров (отдельная задача)
- ❌ Инвалидация по времени просмотра видео
@ -139,10 +132,9 @@ create-issue({ title: "type(scope): description", body: "...", labels: ["<label>
Tool валидирует: title соответствует conventional format (type(scope): desc,
≤80 chars, English), body содержит `## Контекст`, `## Задача`, `## Контракты`,
`## Инварианты`, `## Граничные случаи`, `## Влияние на связанные компоненты`,
`## Вне scope`, `## Критерии приемки` headings и на русском (Cyrillic
обязательна). При ошибке валидации tool возвращает ошибку и НЕ вызывает gh —
почини формат и повтори.
`## Инварианты`, `## Граничные случаи`, `## Вне scope`, `## Критерии приемки`
headings и на русском (Cyrillic обязательна). При ошибке валидации
tool возвращает ошибку и НЕ вызывает gh — почини формат и повтори.
Label выбирай по типу задачи (совпадает с commit `type`):
- `enhancement` — новая функциональность (`feat`)
@ -165,10 +157,11 @@ Label выбирай по типу задачи (совпадает с commit `t
После создания issue, цикл продолжается (см. `run-pipeline` skill для деталей PR процесса):
1. **Subagent**`task(general)` читает issue, реализует, коммитит, push, создаёт PR. Оркестрация — через `run-pipeline` skill.
2. **Code review**`@reviewer` subagent ревьюит PR (diff, skills, standards), постит `## Code Review Summary` комментарий.
3. **Merge or Repeat** — APPROVE → `merge-pr({ pr_number: N })` tool (squash +
2. **Docs review**`@docs-reviewer` subagent валидирует handoff + ADR, обновляет project map (pre-merge).
3. **Code review**`@reviewer` subagent ревьюит PR (diff, skills, standards), постит `## Code Review Summary` комментарий.
4. **Merge or Repeat** — APPROVE → `merge-pr({ pr_number: N })` tool (squash +
delete branch, без `--admin`; НЕ raw `gh pr merge` — заблокирован deny),
после CI ✅; замечания → fix subagent → re-review → merge.
4. **Memory-sync**`@memory-syncer` дистиллирует PR body в `<memory_dir>/repos/{host}/{org}/{repo}.md`.
5. **Memory-sync**`@memory-syncer` дистиллирует handoff + ADR в `<memory_dir>/repos/{host}/{org}/{repo}.md`.
См. `AGENTS.md` (Development Workflow) и `run-pipeline` skill — все три документа описывают одну и ту же full-subagent модель делегирования.

View file

@ -106,16 +106,9 @@ Default: `/root/.local/share/opencode/opencode-memory` (переопределя
Перед добавлением записи — прочитай существующий файл. Если факт уже записан — обнови запись (bump `updated` в frontmatter, дополни детали если нужно). **НЕ создавай дубликаты.** Дубликаты раздули файлы до 600+ KB. Каждая гоча/паттерн/root cause = одна запись, не по одной на каждый PR где упоминалась.
### Ротация файлов (50 KB)
### Лимит 100 KB
Память репо ротируется по размеру вместо inline compaction:
- один репо → один или несколько файлов по пути `{memory-dir}/repos/{host}/{org}/{repo}*.md`
- первый файл: `{repo}.md`; последующие (когда первый заморожен): `{repo}-002.md`, `{repo}-003.md`, ... (3-значный sequential, не по дате)
- порог ротации: **50 KB** (soft). Файл с размером ≥ 50 KB считается замороженным — новые записи в него НЕ пишутся, открывается следующий файл
- замороженные файлы остаются редактируемыми для dedup (обновление существующих гоч, bump `updated` в их frontmatter); новые записи в замороженный файл — НЕ пишутся
- `memory-syncer` выбирает активный файл (первый существующий с размером < 50 KB) перед каждой записью и ищет дубликаты по всем `repo*.md` (включая замороженные) см. `memory-syncer` agent
Inline compaction удалён: память = durable выжимка (сжимать некуда), git = бесконечный backup (без отдельных archive-файлов). Receipts остаются в файлах (audit trail). Существующие большие файлы (`youtube-soft.md` 112 KB, `opencode.md` 80 KB, `opencode-config.md` 64 KB) миграции не требуют — при следующей записи откроется `-002.md`.
Если после записи файл > 100 KB → компрессировать: прочитай все старые записи, оставь только durable (gotchas, паттерны, root causes, ADR-указатели), выкинь не-durable (changelog-дампы, статусы, хроника событий, receipts с повторяющимся содержанием, метрики PR). Tags-строка < 500 символов, summary < 500 символов. Цель держать файл < 100 KB.
### Квитанция ставится всегда

View file

@ -1,352 +0,0 @@
---
name: project-template
description: Init new project from cookiecutter template by type + GitHub remote + branch protection, or audit existing project via project-status. Replaces repo-init. Also when user says "новый проект", "инициализируй репо", "проверь проект", "create project", "project template".
---
# Project Template
Два flow: **init** (новый проект: cookiecutter по типу + GitHub remote) и
**check** (существующий проект: project-status аудит с рекомендациями).
Замена монолитному `repo-init`: Phase A (GitHub remote + branch protection)
мигрирована без изменений, Phase B (ручное scaffolding) заменена на cookiecutter
шаблоны из `.opencode/templates/<type>/` (issue #229 / PR #235). Проверка
архитектуры — через `project-status` tool (issue #228 / PR #233).
## ПРОТОКОЛ (ЖЁСТКО)
1. Определи flow (вопрос юзеру, см. ниже).
2. init flow → шаги 1-6 (см. ниже).
3. check flow → шаги 1-3 (см. ниже).
4. После каждого шага → 1 строка прогресса юзеру (формат: `✅ <step> — <done>`).
### ЗАПРЕЩЕНО
- Запускать cookiecutter / git / gh напрямую из main agent — делегируй subagent
(Template INIT, Template GITHUB). Main agent = оркестратор: вопросы юзеру +
`project-status` tool (read-only oracle) + делегирование.
- `gh repo create` БЕЗ предварительного git init + initial commit (bare-repo →
empty push → main branch не появляется → branch protection падает).
- Импровизировать тип проекта вне `VALID_TYPES` из `spec-status.py` (см. ниже).
- Запускать `/run-pipeline` автоматически — стоп после init/check, дальше юзер
сам.
### Остановы
- Subagent error → 1 retry, потом STOP + report пользователю.
- cookiecutter не установлен → WARN + инструкция, STOP init flow.
- Template для типа не найден → WARN, STOP init flow (предложи check flow или
тип из доступных).
- `project-status` tool вернул `⚠️ ...failed` → WARN, сообщи пользователю,
продолжай без отчёта (не блокирующий).
## Определение flow
Вопрос юзеру (один вопрос, multiple choice):
```
Это новый проект или проверка существующего?
[1] init — новый проект: cookiecutter по типу + GitHub remote + branch protection
[2] check — аудит существующего: project-status (структура, роуты, качество, README, infra)
```
Если юзер выбрал init, но cwd уже git-репо с коммитами и файлами → предложи
check flow (см. Граничные случаи). Если cwd пустой или юзер подтвердил init →
init flow.
## init flow
### Шаг 1: Вопрос — тип проекта
```
Выбери тип проекта (из spec-status VALID_TYPES):
[1] backend — FastAPI + Tortoise, REST API (cookiecutter template)
[2] fullstack — backend + SvelteKit + Tailwind v4 + shadcn-svelte dashboard (TypeScript, cookiecutter template)
[3] cli — Python CLI tool, Typer (cookiecutter template)
[4] mcp-server — MCP + REST сервер (нет cookiecutter template — ручная инициализация)
[5] bot — Telegram bot, aiogram 3 (нет cookiecutter template — ручная инициализация)
[6] worker — Prefect flows (нет cookiecutter template — ручная инициализация)
```
Cookiecutter templates доступны для типов: `backend`, `fullstack`, `cli`
(директории в `.opencode/templates/`). Для `mcp-server`, `bot`, `worker`
шаблонов нет → WARN: "cookiecutter template not found for type `<type>`.
Доступные: backend, fullstack, cli. Для остальных типов используй check flow
или создай issue на добавление template." → STOP init flow.
### Шаг 2: Вопрос — имя, описание, опции
```
Имя проекта (kebab-case, станет package name и GitHub repo name): ___
Описание (1 строка): ___
GitHub owner: ___
use_auth: [1] no (default) / [2] yes
use_db: [1] yes (default) / [2] no
```
Опции `use_auth` / `use_db` — переменные cookiecutter (см. `cookiecutter.json`).
Значения: `"no"` или `"yes"` (строки, lowercase).
### Шаг 3: Делегирование — cookiecutter + git init + initial commit
Subagent (general) — Template INIT ниже. Cookiecutter рендерит проект в
`./<project_name>/`, post_gen_project hook удаляет файлы условные на
`use_auth`/`use_db`. Затем git init + initial commit внутри `./<project_name>/`.
### Шаг 4: Делегирование — GitHub remote + branch protection
Subagent (general) — Template GITHUB ниже. Мигрировано из repo-init Phase A
(steps 1-3) БЕЗ изменений: `gh repo create`, squash-only merge settings, branch
protection на main. Требует локальный git-репо с initial commit (из шага 3).
### Шаг 5: Оркестратор — project-status
Вызови `project-status` tool напрямую (read-only oracle, ALLOWED для
оркестратора — как `pipeline-status` / `spec-status`):
```
project-status({})
```
Tool вернёт отчёт: `Project: <type>`, 8 групп `[OK]/[WARN]`, `Итог:`,
`Рекомендации:`. Покажи отчёт юзеру. Если tool вернул `⚠️ ...failed` → WARN,
продолжай без отчёта.
> README у свежего проекта отсутствует (issue #269) — README генерируется
> позже через repo-readme skill по ручному вызову. Поэтому группа README
> даст WARN «README.md отсутствует» (issue #275: exit code всегда 0,
> все проверки WARN). Это ожидаемое поведение, не ошибка — упомяни в отчёте.
### Шаг 6: Финальный репорт
```
Project <project_name> created at ./<project_name>/.
GitHub: https://github.com/<owner>/<project_name>
Branch protection: main (PR + required_status_checks + linear history)
Project-status: <summary из шага 5>
README: отсутствует (WARN у project-status) — появится позже через repo-readme
Дальше: /run-pipeline для реализации фич, или /spec для генерации spec.
```
## check flow
### Шаг 1: Оркестратор — project-status
```
project-status({})
```
Issue #275: exit code всегда 0 (информационный режим). ``--check`` принимается
для CLI совместимости, но больше не форсирует exit 1. Для пропуска медленных
remote-проверок (branch protection via gh) — `project-status({ fast: true })`.
### Шаг 2: Оркестратор — отчёт + рекомендации
Покажи полный отчёт юзеру. В разделе `Рекомендации:` — список WARN-чеков с
путями (issue #275: все проверки WARN, не FAIL). Сгруппируй по категориям
(Структура / Качество кода / Тесты / README / Infra / Coverage).
### Шаг 3: Вопрос — чинить?
```
Найдены проблемы: <N WARN>.
Запустить fix-subagents для рекомендаций?
[1] да — делегируй subagent(ов) для каждого WARN
[2] нет — только отчёт, я починю сам
```
Если `да` → для каждого WARN из `Рекомендации:` создай subagent (general) с
Template FIX (ниже), передав путь и описание проблемы. Subagent чинит, коммитит
через `commit` tool, push. Один WARN = один subagent (последовательно, не
параллельно — см. AGENTS.md Linear Execution). После всех фиксов → re-run
`project-status` для верификации.
Если `нет` → STOP, отчёт у юзера.
## Граничные случаи
- **Существующий репо (не пустой)** → init flow: предложи check flow. Если юзер
настаивает на init → cookiecutter создаст `./<project_name>/` рядом (не
перезапишет текущий репо). Уточни: "cwd уже git-репо с файлами. Init создаст
новый проект в подкаталоге `./<project_name>/`. Продолжить? [1] да / [2] нет,
лучше check flow".
- **GitHub repo уже существует** → skip `gh repo create`, только branch
protection (если ещё не настроена). Subagent проверяет: `gh repo view
<owner>/<name>` — если существует, пропускает create, переходит к settings +
branch protection.
- **cookiecutter не установлен** → WARN: "cookiecutter не найден. Установи:
`uv tool install cookiecutter` (или `pipx install cookiecutter`). После
установки повтори init." → STOP init flow.
- **project-status не найден** → WARN: "project-status tool не доступен (issue
#228 / PR #233 не завершён или tool не зарегистрирован). Пропускаю
project-status проверку." → продолжай без отчёта (не блокирующий).
- **Template для типа не найден** (mcp-server/bot/worker) → WARN (см. Шаг 1).
- **gh auth не настроен** → subagent упадёт на `gh repo create`. Сообщи юзеру:
"запусти `gh auth login` и повтори".
## Prompt templates
### Template INIT (cookiecutter + git init + initial commit)
```
Создай новый проект типа <type> с именем <project_name>.
Контекст: init flow project-template skill, cwd = <cwd>.
1. Проверь cookiecutter: `cookiecutter --version`. Если не установлен → STOP,
верни: "cookiecutter не установлен. Установи: `uv tool install cookiecutter`".
2. Запусти cookiecutter (no-input, переменные из ответов юзера):
`cookiecutter .opencode/templates/<type>/ --no-input \
project_name=<project_name> \
project_type=<type> \
description="<description>" \
use_auth=<auth> \
use_db=<db> \
python_version=3.13`
Cookiecutter создаст каталог `./<project_name>/` с рендеренным проектом.
post_gen_project hook удалит файлы условные на use_auth/use_db.
3. `cd <project_name>` (все дальнейшие команды — внутри этого каталога).
4. `git init`
5. `git add .` затем `git status` — проверь staged set (только файлы проекта,
без лишнего). Если лишнее — `git restore --staged <file>`.
6. `commit({ message: "chore: initial commit" })` tool (НЕ raw `git commit`
заблокирован deny).
7. Верни: "done: project created at ./<project_name>/, git init + initial commit".
README.md в рендеренном проекте НЕТ (issue #269) — шаблоны его больше не
содержат. README появится позже через repo-readme skill по ручному вызову
(сначала юзер смотрит контент и cover.png из draw-image). project-status на
свежем проекте даст WARN по отсутствующему README — это ожидаемо, не чинить.
Если найдёшь баг вне scope — загрузи skill `bug-discovery` через
`skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам.
```
### Template GITHUB (gh repo create + settings + branch protection)
> Мигрировано из repo-init Phase A (steps 1-3) БЕЗ изменений. Требует локальный
> git-репо с initial commit (из Template INIT).
```
Настрой GitHub remote для проекта <project_name> (cwd = <cwd>/<project_name>).
owner = <owner>, visibility = public (или private если internal).
0. Проверь: GitHub repo уже существует?
`gh repo view <owner>/<project_name>` — если exit 0, skip step 1 (create),
переходи к step 2 (settings) и step 3 (branch protection).
1. Создание репозитория:
`gh repo create <owner>/<project_name> --public --source=. --remote=origin --push`
(или --private если internal)
После создания: `gh auth setup-git`
2. Настройки репозитория (squash-only merge, auto-delete branch):
`gh api repos/<owner>/<project_name> \
--method PATCH \
-f allow_squash_merge=true \
-f allow_merge_commit=false \
-f allow_rebase_merge=false \
-f delete_branch_on_merge=true \
-f squash_merge_commit_title=COMMIT_OR_PR_TITLE \
-f squash_merge_commit_message=COMMIT_MESSAGES`
3. Защита ветки main (требовать PR, required_status_checks, linear history):
`gh api repos/<owner>/<project_name>/rules/branches/main \
--method POST \
-F target=branch \
-f enforcement=active \
--input - <<'EOF'
{
"conditions": {
"ref_name": {
"include": ["refs/heads/main"],
"exclude": []
}
},
"rules": [
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 0,
"dismiss_stale_reviews_on_push": false,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_review_thread_resolution": false
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks": true,
"do_not_enforce_on_create": false,
"required_status_checks": []
}
},
{
"type": "deletion"
},
{
"type": "non_fast_forward"
}
]
}
EOF`
`required_status_checks` заполняется именами CI-джобов после первого пуша
(имена из cookiecutter CI: `lint`, `typecheck`, `test`, `complexity` для
Python; `check` для JS/TS). На этом этапе оставь пустой массив — обновится
после первого CI-прогона.
4. Верни: "done: GitHub remote created/verified, squash-only merge, branch
protection on main".
Если найдёшь баг вне scope — загрузи skill `bug-discovery` через
`skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам.
```
### Template FIX (check flow — починить WARN из project-status)
```
Почини проблему из project-status отчёта.
Категория: <category> (Структура / Качество кода / Тесты / README / Infra / Coverage)
Проблема: <name>: <detail>
Путь: <path из рекомендации>
1. Прочитай контекст проблемы (файл по пути из рекомендации).
2. Минимальный фикс: добавь/исправь только то, что указано в рекомендации.
Не рефактори unrelated код.
3. Перед коммитом — `git status` для проверки staged set (`commit` tool НЕ
делает `git add` — коммитит только staged; используй
`git add <конкретные-пути>`, НЕ `git add -A`).
4. `commit({ message: "fix(<scope>): <description>" })` tool (НЕ raw
`git commit`), push.
5. Верни: "done: fixed <name>, commit <hash>".
Если найдёшь баг вне scope — загрузи skill `bug-discovery` через
`skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам.
```
## VALID_TYPES (из spec-status.py)
```
backend, fullstack, mcp-server, cli, bot, worker
```
Cookiecutter templates доступны для: `backend`, `fullstack`, `cli`
(директории `.opencode/templates/<type>/`).
## Rules
- Main agent = оркестратор: вопросы юзеру + `project-status` tool (read-only) +
делегирование subagent'ам (Template INIT / GITHUB / FIX). Не делает
cookiecutter/git/gh напрямую.
- `project-status` tool — read-only oracle, ALLOWED для оркестратора (как
`pipeline-status` / `spec-status`).
- init flow порядок: cookiecutter → git init/commit → gh repo create → branch
protection → project-status. Не меняй порядок. README в init flow НЕ
генерируется — только по ручному вызову repo-readme (issue #269).
- check flow: project-status → отчёт → рекомендации → subagents (последовательно).
- Subagent error → 1 retry, потом STOP + report.
- Совместим с spec-pipeline: Phase 8 EXECUTE (spec/SKILL.md Template I) может
вызывать project-template init для scaffolding issue.
- `commit` tool НЕ делает `git add` — коммитит только staged. Используй
`git add <конкретные-пути>`, НЕ `git add -A`.

View file

@ -0,0 +1,759 @@
---
name: repo-init
description: Sequential checklist: create GitHub remote → configure settings/branch protection → scaffold project files (Python/JS). Use when starting a new repo. Also when user says "новый репо", "создай репозиторий", "настрой репо".
---
# Repo Init
Полный чек-лист инициализации нового репозитория. Все шаблоны — внутри, берутся из эталонных репозиториев (reference repos).
## Фазы
- **Phase A — GitHub remote** (шаги 1-3, выполняется один раз): создание репо, настройки merge, защита ветки. Требует локального git-репо с initial commit.
- **Phase B — Project scaffolding** (шаги 4-9, по шаблонам): Python/JS файлы, Dependabot, LICENSE, .editorconfig, pre-commit, верификация. Можно повторно использовать для существующего репо (skip Phase A).
## Содержание
1. [Создание репозитория](#1-создание-репозитория)
2. [Настройки репозитория](#2-настройки-репозитория)
3. [Защита ветки main](#3-защита-ветки-main)
4. [Python-проект](#4-python-проект)
5. [JS/TS-проект](#5-jsts-проект)
6. [Dependabot](#6-dependabot)
7. [Общие файлы](#7-общие-файлы)
8. [Установка pre-commit](#8-установка-pre-commit)
9. [Чек-лист верификации](#9-чек-лист-верификации)
---
## Phase A — GitHub remote
> Шаги 1-3 выполняются один раз для нового репо. Требуют локального git-репо с initial commit.
## 0. Prerequisite: git init + initial commit
Перед `gh repo create --source=.` локальный каталог должен быть git-репо с хотя бы одним коммитом (`--source=.` пушит текущую ветку; без коммита — пустой репо).
```bash
git init
echo "# <repo-name>" > README.md
git add README.md
git status # проверь staged set — только README.md, без лишнего
```
> `commit` tool НЕ делает `git add` — коммитит только уже staged файлы. Если
> в индексе лишнее (например `memory-save` stage'нул всё через `git add -A`)
> — не коммить: сначала `git restore --staged <file>` или не stage'и его
> изначально. Используй `git add <конкретные-пути>`, НЕ `git add -A`.
Затем через `commit` tool (НЕ raw `git commit` — заблокирован deny):
```
commit({ message: "chore: initial commit" })
```
Если bare-repo без initial commit — `gh repo create --source=.` создаст remote, но push будет пустым, а main branch не появится → branch protection упадёт.
---
## 1. Создание репозитория
```bash
gh repo create <owner>/<repo-name> --public --source=. --remote=origin --push
```
Или приватный (если internal):
```bash
gh repo create <owner>/<repo-name> --private --source=. --remote=origin --push
```
После создания — настроить git auth:
```bash
gh auth setup-git
```
---
## 2. Настройки репозитория
Squash-only merge, auto-delete branch после merge:
```bash
gh api repos/<owner>/<repo-name> \
--method PATCH \
-f allow_squash_merge=true \
-f allow_merge_commit=false \
-f allow_rebase_merge=false \
-f delete_branch_on_merge=true \
-f squash_merge_commit_title=COMMIT_OR_PR_TITLE \
-f squash_merge_commit_message=COMMIT_MESSAGES
```
---
## 3. Защита ветки main
Требовать PR, требовать status checks (CI), linear history:
```bash
gh api repos/<owner>/<repo-name>/rules/branches/main \
--method POST \
-F target=branch \
-f enforcement=active \
--input - <<'EOF'
{
"conditions": {
"ref_name": {
"include": ["refs/heads/main"],
"exclude": []
}
},
"rules": [
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 0,
"dismiss_stale_reviews_on_push": false,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_review_thread_resolution": false
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks": true,
"do_not_enforce_on_create": false,
"required_status_checks": []
}
},
{
"type": "deletion"
},
{
"type": "non_fast_forward"
}
]
}
EOF
```
`required_status_checks` заполняется именами CI-джобов после первого пуша (см. шаблоны CI ниже). Имена джобов: `lint`, `typecheck`, `test`, `complexity` (Python) или `check` (JS/TS).
---
## Phase B — Project scaffolding
> Шаги 4-9 — шаблоны файлов для Python или JS/TS проекта. Можно применять к существующему репо (skip Phase A). Не зависят от GitHub remote.
## 4. Python-проект
### Инструменты
| Инструмент | Назначение | Конфиг в |
|---|---|---|
| **uv** | Package manager, virtual env | `pyproject.toml` (build + deps) |
| **ruff** | Linter + formatter (замена flake8/isort/black) | `pyproject.toml` `[tool.ruff]` |
| **mypy** | Строгая типизация | `pyproject.toml` `[tool.mypy]` |
| **pytest** + **pytest-cov** | Тесты + покрытие | `pyproject.toml` `[tool.pytest]` |
| **xenon** | Анализ сложности кода | CI workflow |
| **pre-commit** | Git hooks (ruff + mypy перед коммитом) | `.pre-commit-config.yaml` |
| **hatchling** | Build backend (wheel) | `pyproject.toml` `[build-system]` |
### pyproject.toml
> Заменить `<package-name>`, `<description>`, `<owner>/<repo>` на реальные значения. `additional_dependencies` в pre-commit — список runtime-зависимостей (для mypy).
```toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "<package-name>"
version = "0.1.0"
description = "<description>"
readme = "README.md"
license = "MIT"
requires-python = ">=3.12"
authors = [{ name = "slaid098" }]
keywords = []
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = []
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"pytest-timeout>=2.2",
"mypy>=1.10",
"ruff>=0.5",
"xenon>=0.9",
"pre-commit>=3.7",
]
[project.urls]
Homepage = "https://github.com/slaid098/<repo>"
Repository = "https://github.com/slaid098/<repo>"
Issues = "https://github.com/slaid098/<repo>/issues"
Changelog = "https://github.com/slaid098/<repo>/blob/main/CHANGELOG.md"
[tool.hatch.build.targets.wheel]
packages = ["src/<package_name>"]
# ── Ruff ──────────────────────────────────────────────────────────────────
[tool.ruff]
target-version = "py312"
line-length = 100
src = ["src", "tests"]
[tool.ruff.lint]
select = [
"E", "W", # pycodestyle
"F", # pyflakes
"I", # isort
"B", # bugbear
"UP", # pyupgrade
"SIM", # simplify
"C90", # mccabe complexity
"PL", # pylint
"RUF", # ruff-specific
"S", # bandit (security)
"TRY", # tryceratops (exception handling)
"LOG", # flake8-logging
]
ignore = [
"S101", # assert in tests
"S311", # pseudo-random for non-crypto use
"RUF001", # ambiguous Cyrillic chars (we write in Russian)
"RUF002", # same for docstrings
"RUF003", # same for comments
"TRY003", # long messages outside exception class
"PLR2004", # magic values in tests
"S106", # hardcoded passwords in tests
]
[tool.ruff.lint.mccabe]
max-complexity = 10
[tool.ruff.lint.pylint]
max-args = 5
max-branches = 12
max-returns = 5
max-statements = 50
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101", "PLR2004", "S106", "S603", "S607"]
# ── mypy ──────────────────────────────────────────────────────────────────
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
# ── pytest ────────────────────────────────────────────────────────────────
[tool.pytest.ini_options]
addopts = "--cov=<package_name> --cov-report=term-missing --cov-fail-under=90 --timeout=120"
testpaths = ["tests"]
# ── coverage ──────────────────────────────────────────────────────────────
[tool.coverage.run]
source = ["src/<package_name>"]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
```
### .pre-commit-config.yaml
```yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.18.2
hooks:
- id: mypy
additional_dependencies: []
```
`additional_dependencies` — список runtime-зависимостей проекта (из `[project.dependencies]`), чтобы mypy мог резолвить типы.
### .gitignore (Python)
```gitignore
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
*.egg
build/
dist/
.eggs/
*.spec
# Virtual environments
.venv/
venv/
# Environment / secrets
.env
*.env
!.env.template
# Testing / quality caches
.pytest_cache/
.coverage
htmlcov/
.mypy_cache/
.ruff_cache/
```
### .github/workflows/ci.yml (Python)
4 job'а: lint → typecheck → test (matrix) → complexity.
```yaml
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev
- run: uv run ruff check src/ tests/
- run: uv run ruff format --check src/ tests/
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev
- run: uv run mypy src/
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev --python ${{ matrix.python }}
- run: uv run pytest
complexity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev
- run: uv run xenon --max-absolute B --max-modules A --max-average A src/
```
### Структура проекта (Python)
```
<repo>/
├── .github/
│ ├── workflows/
│ │ └── ci.yml
│ └── dependabot.yml
├── src/
│ └── <package_name>/
│ ├── __init__.py
│ └── py.typed
├── tests/
│ └── __init__.py
├── .pre-commit-config.yaml
├── .gitignore
├── LICENSE
├── pyproject.toml
├── README.md
└── uv.lock
```
---
## 5. JS/TS-проект
### Инструменты
| Инструмент | Назначение | Конфиг в |
|---|---|---|
| **npm** | Package manager | `package.json` |
| **Biome** | Linter + formatter (замена ESLint/Prettier) | `biome.json` |
| **TypeScript** | Строгая типизация | `tsconfig.json` |
| **Vitest** + **@vitest/coverage-v8** | Тесты + покрытие | `vitest.config.ts` |
| **Knip** | Dead-code detection | `knip.json` |
### package.json
> Заменить `<name>`, `<description>` на реальные значения. `entry` в knip.json — точка входа (для tree-shaking анализа).
```json
{
"name": "<name>",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "<dev-command>",
"build": "<build-command>",
"lint": "biome check",
"format": "biome format --write",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"knip": "knip"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/node": "^22.10.0",
"@vitest/coverage-v8": "^3.0.0",
"happy-dom": "^20.10.0",
"knip": "^6.24.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
```
### biome.json
```json
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"ignore": ["node_modules", "dist", "coverage"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100,
"lineEnding": "lf"
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "all"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "error"
}
}
}
}
```
### knip.json
```json
{
"entry": ["src/index.ts"],
"project": ["src/**/*.ts", "src/**/*.tsx"],
"ignore": []
}
```
### tsconfig.json
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src", "tests"],
"exclude": ["node_modules", "dist"]
}
```
### vitest.config.ts
```typescript
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html"],
thresholds: {
lines: 60,
functions: 60,
branches: 60,
statements: 60,
},
exclude: [
"tests/**",
"dist/**",
"vitest.config.ts",
],
},
},
});
```
### .gitignore (JS/TS)
```gitignore
node_modules/
dist/
coverage/
*.log
.DS_Store
.env
```
### .github/workflows/ci.yml (JS/TS)
Single job: lint → typecheck → knip → test → build.
```yaml
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- name: Lint (Biome)
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Knip
run: npm run knip
- name: Test (Vitest + Coverage)
run: npm run test
- name: Build
run: npm run build
```
### Структура проекта (JS/TS)
```
<repo>/
├── .github/
│ ├── workflows/
│ │ └── ci.yml
│ └── dependabot.yml
├── src/
│ └── index.ts
├── tests/
├── .gitignore
├── biome.json
├── knip.json
├── package.json
├── package-lock.json
├── tsconfig.json
├── vitest.config.ts
├── LICENSE
└── README.md
```
---
## 6. Dependabot
Автоматическое обновление зависимостей. Еженедельно, 5 PR max.
### Python (uv/pip)
```yaml
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 5
```
### JS/TS (npm)
```yaml
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
```
---
## 7. Общие файлы
### LICENSE (MIT)
```
MIT License
Copyright (c) 2026 slaid098
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### .editorconfig
```ini
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{py,toml}]
indent_style = space
indent_size = 4
[*.{ts,tsx,js,jsx,json,yml,yaml,css,md}]
indent_style = space
indent_size = 2
```
---
## 8. Установка pre-commit
### Python
```bash
uv sync --extra dev
uv run pre-commit install
uv run pre-commit run --all-files
```
### JS/TS
Pre-commit hooks не используются. Все quality gates — в CI (lint → typecheck → knip → test → build).
---
## 9. Чек-лист верификации
- [ ] Репозиторий создан (`gh repo create`)
- [ ] `gh auth setup-git` выполнен (push/pull работает)
- [ ] Squash-only merge, delete branch on merge (Step 2)
- [ ] Branch protection на main (Step 3)
- [ ] CI пайплайн зелёный на первом PR
- [ ] Pre-commit hooks установлены (Python) / CI гоняет (JS/TS)
- [ ] Dependabot включён (Settings → Code security → Dependabot)
- [ ] LICENSE, .gitignore, .editorconfig в репозитории
- [ ] `uv.lock` / `package-lock.json` закоммичен

View file

@ -14,11 +14,11 @@ Support block, Quick Start, language switcher). Скилл даёт контек
- **Новый репо**`create-readme` (mode: `create`) — генерирует
стандартизированный двуязычный README с нуля.
- **Проверка существующего README**`.opencode/scripts/project-status.py`
(`check_readme`) — проверяет, что структура соответствует стандарту витрины.
- **После ручных правок README** → всегда проверка через `check_readme`. Любая правка руками
- **Проверка существующего README**`create-readme` (mode: `validate`) —
проверяет, что структура соответствует стандарту витрины.
- **После ручных правок README** → всегда `validate`. Любая правка руками
агента (через Edit/Write) может нарушить разделители — после правок
обязательна проверка.
обязательна валидация.
Не генерируй README вручную через Write — структура критична для парсинга
витриной. Только через тулзу `create-readme`.
@ -44,16 +44,13 @@ Support block, Quick Start, language switcher). Скилл даёт контек
`"./assets/cover.png"` — можно не передавать.
3. Ручные правки если нужно (агент редактирует файл напрямую через Edit) —
например, расширить `custom_sections`, поправить формулировки.
4. Проверка через `.opencode/scripts/project-status.py` (`check_readme`) →
структура соответствует стандарту витрины (в т.ч. наличие
`assets/cover.png` reference).
5. Если проверка fails → фикс нарушения → повторная проверка. Цикл пока не
4. `create-readme` (mode: `validate`) → проверяет, что структура не нарушена
(теперь в т.ч. наличие `assets/cover.png` reference).
5. Если `validate` fails → фикс нарушения → re-`validate`. Цикл пока не
пройдёт.
Локальный режим (по умолчанию): тулза пишет в `file_path` (default
`README.md`) через `fs.writeFileSync`, путь резолвится относительно
рабочей директории сессии (`context.worktree`) — существующий файл
перезаписывается. Удалённый режим: передай `repo`
`README.md`) через `fs.writeFileSync`. Удалённый режим: передай `repo`
(`owner/name`) — тулза сделает PUT через `gh api
repos/{owner}/{repo}/contents/README.md` с base64-контентом и SHA.
@ -96,7 +93,7 @@ README ссылается именно на этот путь через `![Cove
Поэтому структура README **должна быть гарантирована тулзой**, а не агентом.
Агент может менять текст *внутри* разделителей, но не должен удалять/двигать
сами разделители. `check_readme` ловит такие нарушения.
сами разделители. `validate` ловит такие нарушения.
## 5. Шаблон README (reference)
@ -182,37 +179,35 @@ README ссылается именно на этот путь через `![Cove
## 💬 Support and contacts / Поддержка и контакты
👉 **[slaid098.dev/contacts](https://slaid098.dev/contacts)**
👉 **[slaid098.dev/support](https://slaid098.dev/support)**
```
`check_readme` (project-status.py) проверяет: наличие всех 6 пар EN/RU
разделителей (tagline + summary + features), непустой контент между ними, H1
title prefix `# 🚀 `, **cover image
`validate` проверяет: наличие всех 6 пар EN/RU разделителей (tagline + summary +
features), непустой контент между ними, H1 title prefix `# 🚀 `, **cover image
reference `assets/cover.png`** (substring-чек, без проверки существования
файла), ссылку
`slaid098.dev/contacts`, секции Quick Start (EN) и Быстрый старт (RU), language
`slaid098.dev/support`, секции Quick Start (EN) и Быстрый старт (RU), language
switcher `[English]` / `[Русский]`, заголовок `## 🇷🇺 Русский` (не "Русская
версия"), anchor `[Русский](#-русский)` (не `#-русская-версия`). Флагирует
ручной заголовок `## License` / `## LICENSE` / `## Лицензия` как FAIL
ручной заголовок `## License` / `## LICENSE` / `## Лицензия` как ERROR
дубликат GitHub sidebar (GitHub рендерит license из LICENSE-файла). Шаги
`quick_start_steps_*` не влияют на валидацию — они рендерятся вне delimiter-пар
(summary/features).
## 6. Независимость от project-template
## 6. Независимость от repo-init
- Скилл `project-template` (init flow) создаёт проект через cookiecutter —
шаблоны README.md НЕ содержат (issue #269): свежий проект рождается без
README.
- `repo-readme` (через тулзу `create-readme`) **создаёт** полный README с нуля
(delimiter tags, bilingual, cover) по ручному вызову после init.
- Может применяться к существующим репо без `project-template` — тулза
перезапишет `README.md` (локально) или обновит через GitHub API (с SHA).
- Скилл `repo-init` создаёт **пустой** `README.md` как часть инициализации
репо.
- `repo-readme` (через тулзу `create-readme`) **наполняет** его
стандартизированным контентом.
- Может применяться к существующим репо без `repo-init` — тулза перезапишет
`README.md` (локально) или обновит через GitHub API (с SHA).
## 7. Параметры тулзы (кратко)
`create-readme`:
- `mode``"create"` (обязательный).
- `mode``"create"` | `"validate"` (обязательный).
- `repo_name`, `tagline_en`, `tagline_ru`, `why_en`, `what_en`, `why_ru`,
`what_ru`, `quick_start`, `features_en`, `features_ru` — обязательны для
`create`.
@ -318,6 +313,6 @@ Access at [http://localhost:4096](http://localhost:4096)
`❌ tagline_en is required for create mode`.
**Существующие README** (без tagline delimiter-тегов) станут invalid при
проверке `check_readme` — `Missing <!-- tagline-en:start --> delimiter` и
`validate` — `Missing <!-- tagline-en:start --> delimiter` и
`Missing <!-- tagline-ru:start --> delimiter`. Регенерация README через
`create` (с новыми параметрами) делается отдельным шагом после merge.

View file

@ -1,11 +1,11 @@
---
name: run-pipeline
description: Autonomous PR pipeline executor. Delegates 6 phases to subagents, does not improvise order, does not merge on red CI. Also when user says "запусти пайплайн", "pipeline", "run pipeline".
description: Autonomous PR pipeline executor. Delegates 7 phases to subagents, does not improvise order, does not merge on red CI. Also when user says "запусти пайплайн", "pipeline", "run pipeline".
---
# Run Pipeline
Автономная процедура-loop для проведения PR через 6 фаз. Source of truth для
Автономная процедура-loop для проведения PR через 7 фаз. Source of truth для
порядка и действий — `pipeline-status` tool. NEXT action из tool явно
указывает `subagent_type` + `template` (кроме MERGE — `merge-pr` tool).
@ -16,7 +16,7 @@ description: Autonomous PR pipeline executor. Delegates 6 phases to subagents, d
1. Вызови tool `pipeline-status({pr_number: M})` — вернёт статус всех фаз + строку `NEXT: <action>`.
2. Если вывод содержит `Status: COMPLETE` → финальный репорт пользователю, exit.
3. Если вывод содержит `AMBIGUOUS` → репорт пользователю с причиной, STOP.
4. Иначе — исполни action из строки `NEXT:` (используй prompt templates A, C, D, E, F ниже).
4. Иначе — исполни action из строки `NEXT:` (используй prompt templates A-E ниже).
5. 1 строка прогресса пользователю (формат: `✅ <phase> — <action executed>`).
6. Re-loop (шаг 1).
@ -45,7 +45,10 @@ description: Autonomous PR pipeline executor. Delegates 6 phases to subagents, d
1. Checkout new branch `type/scope/kebab-description` от master.
2. Реализуй по спеке issue (точно, без отклонений). Если спека содержит ошибки,
зафикь и продолжай — не додумывай.
3. Коммиты через `commit({ message: "type(scope): description" })` tool (НЕ
3. Создай handoff + ADR: `bash .opencode/scripts/scaffold-handoff.sh M <slug>`
(M — будет PR номер, используй placeholder `<PR-NUMBER>` в handoff
frontmatter, потом исправишь после create-pr).
4. Коммиты через `commit({ message: "type(scope): description" })` tool (НЕ
raw `git commit` — заблокирован deny). Формат: ≤72 chars, English, no
period, no body unless necessary. Минимум 3-4 логических коммита.
Перед КАЖДЫМ `commit``git status` для проверки staged set. `commit`
@ -53,12 +56,43 @@ description: Autonomous PR pipeline executor. Delegates 6 phases to subagents, d
`git add <конкретные-пути>`, НЕ `git add -A` (иначе лишние файлы уйдут в
коммит). Если в индексе лишнее (например `memory-save` stage'нул всё
через `git add -A`) — не коммить: сначала `git restore --staged <file>`.
4. Push ветку (`git push -u origin HEAD`), затем создай PR через tool:
`create-pr({ title: "type(scope): description", body: "## Что сделано\n...\n\n## Почему\n...\n\n## Watch out\n...\n\n## Pending\n...\n\nCloses #N", issue_number: N })`.
PR body ОБЯЗАТЕЛЬНО содержит 4 heading'а: `## Что сделано`, `## Почему`,
`## Watch out`, `## Pending`. Заполни осмысленно (для Watch out/Pending
можно `—` если реально нет контента).
5. Верни PR номер M.
5. Push ветку (`git push -u origin HEAD`), затем создай PR через tool:
`create-pr({ title: "type(scope): description", body: "## Что сделано\n...\n\n## Почему\n...\n\nCloses #N", issue_number: N })`.
6. После получения PR номера — исправь placeholder `<PR-NUMBER>` в handoff
frontmatter, отдельный коммит `docs(handoff): set PR number` через
`commit` tool, push.
7. Верни PR номер M.
Если найдёшь баг вне scope текущей задачи — загрузи skill `bug-discovery` через `skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам. Сообщи оркестратору: "Created issue #N: ...".
```
### Template B (docs-review)
```
Review PR#M в текущем репо (pre-merge, режим docs).
1. `gh pr checkout M`.
2. Анализируй структурные изменения: `git diff origin/master...HEAD --stat`.
3. Сравни с `docs/project-map/` — обнови если structural changes.
4. Валидируй handoff `docs/handoff/pr-M-*.md`: 4 секции (Что сделано, Почему,
Pending, Watch out) заполнены осмысленно (не пустые плейсхолдеры).
5. Валидируй ADR `docs/decisions/*-pr-M-*.md`: 4 секции (Статус, Контекст,
Решение, Альтернативы).
6. Если криво — почини (edit: allow).
7. `git add docs/project-map/ docs/handoff/ docs/decisions/` (add — НЕ
заблокирован). Перед `commit``git status` для проверки staged set
(`commit` tool НЕ делает `git add` — коммитит только staged; НЕ
`git add -A`, иначе лишние файлы уйдут в коммит). Затем
`commit({ message: "docs: update project map + handoff + ADR" })` tool,
и `git push`.
8. **ВСЕГДА** оставь PR comment (даже если structural changes нет) — это
детерминированный marker для `check_docs` в pipeline-status.py. Без comment
pipeline блокируется на DOCS phase. Используй tool `post-docs-review` (НЕ
raw bash-вызов `gh`) — tool гарантирует heading `## Docs Review Summary` и
verdict-enum (zod), формат который парсит `check_docs`.
`post-docs-review({ pr_number: M, verdict: "APPROVE|FIXED|NO_CHANGES", body: "- Project map: ...\n- Handoff: ...\n- ADR: ..." })`.
Heading `## Docs Review Summary` — обязательно (regex `Docs Review`),
tool добавляет его автоматически — НЕ форматируй heading/verdict вручную.
Если tool вернул строку начинающуюся с `⚠️ ...failed` — СООБЩИ оркестратору
о сбое и STOP (не fallback на raw bash).
Если найдёшь баг вне scope текущей задачи — загрузи skill `bug-discovery` через `skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам. Сообщи оркестратору: "Created issue #N: ...".
```
@ -67,11 +101,11 @@ description: Autonomous PR pipeline executor. Delegates 6 phases to subagents, d
```
Review PR#M в текущем репо.
1. `gh pr view M --json headRefName,body,title`.
2. `git diff origin/HEAD...HEAD`.
2. `git diff origin/master...HEAD`.
3. Load project skills: `find .opencode/skills/ -name "SKILL.md"`, грузи каждый
через `skill("<name>")`.
4. Проверь: code quality, architecture, error handling, security, testing,
duplication, project-specific rules, PR hygiene.
duplication, project-specific rules, PR hygiene, handoff/ADR (quick check).
5. Оставь review через tool `post-review` (НЕ `gh pr review --approve`
GitHub блокирует self-approve; НЕ raw bash-вызов `gh`) — tool гарантирует
heading `## Code Review Summary` + verdict-enum которые парсит
@ -110,13 +144,11 @@ Log: `gh run view <run-id> --log-failed` output:
(default `~/.local/share/opencode/opencode-memory`, override через `OPENCODE_MEMORY_DIR`;
`{host}/{org}/{repo}` вычисли через
`git remote get-url origin` — см. `memory-syncer.md:38`).
1. Прочитай PR body через `gh pr view M --json body,title` (+ `gh issue view`
для контекста issue, если PR ссылается на issue). Текущее состояние — уже
смерженный default branch (checkout/pull НЕ нужны, запрещены permission
set'ом memory-syncer'а).
1. Прочитай `docs/handoff/pr-M-*.md` и `docs/decisions/*-pr-M-*.md` с текущего
состояния (уже смерженный default branch — checkout/pull НЕ нужны, запрещены
permission set'ом memory-syncer'а).
2. Найди durable gotchas (не статусы, не "сейчас делаем"). Паттерны, указатели,
non-obvious API quirks. Источник: PR body `## Watch out` (gotchas) +
`## Pending` (follow-ups) + diff (`git diff` текущего state vs parent).
non-obvious API quirks.
3. Добавь записи формата `- [YYYY-MM-DD, PR#M] <summary>` в конец файла
(секция "Handoff digest").
4. Квитанция ВСЕГДА (даже если durable нет): `- [date, PR#M] — (нет durable-записей)`.
@ -139,7 +171,7 @@ merge-pr({ pr_number: M })
Если вернулась `⚠️ merge-pr failed ...` → репорт пользователю, STOP (НЕ retry
через raw bash — это нарушит orchestrator-контракт). Если `PR #M merged
successfully ...` → 1 строка прогресса и re-loop (`pipeline-status` покажет
`Status: COMPLETE` или перейдёт на MEMORY phase — 6-я фаза).
`Status: COMPLETE` или перейдёт на MEMORY phase).
## API Restrictions

View file

@ -41,17 +41,13 @@ truth для порядка и действий — `spec-status` tool. На в
Общий для всех типов: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI.
- **backend**: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich — legacy), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
- **fullstack**: backend + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip + mobile-first (PWA + axe + Playwright mobile))
- **backend**: FastAPI + uvicorn, Tortoise ORM + Aerich, pydantic-settings, Loguru
- **fullstack**: backend + frontend/ (React 19 + Vite + Biome + TS strict + Vitest + Knip + happy-dom)
- **mcp-server**: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
- **cli**: Typer (default) / click / argparse, hatchling build
- **bot**: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
- **worker**: Prefect flows + tasks, prefect.yaml, docker-compose worker profile
### Миграционная заметка (stack.md)
Если существующий `docs/spec/stack.md` содержит `react` или `aerich` (устаревшие значения до issue #231), spec-status пометит Phase 2 как NOT_DONE (`STACK_REQUIRED` теперь требует `svelte`/`sveltekit` для fullstack и не требует `aerich`). Обнови stack.md: замени `react``svelte`/`sveltekit`, удали `aerich` (Tortoise 1.0+ имеет встроенные миграции `tortoise makemigrations`).
## 9 фаз
### Phase 0: DETECT (subagent, без вопроса юзеру)
@ -65,7 +61,7 @@ Prompt template A (см. ниже).
```
Выбери тип проекта:
[1] backend — FastAPI + Tortoise, REST API, без frontend
[2] fullstack — backend + SvelteKit/Svelte 5 + Tailwind v4 + shadcn-svelte dashboard (TypeScript, monorepo)
[2] fullstack — backend + React 19/Vite dashboard (monorepo)
[3] mcp-server — MCP + REST сервер (Patchright/Playwright over CDP)
[4] cli — Python CLI tool (Typer)
[5] bot — Telegram bot (aiogram 3)
@ -88,7 +84,7 @@ backend:
- Auth: [1] none v1 / [2] JWT / [3] X-API-Key
fullstack:
- frontend: [1] SvelteKit + Svelte 5 + Tailwind v4 + shadcn-svelte (default, mobile-first: PWA + axe + Playwright mobile — silent) / [2] add later
- frontend: [1] React 19 (default) / [2] SvelteKit / [3] add later
- DB: (same as backend)
- Auth: (same as backend)
@ -160,7 +156,7 @@ Prompt template F (см. ниже).
```
Дефолтный roadmap (можешь править):
1. scaffolding — repo structure, CI, .gitignore, LICENSE (через project-template skill init flow)
1. scaffolding — repo structure, CI, .gitignore, LICENSE (через repo-init skill)
2. core: <module 1> — ...
3. core: <module 2> — ...
4. auth (если выбран auth в Phase 2)
@ -235,8 +231,8 @@ Spec complete. Issues: #N1, #N2, ...
Тип проекта: <type> (из frontmatter meta.md).
Default stack для типа (хардкод, добавить всегда):
- Общий: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI
- backend: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
- fullstack: + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip + mobile-first (PWA + axe + Playwright mobile))
- backend: FastAPI + uvicorn, Tortoise ORM + Aerich, pydantic-settings, Loguru
- fullstack: + frontend/ (React 19 + Vite + Biome + TS strict + Vitest + Knip + happy-dom)
- mcp-server: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
- cli: Typer (default) / click / argparse, hatchling build
- bot: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
@ -312,12 +308,11 @@ Default stack для типа (хардкод, добавить всегда):
## Контракты (ожидаемое поведение / API)
## Инварианты (правила без исключений)
## Граничные случаи (что при ошибках)
## Влияние на связанные компоненты (зависящие файлы/оракулы/агенты/промпты/валидаторы; paired updates; «нет связанных компонентов» для тривиальных задач)
## Вне scope (что НЕ делаем)
## Критерии приемки (как проверяем)
## Связанные ресурсы (Part of spec, ref к docs/spec/roadmap.md)
- Issue #1 (scaffolding) body ДОЛЖЕН включать:
"Используй project-template skill init flow для: cookiecutter по типу проекта (pyproject.toml, CI, .gitignore, LICENSE, dependabot, pre-commit). Структура — из ## Структура в docs/spec/modules.md."
"Используй repo-init skill для: pyproject.toml, CI, .gitignore, LICENSE, dependabot, pre-commit. Структура — из ## Структура в docs/spec/modules.md."
- `create-issue({ title: "type(scope): description", body: "<body>", labels: ["enhancement", "from-spec"] })` tool (НЕ raw `gh issue create` — заблокирован deny)
4. Собери реальные номера issues из вывода tool.
5. Update docs/spec/roadmap.md: добавь реальные #N номера. Update docs/spec/meta.md: executed=true, phase=8.

View file

@ -1,8 +0,0 @@
{
"project_name": "my_project",
"project_type": "backend",
"description": "Project description",
"use_auth": ["no", "yes"],
"use_db": ["yes", "no"],
"python_version": "3.13"
}

View file

@ -1,64 +0,0 @@
"""Post-generation hook for the backend cookiecutter template.
Removes files that are conditional on the ``use_auth`` and ``use_db`` flags
so the rendered tree only contains the parts the user asked for.
- ``use_auth == "no"`` -> drop ``routes/auth.py``, ``services/auth_service.py``,
``models/user.py`` (hashed_password), and strip the auth dependency wiring.
- ``use_db == "no"`` -> drop ``db/``, ``migrations/``, ``connection.py``.
The DB dep (``tortoise-orm``/``asyncpg``) is conditionally rendered in
``pyproject.toml`` (jinja if-block on use_db), so no marker needs
to be written: the project-status oracle auto-detects db-projects from
deps (issue #274).
"""
from __future__ import annotations
import shutil
from pathlib import Path
PROJECT_DIR = Path.cwd()
def _remove(path: str) -> None:
"""Remove a file or directory relative to the generated project root."""
p = PROJECT_DIR / path
if p.is_dir():
shutil.rmtree(p, ignore_errors=True)
elif p.exists():
p.unlink()
def main() -> None:
use_auth = "{{ cookiecutter.use_auth }}"
use_db = "{{ cookiecutter.use_db }}"
pkg = "{{ cookiecutter.project_name }}"
if use_auth == "no":
_remove(f"src/{pkg}/api/v1/routes/auth.py")
_remove(f"src/{pkg}/services/auth_service.py")
_remove("tests/test_auth.py")
if use_db == "no":
# db is the root cause for the broken-conditional findings: files
# with unconditional ``from ...db.models.user import User`` must be
# stripped together with db/, otherwise the generated project
# fails to import (ImportError/NameError on startup).
_remove(f"src/{pkg}/db")
_remove("migrations")
_remove(f"src/{pkg}/services/user_service.py")
_remove(f"src/{pkg}/api/v1/routes/users.py")
_remove(f"src/{pkg}/api/v1/dependencies.py")
_remove(f"src/{pkg}/schemas/user.py")
_remove("tests/unit/test_user.py")
_remove("tests/unit/test_user_service.py")
_remove("tests/api/test_users.py")
if use_auth == "yes":
# auth_service imports User; strip it and its wiring too.
_remove(f"src/{pkg}/api/v1/routes/auth.py")
_remove(f"src/{pkg}/services/auth_service.py")
_remove("tests/test_auth.py")
if __name__ == "__main__":
main()

View file

@ -1,35 +0,0 @@
"""Pre-generation hook for the backend cookiecutter template.
Validates that ``project_name`` is a valid Python identifier so the
generated package dir + imports (``from <project_name>.X import Y``) do
not raise ``SyntaxError``. Hyphens, dots, spaces and leading digits are
rejected with a hint to use an underscore-separated name instead.
See issue #262: default ``my-project`` used to render as
``from my-project.config.settings import settings`` which is a
``SyntaxError`` (``-`` is not allowed in a Python identifier).
"""
from __future__ import annotations
import re
import sys
_VALID_IDENTIFIER = re.compile(r"^[a-z][a-z0-9_]*$")
def main() -> None:
project_name = "{{ cookiecutter.project_name }}"
if not _VALID_IDENTIFIER.fullmatch(project_name):
sys.exit(
f"Invalid project_name: {project_name!r}\n"
"project_name must be a valid Python identifier matching "
"^[a-z][a-z0-9_]*$ (lowercase, no hyphens/dots/spaces, "
"no leading digit).\n"
f"Use 'my_project' instead of 'my-project' (or 'my.project', "
"'my project')."
)
if __name__ == "__main__":
main()

View file

@ -1,10 +0,0 @@
version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly

View file

@ -1,42 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev
- run: uv run ruff check .
- run: uv run ruff format --check .
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev
- run: uv run mypy src tests
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev
- run: uv run pytest
build:
runs-on: ubuntu-latest
needs: [lint, typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev
- run: uv build

View file

@ -1,18 +0,0 @@
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.eggs/
build/
dist/
.coverage
htmlcov/
.tox/
.mypy_cache/
.ruff_cache/
.pytest_cache/
*.sqlite3
*.db
.env
.venv/
venv/

View file

@ -1,20 +0,0 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
additional_dependencies: [pydantic-settings]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: check-added-large-files

View file

@ -1 +0,0 @@
{{ cookiecutter.python_version }}

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) {% now 'utc', '%Y' %} slaid098
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,18 +0,0 @@
# Application
APP__ENVIRONMENT=dev
SERVER__HOST=0.0.0.0
SERVER__PORT=8000
# Database (nested via __ separator — pydantic-settings convention)
{% if cookiecutter.use_db == "yes" %}
DATABASE__URL=sqlite://db.sqlite3
{% endif %}
{% if cookiecutter.use_auth == "yes" %}
# Auth
JWT__SECRET=change-me-in-production
JWT__ALGORITHM=HS256
JWT__EXPIRE_MINUTES=60
{% endif %}
# IP whitelist (no hardcoded production IPs — override in production)
IP_WHITELIST=["127.0.0.1", "::1"]

View file

@ -1,42 +0,0 @@
"""FastAPI application entry point for {{ cookiecutter.project_name }}."""
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from {{ cookiecutter.project_name }}.config.logger import setup_logging
{% if cookiecutter.use_db == "yes" %}
from {{ cookiecutter.project_name }}.db.connection import close_db, init_db
{% endif %}
from {{ cookiecutter.project_name }}.api.router import api_router
_BASE_DIR = Path(__file__).resolve().parent
@asynccontextmanager
async def lifespan(app: FastAPI):
{% if cookiecutter.use_db == "yes" %}setup_logging()
await init_db()
try:
yield
finally:
await close_db(){% else %}setup_logging()
yield{% endif %}
app = FastAPI(lifespan=lifespan)
static_dir = _BASE_DIR / "static"
static_dir.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=static_dir), name="static")
app.include_router(api_router, prefix="/api")
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}

View file

@ -1,8 +0,0 @@
# Tortoise migrations directory
This directory holds migration files generated by the built-in Tortoise
migrator. Run:
python -m tortoise.migrator makemigrations
Generated files land here and are committed to git.

View file

@ -1,138 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "{{ cookiecutter.project_name }}"
version = "0.1.0"
description = "{{ cookiecutter.description }}"
license = "MIT"
requires-python = ">={{ cookiecutter.python_version }}"
authors = [{ name = "slaid098" }]
keywords = []
classifiers = [
"Development Status :: 4 - Beta",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.13",
]
dependencies = [
"fastapi",
"uvicorn[standard]",
"pydantic-settings",
"loguru",
{% if cookiecutter.use_db == "yes" %}
"tortoise-orm",
"asyncpg",
{% endif %}
{% if cookiecutter.use_auth == "yes" %}
"passlib[bcrypt]",
"pyjwt",
{% endif %}
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"pytest-asyncio>=0.23",
"pytest-timeout>=2.2",
"httpx",
"mypy>=1.10",
"ruff>=0.5",
"pre-commit>=3.7",
]
[project.urls]
Homepage = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues"
[tool.hatch.build.targets.wheel]
packages = ["src/{{cookiecutter.project_name}}"]
# ── Ruff ──────────────────────────────────────────────────────────────────
[tool.ruff]
target-version = "py313"
line-length = 100
src = ["src", "tests"]
[tool.ruff.lint]
select = [
"E", "W",
"F",
"I",
"B",
"UP",
"SIM",
"C90",
"PL",
"RUF",
"S",
"TRY",
"LOG",
]
ignore = [
"S101",
"S311",
"RUF001",
"RUF002",
"RUF003",
"TRY003",
"PLR2004",
"S106",
]
[tool.ruff.lint.mccabe]
max-complexity = 10
[tool.ruff.lint.pylint]
max-args = 5
max-branches = 12
max-returns = 5
max-statements = 50
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101", "PLR2004", "S106", "S603", "S607", "PLR0913"]
# ── mypy ──────────────────────────────────────────────────────────────────
[tool.mypy]
python_version = "{{ cookiecutter.python_version }}"
strict = true
explicit_package_bases = true
warn_return_any = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
# ── pytest ────────────────────────────────────────────────────────────────
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = "--cov=src --cov-report=term-missing --timeout=120"
# ── coverage ──────────────────────────────────────────────────────────────
[tool.coverage.run]
source = ["src"]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
# ── project-status ─────────────────────────────────────────────────────────
[tool.project-status]
route_line_limit = 50
min_test_count = 1
require_branch_protection = false

View file

@ -1 +0,0 @@
"""{{ cookiecutter.project_name }} package."""

View file

@ -1,3 +0,0 @@
"""API package for {{ cookiecutter.project_name }}."""
from {{ cookiecutter.project_name }}.api.router import api_router # noqa: F401

View file

@ -1,8 +0,0 @@
"""API router aggregation for {{ cookiecutter.project_name }}."""
from fastapi import APIRouter
from {{ cookiecutter.project_name }}.api.v1.router import v1_router
api_router = APIRouter()
api_router.include_router(v1_router, prefix="/v1")

View file

@ -1,3 +0,0 @@
"""v1 API package."""
from {{ cookiecutter.project_name }}.api.v1.router import v1_router # noqa: F401

View file

@ -1,49 +0,0 @@
"""Shared dependencies for v1 routes.
``check_ip_whitelist`` is always present; ``get_current_user`` is wired
only when ``use_auth == "yes``.
"""
from __future__ import annotations
from fastapi import Depends, HTTPException, Request, status
from {{ cookiecutter.project_name }}.config.settings import settings
DEFAULT_WHITELIST = ["127.0.0.1", "::1"]
async def check_ip_whitelist(request: Request) -> None:
"""Reject requests from non-whitelisted IPs.
Defaults to ``["127.0.0.1", "::1"]`` (no hardcoded production IPs);
override via ``IP_WHITELIST`` in env.
"""
client = request.client.host if request.client else None
whitelist = settings.ip_whitelist or DEFAULT_WHITELIST
if client and client not in whitelist:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"IP {client} not allowed",
)
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from {{ cookiecutter.project_name }}.services.auth_service import AuthService
_bearer = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
auth: AuthService = Depends(AuthService),
) -> str:
"""Resolve the current user from the bearer token (JWT).
Returns the username/subject of the token. Only present when
``use_auth == "yes"``.
"""
return await auth.decode_access_token(credentials.credentials)
{% endif %}

View file

@ -1,22 +0,0 @@
"""Versioned router (v1) for {{ cookiecutter.project_name }}."""
from fastapi import APIRouter
{% if cookiecutter.use_db == "yes" %}
from fastapi import Depends
from {{ cookiecutter.project_name }}.api.v1.dependencies import check_ip_whitelist
from {{ cookiecutter.project_name }}.api.v1.routes import users
{% endif %}
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
from {{ cookiecutter.project_name }}.api.v1.routes import auth
{% endif %}
{% if cookiecutter.use_db == "yes" %}
v1_router = APIRouter(dependencies=[Depends(check_ip_whitelist)])
v1_router.include_router(users.router, prefix="/users", tags=["users"])
{% else %}
v1_router = APIRouter()
{% endif %}
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
v1_router.include_router(auth.router, prefix="/auth", tags=["auth"])
{% endif %}

View file

@ -1,8 +0,0 @@
"""Routes package for v1."""
{% if cookiecutter.use_db == "yes" %}
from {{ cookiecutter.project_name }}.api.v1.routes import users # noqa: F401
{% endif %}
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
from {{ cookiecutter.project_name }}.api.v1.routes import auth # noqa: F401
{% endif %}

View file

@ -1,22 +0,0 @@
"""Auth routes — /login, /register (only when use_auth=yes)."""
from fastapi import APIRouter
from {{ cookiecutter.project_name }}.schemas.user import Token, UserCreate, UserLogin, UserResponse
from {{ cookiecutter.project_name }}.services.auth_service import AuthService
router = APIRouter()
@router.post("/register", response_model=UserResponse, status_code=201)
async def register(payload: UserCreate) -> UserResponse:
"""Register a new user — returns the public profile."""
user = await AuthService.register(payload.username, payload.email, payload.password)
return UserResponse(id=user.id, username=user.username, email=user.email)
@router.post("/login", response_model=Token)
async def login(payload: UserLogin) -> Token:
"""Login with username + password — returns a JWT."""
access_token = await AuthService.login(payload.username, payload.password)
return Token(access_token=access_token)

View file

@ -1,22 +0,0 @@
"""User routes — thin handlers (≤50 lines), delegate to services."""
from fastapi import APIRouter
from {{ cookiecutter.project_name }}.schemas.user import UserResponse
from {{ cookiecutter.project_name }}.services.user_service import UserService
router = APIRouter()
@router.get("", response_model=list[UserResponse])
async def list_users() -> list[UserResponse]:
"""List users — thin handler, business logic lives in the service."""
users = await UserService.get_users()
return [UserResponse.model_validate(u) for u in users]
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(user_id: int) -> UserResponse:
"""Get a single user by id — thin handler."""
user = await UserService.get_user(user_id)
return UserResponse.model_validate(user)

View file

@ -1,3 +0,0 @@
"""Configuration package — settings + logger."""
from {{ cookiecutter.project_name }}.config.settings import settings # noqa: F401

View file

@ -1,17 +0,0 @@
"""Loguru logging setup."""
from __future__ import annotations
import sys
from loguru import logger
def setup_logging() -> None:
"""Configure loguru sink — remove default handler, add stdout."""
logger.remove()
logger.add(
sys.stdout,
level="INFO",
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level}</level> | {message}",
)

View file

@ -1,59 +0,0 @@
"""Application settings via pydantic-settings.
Nested fields use the ``__`` separator (pydantic-settings convention):
``DATABASE__URL`` -> ``settings.database.url``.
"""
from __future__ import annotations
from enum import StrEnum
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Environment(StrEnum):
DEV = "dev"
STAGING = "staging"
PROD = "prod"
class ServerSettings(BaseSettings):
host: str = "0.0.0.0"
port: int = 8000
{% if cookiecutter.use_db == "yes" %}
class DatabaseSettings(BaseSettings):
url: str = "sqlite://db.sqlite3"
{% endif %}
{% if cookiecutter.use_auth == "yes" %}
class JWTSettings(BaseSettings):
secret: str = "change-me-in-production"
algorithm: str = "HS256"
expire_minutes: int = 60
{% endif %}
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_nested_delimiter="__",
extra="ignore",
)
environment: Environment = Environment.DEV
server: ServerSettings = Field(default_factory=ServerSettings)
{% if cookiecutter.use_db == "yes" %}
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
{% endif %}
{% if cookiecutter.use_auth == "yes" %}
jwt: JWTSettings = Field(default_factory=JWTSettings)
{% endif %}
ip_whitelist: list[str] = Field(default_factory=lambda: ["127.0.0.1", "::1"])
settings = Settings()

View file

@ -1,5 +0,0 @@
{% if cookiecutter.use_db == "yes" %}"""DB package — Tortoise ORM connection + models."""
from {{ cookiecutter.project_name }}.db.connection import close_db, init_db # noqa: F401
{% else %}"""DB package (disabled — use_db=no)."""
{% endif %}

View file

@ -1,29 +0,0 @@
"""Tortoise ORM connection setup.
Uses ``Path(__file__)``-relative paths (no CWD reliance) so the app is
portable regardless of the working directory it is launched from.
"""
from __future__ import annotations
from pathlib import Path
from tortoise import Tortoise
from {{ cookiecutter.project_name }}.config.settings import settings
_MODELS_PATH = "{{ cookiecutter.project_name }}.db.models"
async def init_db() -> None:
"""Initialize Tortoise with generate_schemas (built-in, NOT Aerich)."""
await Tortoise.init(
db_url=settings.database.url,
modules={"models": [_MODELS_PATH]},
)
await Tortoise.generate_schemas(safe=True)
async def close_db() -> None:
"""Close all Tortoise connections."""
await Tortoise.close_connections()

View file

@ -1,3 +0,0 @@
"""Tortoise ORM models."""
from {{ cookiecutter.project_name }}.db.models.user import User # noqa: F401

View file

@ -1,25 +0,0 @@
"""User model.
When ``use_auth == "yes"`` the ``hashed_password`` field is present;
otherwise the model holds only the public profile fields.
"""
from __future__ import annotations
from tortoise import fields
from tortoise.models import Model
class User(Model):
id = fields.IntField(pk=True)
username = fields.CharField(max_length=64, unique=True)
email = fields.CharField(max_length=128, unique=True)
{% if cookiecutter.use_auth == "yes" %}
hashed_password = fields.CharField(max_length=255)
{% endif %}
class Meta:
table = "users"
def __str__(self) -> str:
return self.username

View file

@ -1,6 +0,0 @@
"""Pydantic schemas."""
from {{ cookiecutter.project_name }}.schemas.base import MetaResponse, PaginatedResponse # noqa: F401
{% if cookiecutter.use_db == "yes" %}
from {{ cookiecutter.project_name }}.schemas.user import UserResponse # noqa: F401
{% endif %}

View file

@ -1,24 +0,0 @@
"""Base schemas — shared response envelopes."""
from __future__ import annotations
from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class MetaResponse(BaseModel):
"""Standard meta block for API responses."""
total: int
page: int = 1
page_size: int = 20
class PaginatedResponse(BaseModel, Generic[T]):
"""Generic paginated envelope: ``{items, meta}``."""
items: list[T]
meta: MetaResponse

View file

@ -1,49 +0,0 @@
"""User schemas.
When ``use_auth == "yes"`` the auth-related schemas (``UserCreate``,
``UserLogin``, ``Token``) are added; ``UserResponse`` and ``UserInput``
are always present.
"""
from __future__ import annotations
from pydantic import BaseModel, EmailStr, Field
class UserInput(BaseModel):
"""Input payload for creating a user."""
username: str = Field(min_length=3, max_length=64)
email: EmailStr
class UserResponse(BaseModel):
"""Public user representation (never leaks the password)."""
id: int
username: str
email: EmailStr
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
class UserCreate(BaseModel):
"""Registration payload — username, email, plain password."""
username: str = Field(min_length=3, max_length=64)
email: EmailStr
password: str = Field(min_length=8, max_length=128)
class UserLogin(BaseModel):
"""Login payload — username + plain password."""
username: str
password: str
class Token(BaseModel):
"""JWT response envelope."""
access_token: str
token_type: str = "bearer"
{% endif %}

View file

@ -1,8 +0,0 @@
"""Services package — business logic (Tortoise queries)."""
{% if cookiecutter.use_db == "yes" %}
from {{ cookiecutter.project_name }}.services.user_service import UserService # noqa: F401
{% endif %}
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
from {{ cookiecutter.project_name }}.services.auth_service import AuthService # noqa: F401
{% endif %}

View file

@ -1,68 +0,0 @@
"""Auth service — JWT issuance + verification (passlib[bcrypt] + pyjwt).
Only rendered when ``use_auth == "yes"``.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from fastapi import HTTPException, status
from passlib.context import CryptContext
from {{ cookiecutter.project_name }}.config.settings import settings
from {{ cookiecutter.project_name }}.db.models.user import User
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
class AuthService:
"""Stateless auth service — JWT + password hashing."""
@staticmethod
def hash_password(password: str) -> str:
return _pwd.hash(password)
@staticmethod
def verify_password(plain: str, hashed: str) -> bool:
return _pwd.verify(plain, hashed)
@staticmethod
def create_access_token(username: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.jwt.expire_minutes,
)
payload: dict[str, Any] = {"sub": username, "exp": expire}
return jwt.encode(payload, settings.jwt.secret, algorithm=settings.jwt.algorithm)
@staticmethod
async def register(username: str, email: str, password: str) -> User:
hashed = AuthService.hash_password(password)
return await User.create(
username=username, email=email, hashed_password=hashed,
)
@staticmethod
async def login(username: str, password: str) -> str:
user = await User.get_or_none(username=username)
if user is None or not AuthService.verify_password(password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
)
return AuthService.create_access_token(username)
@staticmethod
async def decode_access_token(token: str) -> str:
try:
payload = jwt.decode(
token, settings.jwt.secret, algorithms=[settings.jwt.algorithm],
)
except jwt.PyJWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
) from exc
return str(payload.get("sub", ""))

View file

@ -1,37 +0,0 @@
"""User service — business logic (Tortoise queries).
Fix from slaid098/templates: ``get_users`` returns a plain ``list`` of
User instances (the old implementation returned a tuple while every caller
expected a list).
"""
from __future__ import annotations
from fastapi import HTTPException, status
from {{ cookiecutter.project_name }}.db.models.user import User
class UserService:
"""Stateless user service — all methods are async classmethods."""
@staticmethod
async def get_users() -> list[User]:
"""Return all users as a list (NOT a tuple — bug fixed)."""
return list(await User.all())
@staticmethod
async def get_user(user_id: int) -> User:
"""Return a single user by id or raise 404."""
user = await User.get_or_none(id=user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {user_id} not found",
)
return user
@staticmethod
async def create_user(username: str, email: str) -> User:
"""Create a new user."""
return await User.create(username=username, email=email)

View file

@ -1,7 +0,0 @@
"""Utils package.
NOTE: ProjectMetadata lives ONLY in ``metadata.py`` no duplication in
``__init__.py`` (fix from slaid098/templates).
"""
from {{ cookiecutter.project_name }}.utils.metadata import ProjectMetadata # noqa: F401

View file

@ -1,35 +0,0 @@
"""Project metadata read from ``pyproject.toml`` (single source of truth).
No duplication callers import from here, never re-declare the metadata.
Uses ``Path(__file__)`` to locate ``pyproject.toml`` regardless of CWD.
"""
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from pathlib import Path
_PYPROJECT = Path(__file__).resolve().parents[3] / "pyproject.toml"
@dataclass(frozen=True)
class ProjectMetadata:
name: str
version: str
description: str
def load_metadata() -> ProjectMetadata:
"""Load metadata from ``pyproject.toml`` (single source of truth)."""
with _PYPROJECT.open("rb") as f:
data = tomllib.load(f)
project = data.get("project", {})
return ProjectMetadata(
name=str(project.get("name", "")),
version=str(project.get("version", "")),
description=str(project.get("description", "")),
)
metadata = load_metadata()

View file

@ -1 +0,0 @@
"""API tests package."""

View file

@ -1,20 +0,0 @@
"""API tests for user routes (thin handlers → services)."""
from __future__ import annotations
import pytest
@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)")
async def test_list_users(client) -> None:
"""GET /api/v1/users returns a list of users."""
response = await client.get("/api/v1/users")
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)")
async def test_ip_whitelist_blocks(client) -> None:
"""Non-whitelisted IPs must get 403."""
response = await client.get("/api/v1/users", headers={"X-Forwarded-For": "10.0.0.1"})
assert response.status_code == 403

View file

@ -1,57 +0,0 @@
"""Pytest configuration — fixtures shared across all tests."""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from {{ cookiecutter.project_name }}.config.settings import settings
@pytest.fixture(autouse=True)
def mock_settings(monkeypatch) -> None:
"""Override settings with safe test defaults (no real DB/SMTP/etc)."""
monkeypatch.setattr(settings, "environment", "dev")
monkeypatch.setattr(settings, "ip_whitelist", ["127.0.0.1", "::1"])
@pytest.fixture
async def client() -> AsyncIterator[AsyncClient]:
"""HTTPX AsyncClient bound to the FastAPI app (no network)."""
from main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
@pytest.fixture
async def create_user() -> Any:
"""Factory: create a user in the test DB."""
from {{ cookiecutter.project_name }}.db.models.user import User
from {{ cookiecutter.project_name }}.services.auth_service import AuthService
async def _create(username: str = "tester", password: str = "password123") -> User:
hashed = AuthService.hash_password(password)
return await User.create(username=username, email=f"{username}@test.local", hashed_password=hashed)
return _create
@pytest.fixture
async def auth_client(create_user) -> AsyncIterator[AsyncClient]:
"""HTTPX client with a valid bearer token pre-set."""
from main import app
await create_user()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.post("/api/v1/auth/login", json={"username": "tester", "password": "password123"})
token = resp.json()["access_token"]
ac.headers["Authorization"] = f"Bearer {token}"
yield ac
{% endif %}

View file

@ -1 +0,0 @@
"""Integration tests package."""

View file

@ -1,24 +0,0 @@
"""Integration tests — skip by default unless real credentials are set.
``pytestmark`` = [pytest.mark.integration, skipif no creds] these only
run when an explicit env var is set (e.g. ``RUN_INTEGRATION=1``).
"""
from __future__ import annotations
import os
import pytest
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not os.getenv("RUN_INTEGRATION"),
reason="no real external credentials — set RUN_INTEGRATION=1 to enable",
),
]
async def test_real_external_placeholder() -> None:
"""Placeholder — replace with a real external service call."""
assert True

View file

@ -1,26 +0,0 @@
"""Auth flow tests — /register, /login (only when use_auth=yes)."""
from __future__ import annotations
import pytest
@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)")
async def test_register(client) -> None:
"""POST /api/v1/auth/register creates a user."""
response = await client.post(
"/api/v1/auth/register",
json={"username": "newuser", "email": "new@test.local", "password": "password123"},
)
assert response.status_code == 201
@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)")
async def test_login_returns_token(client) -> None:
"""POST /api/v1/auth/login returns a JWT."""
response = await client.post(
"/api/v1/auth/login",
json={"username": "tester", "password": "password123"},
)
assert response.status_code == 200
assert "access_token" in response.json()

View file

@ -1 +0,0 @@
"""Unit tests package."""

View file

@ -1,31 +0,0 @@
"""Unit tests for the User model."""
from __future__ import annotations
import pytest
{% if cookiecutter.use_auth == "yes" %}
from {{ cookiecutter.project_name }}.services.auth_service import AuthService
def test_hash_password_is_not_plain() -> None:
"""hash_password must not store the plain text."""
hashed = AuthService.hash_password("mypassword")
assert hashed != "mypassword"
assert hashed.startswith("$2") # bcrypt prefix
def test_verify_password_roundtrip() -> None:
"""verify_password must accept the correct password."""
hashed = AuthService.hash_password("mypassword")
assert AuthService.verify_password("mypassword", hashed) is True
assert AuthService.verify_password("wrong", hashed) is False
{% else %}
def test_user_model_table_name() -> None:
"""User model must use the 'users' table."""
from {{ cookiecutter.project_name }}.db.models.user import User
assert User._meta.db_table == "users"
{% endif %}

View file

@ -1,14 +0,0 @@
"""Unit tests for UserService (business logic, not HTTP)."""
from __future__ import annotations
import pytest
from {{ cookiecutter.project_name }}.services.user_service import UserService
@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)")
async def test_get_users_returns_list() -> None:
"""get_users must return a list (NOT a tuple — bug fixed)."""
users = await UserService.get_users()
assert isinstance(users, list)

View file

@ -1,8 +0,0 @@
{
"project_name": "my_cli",
"project_type": "cli",
"description": "CLI tool description",
"use_auth": ["no", "yes"],
"use_db": ["yes", "no"],
"python_version": "3.13"
}

View file

@ -1,31 +0,0 @@
"""Post-generation hook for the cli cookiecutter template.
The cli template is unconditional (no use_auth/use_db flags affect it),
but the hook is kept for parity with backend/fullstack so the same
contract applies.
"""
from __future__ import annotations
import shutil
from pathlib import Path
PROJECT_DIR = Path.cwd()
def _remove(path: str) -> None:
"""Remove a file or directory relative to the generated project root."""
p = PROJECT_DIR / path
if p.is_dir():
shutil.rmtree(p, ignore_errors=True)
elif p.exists():
p.unlink()
def main() -> None:
"""No conditional files for the cli template yet — placeholder."""
return None
if __name__ == "__main__":
main()

View file

@ -1,34 +0,0 @@
"""Pre-generation hook for the cli cookiecutter template.
Validates that ``project_name`` is a valid Python identifier so the
generated package dir + imports (``from <project_name>.X import Y``) do
not raise ``SyntaxError``. Hyphens, dots, spaces and leading digits are
rejected with a hint to use an underscore-separated name instead.
See issue #262: default ``my-cli`` used to render as
``from my-cli.core import app`` which is a ``SyntaxError`` (``-`` is not
allowed in a Python identifier).
"""
from __future__ import annotations
import re
import sys
_VALID_IDENTIFIER = re.compile(r"^[a-z][a-z0-9_]*$")
def main() -> None:
project_name = "{{ cookiecutter.project_name }}"
if not _VALID_IDENTIFIER.fullmatch(project_name):
sys.exit(
f"Invalid project_name: {project_name!r}\n"
"project_name must be a valid Python identifier matching "
"^[a-z][a-z0-9_]*$ (lowercase, no hyphens/dots/spaces, "
"no leading digit).\n"
f"Use 'my_cli' instead of 'my-cli' (or 'my.cli', 'my cli')."
)
if __name__ == "__main__":
main()

View file

@ -1,10 +0,0 @@
version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly

View file

@ -1,42 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev
- run: uv run ruff check .
- run: uv run ruff format --check .
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev
- run: uv run mypy src tests
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev
- run: uv run pytest
build:
runs-on: ubuntu-latest
needs: [lint, typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev
- run: uv build

View file

@ -1,16 +0,0 @@
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.eggs/
build/
dist/
.coverage
htmlcov/
.tox/
.mypy_cache/
.ruff_cache/
.pytest_cache/
.env
.venv/
venv/

Some files were not shown because too many files have changed in this diff Show more