Compare commits

..

1 commit

Author SHA1 Message Date
opencode-agent
e7360ff556 fix(docker): set NODE_PATH for global @vscode/ripgrep resolution 2026-07-27 00:31:53 +00:00
446 changed files with 3410 additions and 25903 deletions

View file

@ -1,3 +0,0 @@
app_data/
.git
**/node_modules

View file

@ -12,16 +12,11 @@ MEMORY_CHUNK_SIZE=512
MEMORY_CHUNK_OVERLAP=64 MEMORY_CHUNK_OVERLAP=64
# OpenCode Server # OpenCode Server
OPENCODE_SERVER_USERNAME=your-opencode-server-username
OPENCODE_SERVER_PASSWORD=your-opencode-server-password OPENCODE_SERVER_PASSWORD=your-opencode-server-password
# GitHub # GitHub
GITHUB_TOKEN=your-github-token-here 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 MCP
CONTEXT7_API_KEY=your-context7-api-key-here CONTEXT7_API_KEY=your-context7-api-key-here
@ -29,17 +24,11 @@ CONTEXT7_API_KEY=your-context7-api-key-here
TELEGRAM_API_ID=your-telegram-api-id TELEGRAM_API_ID=your-telegram-api-id
TELEGRAM_API_HASH=your-telegram-api-hash TELEGRAM_API_HASH=your-telegram-api-hash
TELEGRAM_BOT_TOKEN=your-telegram-bot-token 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 (optional — see separate tunnel repo)
CLOUDFLARE_TUNNEL_TOKEN= CLOUDFLARE_TUNNEL_TOKEN=
TUNNEL_DOMAIN= TUNNEL_DOMAIN=
# Vercel (optional — placeholder example, not currently wired to code)
VERCEL_TOKEN=
# Antidetect Browser MCP # Antidetect Browser MCP
# Local: http://localhost:8765/mcp # Local: http://localhost:8765/mcp
# Remote: http://<your-server-ip>:8765/mcp # Remote: http://<your-server-ip>:8765/mcp

View file

@ -6,12 +6,7 @@ updates:
interval: weekly interval: weekly
open-pull-requests-limit: 5 open-pull-requests-limit: 5
- package-ecosystem: "npm" - package-ecosystem: "npm"
directory: "/.opencode" directory: "/config"
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: "npm"
directory: "/.opencode/draw-image"
schedule: schedule:
interval: weekly interval: weekly
open-pull-requests-limit: 5 open-pull-requests-limit: 5

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 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - 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 sync --extra dev
- run: uv run ruff check src/ tests/ .opencode/scripts/ - run: uv run ruff check src/ tests/ .opencode/scripts/
- run: uv run ruff format --check src/ tests/ .opencode/scripts/ - run: uv run ruff format --check src/ tests/ .opencode/scripts/
@ -46,6 +50,10 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - 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 sync --extra dev
- run: uv run mypy src/ - run: uv run mypy src/
@ -57,15 +65,16 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python: ["3.13"] python: ["3.12", "3.13", "3.14"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: npm ci - uses: actions/setup-python@v5
working-directory: .opencode with:
- run: npm ci python-version: ${{ matrix.python }}
working-directory: .opencode/draw-image - uses: actions/setup-node@v4
- run: npm test with:
working-directory: .opencode/draw-image node-version: '22'
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev --python ${{ matrix.python }} - run: uv sync --extra dev --python ${{ matrix.python }}
- run: uv run --python ${{ matrix.python }} pytest - run: uv run --python ${{ matrix.python }} pytest
@ -76,5 +85,9 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - 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 sync --extra dev
- run: uv run xenon --max-absolute B --max-modules A --max-average A src/ - run: uv run xenon --max-absolute B --max-modules A --max-average A src/

View file

@ -0,0 +1,246 @@
---
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
"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/master...HEAD --stat` (or origin/main...HEAD) 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 config/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, опционально)
Если `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).

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 mode: subagent
temperature: 0.1 temperature: 0.1
steps: 150 steps: 150
@ -30,7 +30,6 @@ permission:
"echo *": allow "echo *": allow
"gh pr view*": allow "gh pr view*": allow
"gh issue view*": allow "gh issue view*": allow
"gh issue list*": allow
--- ---
You are a memory-syncer agent. Your job: distill durable knowledge from a merged PR handoff into the global memory file at `<memory_dir>/repos/{host}/{org}/{repo}.md` (default `~/.local/share/opencode/opencode-memory`, override via `OPENCODE_MEMORY_DIR`). You are a memory-syncer agent. Your job: distill durable knowledge from a merged PR handoff into the global memory file at `<memory_dir>/repos/{host}/{org}/{repo}.md` (default `~/.local/share/opencode/opencode-memory`, override via `OPENCODE_MEMORY_DIR`).
@ -39,28 +38,23 @@ You are **read-only on the repository** and **write-only on memory**. You CANNOT
## Setup ## Setup
1. Load the memory skill via `skill("memory")` to get distillation rules and format conventions. 1. Get the PR number from the invocation prompt.
2. Get the PR number from the invocation prompt. 2. 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).
3. Прочитай PR body через `gh pr view <N> --json body,title` (PR body содержит 4 секции: Что сделано, Почему, Watch out, Pending). При необходимости — `gh issue view <issue-N>` для контекста. 3. Determine the repo: `git remote get-url origin` → parse `{host}/{org}/{repo}` (e.g. `github.com/slaid098/opencode-config`).
4. Determine the repo: `git remote get-url origin` → parse `{host}/{org}/{repo}` (e.g. `github.com/slaid098/opencode-config`). 4. 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.
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. 5. 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.
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.
## Distillation ## Distillation
Distill durable-only records from the handoff. **Критерий durable: «поможет ли это в следующий раз когда я полезу в этот код?» Да → durable. Нет → НЕ пиши.** Distill durable-only records from the handoff:
Durable (записывай):
- gotchas / workarounds (non-obvious behavior) - gotchas / workarounds (non-obvious behavior)
- patterns, repository conventions - patterns, repository conventions
- pointers: «for X use Y, careful with Z» - pointers: «for X use Y, careful with Z»
- root causes of bugs - 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 (НЕ записывай): DO NOT distill: statuses, «currently working on», current tasks, ephemeral context.
- статусы, «сейчас работаем над», текущие таски, ephemeral контекст
- **changelog-дампы**: «PR#N: добавили X», «PR#N: починили Y» — это changelog, код уже документирует что было сделано. Только неочевидные знания: gotchas, паттерны, root causes, ADR-указатели.
- хроника событий, «x тестов passed», coverage %, количества файлов/коммитов — это метрики PR, не знания.
### Format ### Format
@ -80,31 +74,9 @@ Even if there are no durable records, the receipt is mandatory:
This confirms the memory-sync phase was executed (audit trail). This confirms the memory-sync phase was executed (audit trail).
### Выбор файла для записи (ОБЯЗАТЕЛЬНО перед записью) ### Edit instead of duplicate
Память репо — один или несколько файлов по пути `<memory_dir>/repos/{host}/{org}/{repo}*.md`: If a fact is already recorded — update the entry (bump `updated` in frontmatter). Do not create duplicates.
- первый файл: `{repo}.md`
- последующие (когда первый заморожен по размеру): `{repo}-002.md`, `{repo}-003.md`, ... (3-значный sequential, не по дате)
Порог ротации: **50 KB** (soft). Файл с размером ≥ 50 KB считается замороженным — новые записи в него НЕ пишутся.
Перед записью:
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` с нуля (текущее поведение).
### Дедуп across files (ОБЯЗАТЕЛЬНО перед записью)
Перед добавлением новой записи — ищи дубликат по **всем** `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 ## Save
@ -115,16 +87,11 @@ 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. 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). 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'а. 3. ONLY edit files under `<memory_dir>/repos/{host}/{org}/{repo}.md`.
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. 4. ONLY read files under `docs/handoff/` and `docs/decisions/`.
5. Receipt is mandatory even if no durable records found. 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). 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). 7. Для debug-вывода используй `pwd`/`ls`/`cat`/`printenv`НЕ `echo`е в allow-list).
8. Для статуса PR используй нативный tool `pipeline-status` (НЕ bash `python3 .../pipeline-status.py` — детерминированный deny-rule, см. ADR-019). 8. Для статуса PR используй нативный tool `pipeline-status` (НЕ bash `python3 .../pipeline-status.py` — детерминированный deny-rule, см. ADR-019).
9. НЕ используй `git -C <path>` — работай в текущем cwd (memory-syncer читает уже смерженный default branch). 9. НЕ используй `git -C <path>` — работай в текущем cwd (memory-syncer читает уже смерженный default branch).
10. НЕ делай `git checkout`/`git pull` — работаешь на уже смерженном default branch, переключаться не нужно. 10. НЕ делай `git checkout`/`git pull` — работаешь на уже смерженном default branch, переключаться не нужно.
11. **Фронт-матч фикс**: если frontmatter целевого файла сломан (битые отступы в `updated:`/`related:`, невалидные `importance: 3`/`5`/`NA` вместо `high`/`medium`/`low`, лишние `---` разделители) → починить при записи. Valid `importance` values: `high` | `medium` | `low`. Frontmatter keys без отступов (`^(\w+):` требует `^` в начале строки).
## 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

@ -22,14 +22,8 @@ permission:
"cat *": allow "cat *": allow
"head*": allow "head*": allow
"tail*": allow "tail*": allow
"sed -n *": allow
"wc*": allow "wc*": allow
"diff*": allow "diff*": allow
"date": allow
"date *": allow
"sort": allow
"sort *": allow
"sort * -o *": deny
"gh pr diff*": allow "gh pr diff*": allow
"gh pr checkout*": allow "gh pr checkout*": allow
"gh issue*": allow "gh issue*": allow
@ -99,9 +93,8 @@ You are a global code reviewer. Your job: review PRs against project skills and
2. Run `git diff main...HEAD --stat` to see what files changed. 2. Run `git diff main...HEAD --stat` to see what files changed.
3. Run `git diff main...HEAD` to see the actual changes. 3. Run `git diff main...HEAD` to see the actual changes.
4. Check if the repo has project-specific skills: 4. Check if the repo has project-specific skills:
- Run `find .opencode/skills/ -name "SKILL.md" -o -name "skill.md" 2>/dev/null` - Run `find skills/ -name "SKILL.md" -o -name "skill.md" 2>/dev/null`
- If skills exist, load each via `skill("<name>")` to get project-specific rules. - If skills exist, load each via `skill("<name>")` to get project-specific rules.
- Load `skill("code-standards")` for universal code review standards.
5. Check CI status: use `pipeline-status({pr_number: <PR_NUMBER>})` tool. 5. Check CI status: use `pipeline-status({pr_number: <PR_NUMBER>})` tool.
If unavailable, use `gh run list --branch <headRefName> --limit 3`. If unavailable, use `gh run list --branch <headRefName> --limit 3`.
Do NOT use `gh pr checks` (403) or bash `python3 .../pipeline-status.py` (denied). Do NOT use `gh pr checks` (403) or bash `python3 .../pipeline-status.py` (denied).
@ -109,18 +102,10 @@ You are a global code reviewer. Your job: review PRs against project skills and
## Investigation Budget ## 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. 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 An incomplete review with verdict NEEDS_DISCUSSION is better than an infinite investigation.
investigation. Do NOT repeatedly verify references in agent .md files — read once, assess, move on.
Cross-file impact analysis: до 10 доп. шагов на проверку readers/writers (не
считается против основного budget 15). Это необходимо для детерминированных
связей writer↔reader.
Не повторять проверку ссылок в agent .md files БЕЗ причины — но ЕСЛИ PR
изменяет writer (агент/промпт/формат/литерал/frontmatter key/env var) —
проверка readers обязательна (см. Section 10).
## Review Checklist ## Review Checklist
@ -211,49 +196,22 @@ Examples of project-specific rules:
- No `.env` or secret files in the diff. - No `.env` or secret files in the diff.
- Branch name is descriptive. - Branch name is descriptive.
### 9. PR body quality ### 9. Documentation (if docs/project-map/ exists)
- PR body содержит `## Что сделано`, `## Почему`, `## Watch out` заполнены - Project map files accurately reflect current project structure
осмысленно (не пустые плейсхолдеры, `—` допустим для Watch out/Pending если - New modules have corresponding map files in `docs/project-map/`
нет контента). Если PR body неполный → REQUEST_CHANGES. - 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: - `docs/handoff/pr-<N>-<slug>.md` exists in the diff (N = PR number)
- `rg` по репо — кто читает/пишет тот же ресурс (файл-путь pattern, формат, - Handoff has all sections: Что сделано, Почему, Pending, Watch out
литерал, env var, frontmatter key, comment format). - Handoff content is meaningful — not empty placeholders
- Категории связей: - If PR introduces architectural changes → `docs/decisions/<NN>-<title>.md` exists
- oracle-скрипты (`pipeline-status.py`, `spec-status.py`, - ADR has: Статус, Контекст, Решение, Альтернативы
`project-status.py`) — читают форматы/файлы. - If handoff/ADR missing or empty → REQUEST_CHANGES
- валидаторы (`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).
## Output Format ## Output Format
@ -304,48 +262,6 @@ Body format (without heading — tool adds `## Code Review Summary` and `### Ver
Fix: suggestion 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. 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. After this call, you MUST respond with your review text only. Do NOT call any more tools.
@ -396,37 +312,3 @@ If `post-review` returns a string starting with `⚠️ ...failed` (e.g. `⚠️
8. After `post-review` with REQUEST_CHANGES, STOP. Do not merge. 8. After `post-review` with REQUEST_CHANGES, STOP. Do not merge.
9. Для получения login автора PR используй `gh pr view --json author` (НЕ `gh api user` — broad API call, не в allow-list, вызывает doom-loop). 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). 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: Feature spec — SDD-style Q&A before implementation
agent: build
---
Load the `feature-spec` skill via `skill({name: "feature-spec"})` and follow its ПРОТОКОЛ strictly. Главный агент — оркестратор: Q&A с юзером по SDD-шаблону, план в чате, затем issue через `issue` скилл. Не создаёт issues сам — только планирует. Не запускает /run-pipeline — юзер сам.

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 +0,0 @@
---
description: Standardize repo README + cover image (create)
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.

View file

@ -1,5 +1,5 @@
--- ---
description: Run pipeline — autonomous 6-phase PR pipeline description: Run pipeline — autonomous 7-phase PR pipeline
agent: build 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. 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,5 +0,0 @@
node_modules/
.tmp-render.svg
icons/lucide/*
!icons/lucide/mic.svg
!icons/lucide/play.svg

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" fill="#ccff00"/>
</svg>

Before

Width:  |  Height:  |  Size: 225 B

View file

@ -1,8 +0,0 @@
{
"base": "#0a0a0a",
"surface": "#121212",
"fg": "#ededed",
"muted": "#a1a1aa",
"accent": "#ccff00",
"line": "rgba(255,255,255,0.06)"
}

View file

@ -1,91 +0,0 @@
import { writeFileSync, mkdirSync, existsSync, readFileSync, rmSync } 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"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
function parseArgs(argv: string[]): Record<string, string> {
const out: Record<string, string> = {}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a.startsWith("--")) {
const key = a.slice(2)
const val = argv[i + 1] ?? ""
out[key] = val
i++
}
}
return out
}
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.exit(2)
}
const template = argv[1]
const opts = parseArgs(argv.slice(2))
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) {
for (const pair of opts.slots.split(",")) {
const eq = pair.indexOf("=")
if (eq !== -1) slots[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim()
}
}
const brand = loadBrand(__dirname)
const templateSvg = loadTemplate(__dirname, template, templateDir)
const hash = computeHash(brand, templateSvg, { template, title, subtitle, slots, out })
const outResolved = path.resolve(out)
const metaPath = outResolved.replace(/\.png$/, ".meta.json")
if (existsSync(outResolved) && existsSync(metaPath)) {
try {
const meta = JSON.parse(readFileSync(metaPath, "utf-8"))
if (meta.hash === hash) {
console.log(JSON.stringify({ path: outResolved, hash, status: "skipped" }))
return
}
} catch {
// stale meta, re-render
}
}
const svg = buildSvg(templateSvg, brand, { template, title, subtitle, slots, out }, __dirname)
const tmpSvg = path.join(os.tmpdir(), `draw-image-${process.pid}.svg`)
try {
writeFileSync(tmpSvg, svg)
const r = spawnSync("node", [path.join(__dirname, "render.mjs"), tmpSvg, outResolved], {
encoding: "utf-8",
})
if (r.status !== 0) {
process.stderr.write(r.stderr || r.stdout || "render failed\n")
process.exit(1)
}
const meta = {
hash,
generatedAt: new Date().toISOString(),
size: 1024,
}
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

@ -1,17 +0,0 @@
<!-- @license lucide-static v1.27.0 - ISC -->
<svg
class="lucide lucide-mic"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 19v3" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<rect x="9" y="2" width="6" height="13" rx="3" />
</svg>

Before

Width:  |  Height:  |  Size: 400 B

View file

@ -1,15 +0,0 @@
<!-- @license lucide-static v1.27.0 - ISC -->
<svg
class="lucide lucide-play"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" />
</svg>

Before

Width:  |  Height:  |  Size: 381 B

File diff suppressed because it is too large Load diff

View file

@ -1,20 +0,0 @@
{
"name": "draw-image",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "SVG template renderer for on-brand covers (sharp + lucide-static)",
"scripts": {
"test": "vitest run",
"postinstall": "node scripts/sync-lucide.mjs"
},
"dependencies": {
"lucide-static": "^1.27.0",
"sharp": "^0.35.3"
},
"devDependencies": {
"@types/node": "^26.1.1",
"typescript": "^7.0.2",
"vitest": "^3.2.4"
}
}

View file

@ -1,44 +0,0 @@
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import sharp from "sharp"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
async function main() {
const args = process.argv.slice(2)
if (args.length < 2) {
process.stderr.write("usage: node render.mjs <svg-file> <out-path>\n")
process.exit(2)
}
const svgFile = args[0]
const outPath = args[1]
const svg = readFileSync(svgFile, "utf-8")
const outDir = path.dirname(outPath)
if (outDir && !existsSync(outDir)) {
mkdirSync(outDir, { recursive: true })
}
const fontDir = path.join(__dirname, "fonts")
const fontFiles = []
const sansTtf = path.join(fontDir, "Geist-Regular.ttf")
const sansBoldTtf = path.join(fontDir, "Geist-Bold.ttf")
if (existsSync(sansTtf)) fontFiles.push(sansTtf)
if (existsSync(sansBoldTtf)) fontFiles.push(sansBoldTtf)
const pngOpts = { compressionLevel: 9 }
if (fontFiles.length > 0) {
pngOpts.fontFiles = fontFiles
}
await sharp(Buffer.from(svg), { density: 72 })
.png(pngOpts)
.toFile(outPath)
}
main().catch((err) => {
process.stderr.write(`render failed: ${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
})

View file

@ -1,23 +0,0 @@
import { existsSync, mkdirSync, readdirSync, copyFileSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const drawImageDir = path.resolve(__dirname, "..")
const lucideSrc = path.join(drawImageDir, "node_modules", "lucide-static", "icons")
const lucideDst = path.join(drawImageDir, "icons", "lucide")
if (!existsSync(lucideSrc)) {
console.log("lucide-static not installed, skipping icon sync")
process.exit(0)
}
mkdirSync(lucideDst, { recursive: true })
let count = 0
for (const file of readdirSync(lucideSrc)) {
if (file.endsWith(".svg")) {
copyFileSync(path.join(lucideSrc, file), path.join(lucideDst, file))
count++
}
}
console.log(`synced ${count} lucide icons`)

View file

@ -1,56 +0,0 @@
import { readFileSync } from "node:fs"
import path from "node:path"
export type Brand = {
base: string
surface: string
fg: string
muted: string
accent: string
line: string
}
const HEX_RE = /^#[0-9a-fA-F]{6}$/
type BrandKey = keyof Brand
const REQUIRED_KEYS: BrandKey[] = ["base", "surface", "fg", "muted", "accent"]
export function validateBrand(raw: unknown): Brand {
if (typeof raw !== "object" || raw === null) {
throw new Error("brand.json must be a JSON object")
}
const obj = raw as Record<string, unknown>
for (const key of REQUIRED_KEYS) {
if (!(key in obj)) {
throw new Error(`brand.json missing required key: ${key}`)
}
const val = obj[key]
if (typeof val !== "string" || !HEX_RE.test(val)) {
throw new Error(`brand.json key '${key}' must be a 6-digit hex color (got: ${String(val)})`)
}
}
if ("line" in obj && typeof obj.line !== "string") {
throw new Error("brand.json key 'line' must be a string")
}
return {
base: obj.base as string,
surface: obj.surface as string,
fg: obj.fg as string,
muted: obj.muted as string,
accent: obj.accent as string,
line: (obj.line as string | undefined) ?? "rgba(255,255,255,0.06)",
}
}
export function loadBrand(dir: string): Brand {
const brandPath = path.join(dir, "brand.json")
const raw = JSON.parse(readFileSync(brandPath, "utf-8"))
return validateBrand(raw)
}
export function resolveColor(brand: Brand, name: string): string {
if (name === "none") return "none"
if (name in brand) return (brand as Record<string, string>)[name]
throw new Error(`unknown color alias: ${name}`)
}

View file

@ -1,71 +0,0 @@
import type { FitMode } from "./slot-parser.ts"
export type FitResult = {
x: number
y: number
width: number
height: number
viewBox: string
}
export function computeFit(
fit: FitMode,
slotX: number,
slotY: number,
slotW: number,
slotH: number,
contentW: number,
contentH: number,
): FitResult {
if (fit === "stretch") {
return {
x: slotX,
y: slotY,
width: slotW,
height: slotH,
viewBox: `0 0 ${contentW} ${contentH}`,
}
}
if (fit === "cover") {
const scale = Math.max(slotW / contentW, slotH / contentH)
const renderedW = contentW * scale
const renderedH = contentH * scale
const offsetX = slotX + (slotW - renderedW) / 2
const offsetY = slotY + (slotH - renderedH) / 2
return {
x: offsetX,
y: offsetY,
width: renderedW,
height: renderedH,
viewBox: `0 0 ${contentW} ${contentH}`,
}
}
const scale = Math.min(slotW / contentW, slotH / contentH)
const renderedW = contentW * scale
const renderedH = contentH * scale
const offsetX = slotX + (slotW - renderedW) / 2
const offsetY = slotY + (slotH - renderedH) / 2
return {
x: offsetX,
y: offsetY,
width: renderedW,
height: renderedH,
viewBox: `0 0 ${contentW} ${contentH}`,
}
}
export function parseViewBox(svg: string): { width: number; height: number } {
const m = svg.match(/viewBox=["']([^"']+)["']/)
if (m) {
const parts = m[1].split(/[\s,]+/).map(Number)
if (parts.length === 4 && parts.every((n) => !Number.isNaN(n))) {
return { width: parts[2], height: parts[3] }
}
}
const wMatch = svg.match(/\swidth=["'](\d+)["']/)
const hMatch = svg.match(/\sheight=["'](\d+)["']/)
if (wMatch && hMatch) {
return { width: parseInt(wMatch[1], 10), height: parseInt(hMatch[1], 10) }
}
return { width: 24, height: 24 }
}

View file

@ -1,26 +0,0 @@
export function recolorSvg(svg: string, color: string): string {
if (color === "none") return svg
let out = svg
out = out.replace(/\bstroke=["'](?!none)([^"']+)["']/g, `stroke="${color}"`)
out = out.replace(/\bfill=["'](?!none)([^"']+)["']/g, (match, _val) => {
if (match.includes(`fill="none"`)) return match
return `fill="${color}"`
})
return out
}
export function stripSvgWrapper(svg: string): string {
const m = svg.match(/<svg[^>]*>([\s\S]*)<\/svg>/)
return m ? m[1].trim() : svg
}
export function extractRootAttrs(svg: string): { stroke?: string; fill?: string; strokeWidth?: string } {
const attrs: { stroke?: string; fill?: string; strokeWidth?: string } = {}
const strokeMatch = svg.match(/<svg[^>]*\bstroke=["']([^"']+)["']/)
if (strokeMatch) attrs.stroke = strokeMatch[1]
const fillMatch = svg.match(/<svg[^>]*\bfill=["']([^"']+)["']/)
if (fillMatch) attrs.fill = fillMatch[1]
const swMatch = svg.match(/<svg[^>]*\bstroke-width=["']([^"']+)["']/)
if (swMatch) attrs.strokeWidth = swMatch[1]
return attrs
}

View file

@ -1,87 +0,0 @@
import { readFileSync } from "node:fs"
import path from "node:path"
import { createHash } from "node:crypto"
import { loadBrand, type Brand } from "./config.ts"
import { parseSlots, type SlotSpec } from "./slot-parser.ts"
import { resolveSlotContent, renderSlotSvg, renderSlotBackground } from "./resolve.ts"
export type RenderArgs = {
template: string
title?: string
subtitle?: string
slots?: Record<string, string>
out?: string
}
export type RenderResult = {
path: string
hash: string
status: "rendered" | "skipped"
}
const SLOT_COMMENT_RE = /<!--\s*slot:[^>]*?-->\n?/g
export function buildSvg(
templateSvg: string,
brand: Brand,
args: RenderArgs,
drawImageDir: string,
): string {
const slots = parseSlots(templateSvg)
let svg = templateSvg
const slotSvgs: string[] = []
for (const slot of slots) {
const value = args.slots?.[slot.name]
if (value) {
const bg = renderSlotBackground(slot, brand)
if (bg) slotSvgs.push(bg)
const resolved = resolveSlotContent(slot, brand, drawImageDir, value)
if (resolved) slotSvgs.push(renderSlotSvg(slot, resolved))
}
}
svg = svg.replace(SLOT_COMMENT_RE, "")
svg = svg.replace(/\{\{base\}\}/g, brand.base)
svg = svg.replace(/\{\{surface\}\}/g, brand.surface)
svg = svg.replace(/\{\{fg\}\}/g, brand.fg)
svg = svg.replace(/\{\{muted\}\}/g, brand.muted)
svg = svg.replace(/\{\{accent\}\}/g, brand.accent)
svg = svg.replace(/\{\{line\}\}/g, brand.line)
svg = svg.replace(/\{\{title\}\}/g, escapeXml(args.title ?? ""))
if (args.subtitle) {
svg = svg.replace(/\{\{subtitle\}\}/g, escapeXml(args.subtitle))
} else {
svg = svg.replace(/[^\n]*\{\{subtitle\}\}[^\n]*\n?/g, "")
}
const insertPoint = svg.indexOf("</svg>")
if (insertPoint === -1) return svg
return svg.slice(0, insertPoint) + slotSvgs.join("\n") + "\n" + svg.slice(insertPoint)
}
export function computeHash(brand: Brand, templateSvg: string, args: RenderArgs): string {
const data = JSON.stringify({
brand,
template: templateSvg,
template_name: args.template,
title: args.title ?? "",
subtitle: args.subtitle ?? "",
slots: args.slots ?? {},
out: args.out ?? "",
})
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`)
return readFileSync(templatePath, "utf-8")
}
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;")
}

View file

@ -1,78 +0,0 @@
import { existsSync, readFileSync } from "node:fs"
import path from "node:path"
import { recolorSvg, stripSvgWrapper, extractRootAttrs } from "./recolor.ts"
import { computeFit, parseViewBox } from "./fit.ts"
import type { SlotSpec } from "./slot-parser.ts"
import type { Brand } from "./config.ts"
import { resolveColor } from "./config.ts"
export type ResolveResult = {
inner: string
contentW: number
contentH: number
rootAttrs: { stroke?: string; fill?: string; strokeWidth?: string }
}
export function resolveSlotContent(
slot: SlotSpec,
brand: Brand,
drawImageDir: string,
slotValue: string,
): ResolveResult | null {
const raw = loadSlotSvg(slotValue, drawImageDir)
if (!raw) return null
const color = resolveColor(brand, slot.recolor)
const recolored = recolorSvg(raw, color)
const rootAttrs = extractRootAttrs(recolored)
const inner = stripSvgWrapper(recolored)
const { width, height } = parseViewBox(raw)
return { inner, contentW: width, contentH: height, rootAttrs }
}
export function loadSlotSvg(value: string, drawImageDir: string): string | null {
if (value.startsWith("./") || value.startsWith("/") || value.startsWith("../")) {
if (!existsSync(value)) return null
return readFileSync(value, "utf-8")
}
const lucidePath = path.join(drawImageDir, "icons", "lucide", `${value}.svg`)
if (existsSync(lucidePath)) {
return readFileSync(lucidePath, "utf-8")
}
const brandPath = path.join(drawImageDir, "brand-logos", `${value}.svg`)
if (existsSync(brandPath)) {
return readFileSync(brandPath, "utf-8")
}
return null
}
export function renderSlotSvg(slot: SlotSpec, resolved: ResolveResult): string {
const fit = computeFit(
slot.fit,
slot.x,
slot.y,
slot.w,
slot.h,
resolved.contentW,
resolved.contentH,
)
const attrs = resolved.rootAttrs
const strokeAttr = attrs.stroke ? ` stroke="${attrs.stroke}"` : ""
const fillAttr = attrs.fill ? ` fill="${attrs.fill}"` : ""
const swAttr = attrs.strokeWidth ? ` stroke-width="${attrs.strokeWidth}"` : ""
return `<svg x="${fit.x}" y="${fit.y}" width="${fit.width}" height="${fit.height}" viewBox="${fit.viewBox}" xmlns="http://www.w3.org/2000/svg"${strokeAttr}${fillAttr}${swAttr}>${resolved.inner}</svg>`
}
export function renderSlotBackground(slot: SlotSpec, brand: Brand): string {
const parts: string[] = []
if (slot.bg !== "none") {
const bg = resolveColor(brand, slot.bg)
const r = slot.radius ? Math.min(slot.w, slot.h) * slot.radius / 2 : 0
parts.push(`<rect x="${slot.x}" y="${slot.y}" width="${slot.w}" height="${slot.h}" rx="${r}" fill="${bg}" />`)
}
if (slot.border !== "none") {
const border = resolveColor(brand, slot.border)
const r = slot.radius ? Math.min(slot.w, slot.h) * slot.radius / 2 : 0
parts.push(`<rect x="${slot.x}" y="${slot.y}" width="${slot.w}" height="${slot.h}" rx="${r}" fill="none" stroke="${border}" stroke-width="4" />`)
}
return parts.join("\n")
}

View file

@ -1,57 +0,0 @@
export type FitMode = "contain" | "cover" | "stretch"
export type SlotSpec = {
name: string
x: number
y: number
w: number
h: number
fit: FitMode
recolor: string
bg: string
border: string
radius: number
}
const SLOT_RE = /<!--\s*slot:\s*([^>]*?)-->/g
export function parseSlots(svg: string): SlotSpec[] {
const slots: SlotSpec[] = []
let m: RegExpExecArray | null
while ((m = SLOT_RE.exec(svg)) !== null) {
const body = m[1].trim()
const spec = parseSlotBody(body)
if (spec) slots.push(spec)
}
return slots
}
function parseSlotBody(body: string): SlotSpec | null {
const fields = new Map<string, string>()
for (const part of body.split(",")) {
const eq = part.indexOf("=")
if (eq === -1) continue
const key = part.slice(0, eq).trim()
const val = part.slice(eq + 1).trim()
fields.set(key, val)
}
const name = fields.get("name")
if (!name) return null
const x = num(fields, "x")
const y = num(fields, "y")
const w = num(fields, "w")
const h = num(fields, "h")
if ([x, y, w, h].some((v) => Number.isNaN(v))) return null
const fit = (fields.get("fit") as FitMode) ?? "contain"
const recolor = fields.get("recolor") ?? "none"
const bg = fields.get("bg") ?? "none"
const border = fields.get("border") ?? "none"
const radius = num(fields, "radius") || 0
return { name, x, y, w, h, fit, recolor, bg, border, radius }
}
function num(fields: Map<string, string>, key: string): number {
const v = fields.get(key)
if (v === undefined) return NaN
return parseFloat(v)
}

View file

@ -1,6 +0,0 @@
<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=sub-icon, x=412, y=600, w=200, h=200, fit=contain, recolor=accent -->
<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>
</svg>

Before

Width:  |  Height:  |  Size: 470 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,57 +0,0 @@
import { describe, test, expect } from "vitest"
function parseArgs(argv: string[]): Record<string, string> {
const out: Record<string, string> = {}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a.startsWith("--")) {
const key = a.slice(2)
const val = argv[i + 1] ?? ""
out[key] = val
i++
}
}
return out
}
function parseSlotsArg(slotsStr: string | undefined): Record<string, string> {
const slots: Record<string, string> = {}
if (slotsStr) {
for (const pair of slotsStr.split(",")) {
const eq = pair.indexOf("=")
if (eq !== -1) slots[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim()
}
}
return slots
}
describe("cli — arg parsing", () => {
test("parses title and out", () => {
const opts = parseArgs(["--title", "Hello", "--out", "/tmp/x.png"])
expect(opts.title).toBe("Hello")
expect(opts.out).toBe("/tmp/x.png")
})
test("default out is ./assets/cover.png", () => {
const opts = parseArgs(["--title", "Test"])
const out = opts.out ?? "./assets/cover.png"
expect(out).toBe("./assets/cover.png")
})
test("parses slots string into object", () => {
const slots = parseSlotsArg("icon=mic,sub-icon=opencode,badge=./x.svg")
expect(slots.icon).toBe("mic")
expect(slots["sub-icon"]).toBe("opencode")
expect(slots.badge).toBe("./x.svg")
})
test("empty slots string yields empty object", () => {
expect(parseSlotsArg(undefined)).toEqual({})
expect(parseSlotsArg("")).toEqual({})
})
test("subtitle is optional", () => {
const opts = parseArgs(["--title", "Test"])
expect(opts.subtitle).toBeUndefined()
})
})

View file

@ -1,42 +0,0 @@
import { describe, test, expect } from "vitest"
import { validateBrand } from "../src/config"
describe("config — validateBrand", () => {
test("valid brand passes", () => {
const brand = validateBrand({
base: "#0a0a0a",
surface: "#121212",
fg: "#ededed",
muted: "#a1a1aa",
accent: "#ccff00",
line: "rgba(255,255,255,0.06)",
})
expect(brand.accent).toBe("#ccff00")
expect(brand.base).toBe("#0a0a0a")
})
test("missing accent rejected", () => {
expect(() => validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa" }))
.toThrow(/accent/)
})
test("non-hex accent rejected", () => {
expect(() => validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa", accent: "green" }))
.toThrow(/hex/)
})
test("short hex rejected", () => {
expect(() => validateBrand({ base: "#0a0", surface: "#121212", fg: "#ededed", muted: "#a1a1aa", accent: "#ccff00" }))
.toThrow(/hex/)
})
test("non-object rejected", () => {
expect(() => validateBrand("not an object")).toThrow(/object/)
expect(() => validateBrand(null)).toThrow(/object/)
})
test("line defaults when missing", () => {
const brand = validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa", accent: "#ccff00" })
expect(brand.line).toBe("rgba(255,255,255,0.06)")
})
})

View file

@ -1,41 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } 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}`)
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" }))
.toThrow(/accent/)
})
test("non-hex accent rejected", () => {
expect(() => validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa", accent: "green" }))
.toThrow(/hex/)
})
test("cli exits non-zero with missing template file", () => {
const out = path.join(TMP, "bad.png")
const r = spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), "render", "nonexistent", "--title", "Bad", "--out", out], {
encoding: "utf-8",
cwd: DRAW_IMAGE_DIR,
})
expect(r.status).not.toBe(0)
})
})

View file

@ -1,38 +0,0 @@
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-no-icon-${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("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"])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
expect(existsSync(out)).toBe(true)
const buf = readFileSync(out)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50)
})
})

View file

@ -1,55 +0,0 @@
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"
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}`)
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,
})
}
function assertPng(filePath: string) {
expect(existsSync(filePath), `PNG not found: ${filePath}`).toBe(true)
const buf = readFileSync(filePath)
expect(buf.length).toBeGreaterThan(1000)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50)
expect(buf[2]).toBe(0x4e)
expect(buf[3]).toBe(0x47)
}
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"])
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)
expect(svg).not.toContain('x="780" y="780"')
expect(svg).not.toContain('y="930"')
expect(svg).not.toContain("{{subtitle}}")
})
})

View file

@ -1,69 +0,0 @@
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}`)
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,
})
}
function assertPng1024(filePath: string) {
expect(existsSync(filePath), `PNG not found: ${filePath}`).toBe(true)
const buf = readFileSync(filePath)
expect(buf.length).toBeGreaterThan(1000)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50)
expect(buf[2]).toBe(0x4e)
expect(buf[3]).toBe(0x47)
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
expect(width, `PNG width should be 1024, got ${width}`).toBe(1024)
expect(height, `PNG height should be 1024, got ${height}`).toBe(1024)
}
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"])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
const result = JSON.parse(r.stdout)
expect(result.status).toBe("rendered")
assertPng1024(out)
const metaPath = out.replace(/\.png$/, ".meta.json")
expect(existsSync(metaPath)).toBe(true)
const meta = JSON.parse(readFileSync(metaPath, "utf-8"))
expect(meta.hash).toHaveLength(64)
})
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"])
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"])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
assertPng1024(out)
})
})

View file

@ -1,64 +0,0 @@
import { describe, test, expect } from "vitest"
import { computeFit, parseViewBox } from "../src/fit"
describe("fit — contain", () => {
test("square content into wider slot centers horizontally", () => {
const r = computeFit("contain", 100, 100, 400, 200, 100, 100)
expect(r.width).toBe(200)
expect(r.height).toBe(200)
expect(r.x).toBe(200)
expect(r.y).toBe(100)
expect(r.viewBox).toBe("0 0 100 100")
})
test("square content into taller slot centers vertically", () => {
const r = computeFit("contain", 100, 100, 200, 400, 100, 100)
expect(r.width).toBe(200)
expect(r.height).toBe(200)
expect(r.x).toBe(100)
expect(r.y).toBe(200)
})
test("horizontal content into square slot fits by width", () => {
const r = computeFit("contain", 0, 0, 200, 200, 400, 100)
expect(r.width).toBe(200)
expect(r.height).toBe(50)
expect(r.x).toBe(0)
expect(r.y).toBe(75)
})
})
describe("fit — cover", () => {
test("square content into wide slot fills height", () => {
const r = computeFit("cover", 0, 0, 400, 200, 100, 100)
expect(r.width).toBe(400)
expect(r.height).toBe(400)
expect(r.x).toBe(0)
expect(r.y).toBe(-100)
})
})
describe("fit — stretch", () => {
test("stretches to slot dimensions ignoring aspect", () => {
const r = computeFit("stretch", 10, 20, 300, 150, 100, 100)
expect(r.x).toBe(10)
expect(r.y).toBe(20)
expect(r.width).toBe(300)
expect(r.height).toBe(150)
expect(r.viewBox).toBe("0 0 100 100")
})
})
describe("fit — parseViewBox", () => {
test("extracts from viewBox attribute", () => {
expect(parseViewBox('<svg viewBox="0 0 24 24">')).toEqual({ width: 24, height: 24 })
})
test("extracts from width/height attributes", () => {
expect(parseViewBox('<svg width="48" height="48">')).toEqual({ width: 48, height: 48 })
})
test("defaults to 24x24 when nothing found", () => {
expect(parseViewBox("<svg></svg>")).toEqual({ width: 24, height: 24 })
})
})

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,54 +0,0 @@
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-idempotency-${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 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"]
if (slots) args.push("--slots", slots)
return spawnSync("node", args, { encoding: "utf-8", cwd: DRAW_IMAGE_DIR })
}
describe("integration — idempotency", () => {
test("re-render with same args skips", () => {
const out = path.join(TMP, "cover.png")
const r1 = renderCli("cover", "Same Title", out)
expect(r1.status).toBe(0)
const result1 = JSON.parse(r1.stdout)
expect(result1.status).toBe("rendered")
const r2 = renderCli("cover", "Same Title", out)
expect(r2.status).toBe(0)
const result2 = JSON.parse(r2.stdout)
expect(result2.status).toBe("skipped")
})
test("changed title re-renders", () => {
const out = path.join(TMP, "cover2.png")
const r1 = renderCli("cover", "Title A", out)
const result1 = JSON.parse(r1.stdout)
expect(result1.status).toBe("rendered")
const r2 = renderCli("cover", "Title B", out)
const result2 = JSON.parse(r2.stdout)
expect(result2.status).toBe("rendered")
const meta = JSON.parse(readFileSync(out.replace(/\.png$/, ".meta.json"), "utf-8"))
expect(meta.hash).toHaveLength(64)
})
})

View file

@ -1,48 +0,0 @@
import { describe, test, expect } from "vitest"
import { recolorSvg, stripSvgWrapper } from "../src/recolor"
const SAMPLE = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19v3" /></svg>`
describe("recolor — recolorSvg", () => {
test("replaces stroke=currentColor with accent", () => {
const out = recolorSvg(SAMPLE, "#ccff00")
expect(out).toContain('stroke="#ccff00"')
expect(out).not.toContain('stroke="currentColor"')
})
test("replaces fill (non-none) with target color", () => {
const svg = `<svg fill="#000000" stroke="currentColor"><path fill="#ff0000" /></svg>`
const out = recolorSvg(svg, "#ccff00")
expect(out).toContain('fill="#ccff00"')
expect(out).not.toContain('fill="#000000"')
expect(out).not.toContain('fill="#ff0000"')
})
test("preserves fill=none", () => {
const svg = `<svg fill="none" stroke="currentColor"><path fill="none" /></svg>`
const out = recolorSvg(svg, "#ccff00")
expect(out).toContain('fill="none"')
})
test("none color returns unchanged", () => {
expect(recolorSvg(SAMPLE, "none")).toBe(SAMPLE)
})
test("replaces stroke with fg color", () => {
const out = recolorSvg(SAMPLE, "#ededed")
expect(out).toContain('stroke="#ededed"')
})
test("replaces stroke with muted color", () => {
const out = recolorSvg(SAMPLE, "#a1a1aa")
expect(out).toContain('stroke="#a1a1aa"')
})
})
describe("recolor — stripSvgWrapper", () => {
test("extracts inner content", () => {
const inner = stripSvgWrapper(SAMPLE)
expect(inner).toContain("<path")
expect(inner).not.toContain("<svg")
})
})

View file

@ -1,72 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync, mkdtempSync } 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"
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}`)
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 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 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)
const outPath = path.join(TMP, "cover.png")
renderToPng(svg, outPath)
expect(existsSync(outPath)).toBe(true)
const buf = readFileSync(outPath)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50)
expect(buf[2]).toBe(0x4e)
expect(buf[3]).toBe(0x47)
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
expect(width, `PNG width should be 1024, got ${width}`).toBe(1024)
expect(height, `PNG height should be 1024, got ${height}`).toBe(1024)
expect(hash).toHaveLength(64)
})
test("creates output directory recursively", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("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")
renderToPng(svg, outPath)
expect(existsSync(outPath)).toBe(true)
})
})

View file

@ -1,76 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync, mkdtempSync } 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-integration-${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 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 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) {
expect(existsSync(filePath), `PNG not found: ${filePath}`).toBe(true)
const buf = readFileSync(filePath)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50)
expect(buf[2]).toBe(0x4e)
expect(buf[3]).toBe(0x47)
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
expect(width, `PNG width should be 1024, got ${width}`).toBe(1024)
expect(height, `PNG height should be 1024, got ${height}`).toBe(1024)
}
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 args = { template: "cover", title: "Bare" }
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
const outPath = path.join(TMP, "bare.png")
renderToPng(svg, outPath)
assertPng(outPath)
})
test("render with subtitle and badge → valid PNG", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const args = {
template: "cover",
title: "Full",
subtitle: "Sub Text",
slots: { badge: "mic" },
}
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
const outPath = path.join(TMP, "full.png")
renderToPng(svg, outPath)
assertPng(outPath)
})
})

View file

@ -1,57 +0,0 @@
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"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const BADGE_RECT = 'x="780" y="780" width="180" height="180"'
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 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"')
})
test("filled badge slot renders bg/border rect", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const svg = buildSvg(templateSvg, brand, {
template: "cover",
title: "With Badge",
slots: { badge: "mic" },
}, DRAW_IMAGE_DIR)
expect(svg).toContain(BADGE_RECT)
expect(svg).toContain('fill="#121212"')
expect(svg).toContain('stroke="#ccff00"')
})
})
describe("unit — optional subtitle", () => {
test("empty subtitle removes the subtitle text line", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("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}}")
})
test("set subtitle keeps the subtitle text line", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const svg = buildSvg(templateSvg, brand, {
template: "cover",
title: "Main",
subtitle: "My Sub",
}, DRAW_IMAGE_DIR)
expect(svg).toContain(SUBTITLE_Y)
expect(svg).toContain("My Sub")
})
})

View file

@ -1,58 +0,0 @@
import { describe, test, expect } from "vitest"
import { parseSlots } from "../src/slot-parser"
describe("slot-parser — parseSlots", () => {
test("parses icon slot with all defaults", () => {
const svg = `<!-- slot: name=icon, x=312, y=200, w=400, h=400, fit=contain, recolor=accent -->`
const slots = parseSlots(svg)
expect(slots).toHaveLength(1)
expect(slots[0].name).toBe("icon")
expect(slots[0].x).toBe(312)
expect(slots[0].y).toBe(200)
expect(slots[0].w).toBe(400)
expect(slots[0].h).toBe(400)
expect(slots[0].fit).toBe("contain")
expect(slots[0].recolor).toBe("accent")
expect(slots[0].bg).toBe("none")
expect(slots[0].border).toBe("none")
expect(slots[0].radius).toBe(0)
})
test("parses badge slot with bg+border+radius", () => {
const svg = `<!-- slot: name=badge, x=780, y=780, w=180, h=180, fit=contain, recolor=none, bg=surface, border=accent, radius=0.5 -->`
const slots = parseSlots(svg)
expect(slots).toHaveLength(1)
expect(slots[0].name).toBe("badge")
expect(slots[0].recolor).toBe("none")
expect(slots[0].bg).toBe("surface")
expect(slots[0].border).toBe("accent")
expect(slots[0].radius).toBe(0.5)
})
test("parses multiple slots", () => {
const svg = `
<!-- 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 -->
`
const slots = parseSlots(svg)
expect(slots).toHaveLength(3)
expect(slots.map((s) => s.name)).toEqual(["icon", "sub-icon", "badge"])
})
test("defaults fit to contain when omitted", () => {
const svg = `<!-- slot: name=icon, x=0, y=0, w=100, h=100 -->`
const slots = parseSlots(svg)
expect(slots[0].fit).toBe("contain")
expect(slots[0].recolor).toBe("none")
})
test("returns empty for no slots", () => {
expect(parseSlots("<svg></svg>")).toEqual([])
})
test("skips slot without name", () => {
const svg = `<!-- slot: x=0, y=0, w=100, h=100 -->`
expect(parseSlots(svg)).toEqual([])
})
})

View file

@ -1,76 +0,0 @@
import { describe, test, expect, beforeAll } from "vitest"
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 { 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, "..")
beforeAll(() => {
const tmp = "/tmp/draw-image-slot"
if (existsSync(tmp)) rmSync(tmp, { recursive: true, force: true })
mkdirSync(tmp, { recursive: true })
})
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 slots = parseSlots(templateSvg)
const iconSlot = slots.find((s) => s.name === "icon")!
expect(iconSlot).toBeDefined()
const resolved = resolveSlotContent(iconSlot, brand, DRAW_IMAGE_DIR, "mic")
expect(resolved).not.toBeNull()
expect(resolved!.inner).toContain("<path")
expect(resolved!.rootAttrs.stroke).toBe("#ccff00")
expect(resolved!.contentW).toBe(24)
expect(resolved!.contentH).toBe(24)
})
test("icon=play resolves lucide icon", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const slots = parseSlots(templateSvg)
const iconSlot = slots.find((s) => s.name === "icon")!
const resolved = resolveSlotContent(iconSlot, brand, DRAW_IMAGE_DIR, "play")
expect(resolved).not.toBeNull()
expect(resolved!.inner).toContain("<path")
})
test("badge slot has bg=surface and border=accent", () => {
const templateSvg = loadFixture("cover")
const slots = parseSlots(templateSvg)
const badgeSlot = slots.find((s) => s.name === "badge")!
expect(badgeSlot.bg).toBe("surface")
expect(badgeSlot.border).toBe("accent")
expect(badgeSlot.radius).toBe(0.5)
})
test("buildSvg inserts slot content into final SVG", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("cover")
const svg = buildSvg(templateSvg, brand, {
template: "cover",
title: "With Icon",
slots: { icon: "mic" },
}, DRAW_IMAGE_DIR)
expect(svg).toContain("<path")
expect(svg).toContain('stroke="#ccff00"')
expect(svg).not.toContain("<!-- slot:")
expect(svg).toContain("With Icon")
})
test("buildSvg without slots renders template defaults", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadFixture("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

@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmit": true,
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "cli.ts", "tests/**/*.ts"]
}

View file

@ -1,9 +0,0 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
testTimeout: 30000,
},
})

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": { "permission": {
@ -236,11 +189,6 @@
"python3 */spec-status.py*": "deny", "python3 */spec-status.py*": "deny",
"python *spec-status.py*": "deny", "python *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", "mkdir*": "allow",
"git branch -D *": "ask", "git branch -D *": "ask",
"git branch -d *": "ask", "git branch -d *": "ask",
@ -328,26 +276,7 @@
"git commit *": "deny", "git commit *": "deny",
"gh pr create *": "deny", "gh pr create *": "deny",
"gh pr merge *": "deny", "gh pr merge *": "deny",
"gh issue create *": "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"
} }
}, },
"agent": { "agent": {
@ -358,25 +287,38 @@
"create_pr": true, "create_pr": true,
"create_issue": true, "create_issue": true,
"merge_pr": false, "merge_pr": false,
"post_review": false "post_review": false,
"post_docs_review": false
} }
}, },
"reviewer": { "reviewer": {
"tools": { "tools": {
"commit": false, "commit": false,
"create_pr": false, "create_pr": false,
"create_issue": true, "create_issue": false,
"merge_pr": false, "merge_pr": false,
"post_review": true "post_review": true,
"post_docs_review": false
}
},
"docs-reviewer": {
"tools": {
"commit": true,
"create_pr": false,
"create_issue": false,
"merge_pr": false,
"post_review": false,
"post_docs_review": true
} }
}, },
"memory-syncer": { "memory-syncer": {
"tools": { "tools": {
"commit": false, "commit": false,
"create_pr": false, "create_pr": false,
"create_issue": true, "create_issue": false,
"merge_pr": false, "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,35 +1,33 @@
#!/usr/bin/env python3 #!/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 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`` derive the current pipeline phase of a PR no state file, like ``git status``
for the PR pipeline. CI gate via ``gh pr view --json statusCheckRollup`` (read-only): for the PR pipeline. CI gate via Actions API (read-only): CI blocks MERGE
CI blocks MERGE (transitive guard first phase blocks all subsequent phases). (transitive guard first phase blocks all subsequent phases).
Usage: Usage:
python3 config/scripts/pipeline-status.py <PR_NUMBER> # single PR status python3 config/scripts/pipeline-status.py <PR_NUMBER> # single PR status
python3 config/scripts/pipeline-status.py # table of open PRs 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 1. ISSUE GitHub issue exists and linked via Closes/Fixes #N
2. IMPLEMENT PR exists + PR body has 4 required headings 2. IMPLEMENT PR exists + handoff file docs/handoff/pr-N-slug.md in diff
3. CI all checks on PR head SHA completed & success (statusCheckRollup) 3. DOCS handoff valid (4 sections) + mandatory ADR
4. REVIEW APPROVE found in PR comments 4. CI latest CI run on PR branch completed & success
5. MERGE PR state is MERGED 5. REVIEW APPROVE found in PR comments
6. MEMORY PR#N distilled into repos/{host}/{org}/{repo}.md 6. MERGE PR state is MERGED
7. MEMORY PR#N distilled into repos/{host}/{org}/{repo}.md
""" """
from __future__ import annotations from __future__ import annotations
import functools import functools
import json
import os import os
import re import re
import subprocess import subprocess
import sys import sys
import time import time
import urllib.error
import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
@ -51,8 +49,13 @@ _MEMORY_BASE = os.environ.get(
str(REPO_ROOT / "app_data" / "opencode-memory"), str(REPO_ROOT / "app_data" / "opencode-memory"),
) )
MEMORY_DIR = Path(_MEMORY_BASE) / "repos" 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_WAIT_TIMEOUT = 300
CI_POLL_INTERVAL = 10 CI_POLL_INTERVAL = 10
@ -68,6 +71,7 @@ REVIEW_VERDICT_RE = re.compile(
r"## Code Review Summary.*?###\s*Verdict:\s*(\w+)", r"## Code Review Summary.*?###\s*Verdict:\s*(\w+)",
re.IGNORECASE | re.DOTALL, 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) JSON_FIELD_RE = re.compile(r'"(\w+)"\s*:\s*"?([^",}]*)"?', re.IGNORECASE)
@ -87,135 +91,10 @@ class PhaseResult:
detail: str 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]: def run_cmd(args: list[str]) -> tuple[int, str, str]:
"""Run a command, return (returncode, stdout, stderr). """Run a command, return (returncode, stdout, stderr)."""
result = subprocess.run(args, capture_output=True, text=True, check=False)
Forgejo dispatch (ADR-forgejo): when ``FORGEJO_URL`` is set, ``gh`` argv return result.returncode, result.stdout, result.stderr
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
@dataclass(frozen=True) @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}") raise ValueError(f"Cannot parse remote URL: {url}")
def _resolve_memory_base() -> tuple[Path, str]: def get_memory_file_path() -> Path:
"""Derive (memory_dir, repo_name) from ``git remote get-url origin``.""" """Derive memory file path from ``git remote get-url origin``."""
rc, out, err = run_cmd(["git", "remote", "get-url", "origin"]) rc, out, err = run_cmd(["git", "remote", "get-url", "origin"])
if rc != 0: if rc != 0:
raise RuntimeError(f"Cannot get git remote URL: {err.strip()}") raise RuntimeError(f"Cannot get git remote URL: {err.strip()}")
host, org, repo = parse_remote_url(out.strip()) host, org, repo = parse_remote_url(out.strip())
return MEMORY_DIR / host / org, repo return MEMORY_DIR / host / org / f"{repo}.md"
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)
@functools.cache @functools.cache
@ -379,106 +248,223 @@ def check_issue(pr_number: int) -> PhaseResult:
def check_implement(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( 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: if rc != 0:
return PhaseResult(PhaseStatus.NOT_DONE, f"PR #{pr_number} не существует") return PhaseResult(PhaseStatus.NOT_DONE, f"PR #{pr_number} не существует")
try:
pr_body = json.loads(out).get("body", "") or "" files = re.findall(r'"path"\s*:\s*"([^"]+)"', out)
except (json.JSONDecodeError, ValueError): pattern = f"docs/handoff/pr-{pr_number}-"
return PhaseResult( handoff_files = [f for f in files if pattern in f]
PhaseStatus.NOT_DONE, f"PR #{pr_number}: не удалось распарсить JSON body" if not handoff_files:
) return PhaseResult(PhaseStatus.NOT_DONE, f"handoff {pattern}*.md не найден в diff")
required = ["## Что сделано", "## Почему", "## Watch out", "## Pending"]
missing = [h for h in required if h not in pr_body] 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: if missing:
return PhaseResult( return PhaseResult(PhaseStatus.NOT_DONE, f"отсутствуют секции: {', '.join(missing)}")
PhaseStatus.NOT_DONE, f"PR body не содержит heading'и: {', '.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 config/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: def check_ci(pr_number: int) -> PhaseResult:
"""Phase 3: CI — all checks on PR head SHA completed & success. """Phase 4: CI — latest CI run on PR branch completed & success.
Uses ``gh pr view --json statusCheckRollup`` which aggregates ALL Uses Actions API (read-only, ``Actions: read`` scope). Polls until
workflows for the PR head SHA (CI, CI (always), ADR check, etc.). ``status == completed`` or ``CI_WAIT_TIMEOUT`` elapsed. One tool call
This handles docs-only PRs where ``ci.yml`` has ``paths-ignore`` and final status (DONE on green, NOT_DONE on failure, AMBIGUOUS on
only ``always-ci.yml`` runs. timeout / API error).
Edge cases (no polling):
- API error (rc != 0, e.g. 403) сразу AMBIGUOUS (retries won't help).
- no runs (jq null) short retry ``CI_NO_RUNS_RETRY`` times with
``CI_NO_RUNS_INTERVAL`` (CI may not be registered right after push),
then AMBIGUOUS.
- conclusion != success сразу NOT_DONE (fix the failure, don't wait).
- status in (in_progress, queued, ...) polling loop with
``sleep(CI_POLL_INTERVAL)`` + re-query until completed or timeout.
""" """
head_branch, branch_error = _get_pr_head_branch(pr_number)
if branch_error is not None or head_branch is None:
return PhaseResult(PhaseStatus.AMBIGUOUS, branch_error or "head_branch is None")
config = _load_ci_config() config = _load_ci_config()
return _run_ci_loop(pr_number, config) return _run_ci_loop(head_branch, config)
def _run_ci_loop(pr_number: int, config: CiPollConfig) -> PhaseResult: def _run_ci_loop(head_branch: str, config: CiPollConfig) -> PhaseResult:
"""Initial CI query + edge-case dispatch + delegate to poll/no-checks helpers.""" """Initial CI query + edge-case dispatch + delegate to poll/no-runs helpers."""
kind, json_str, err = _query_ci_rollup(pr_number) kind, runs_str, err = _query_ci_run(head_branch)
if kind == "error": if kind == "error":
return PhaseResult(PhaseStatus.AMBIGUOUS, err) return PhaseResult(PhaseStatus.AMBIGUOUS, err)
if kind == "no_checks": if kind == "no_runs":
return _retry_no_checks(pr_number, config) return _retry_no_runs(head_branch, config)
return _classify_rollup_with_poll(pr_number, config, json_str) status = _extract_json_field_loose(runs_str, "status")
if status is None:
return PhaseResult(PhaseStatus.AMBIGUOUS, "не удалось распарсить status CI run")
if status == "completed":
conclusion = _extract_json_field_loose(runs_str, "conclusion")
return _classify_ci_status(status, conclusion)
return _poll_until_done(head_branch, config, runs_str, status)
def _retry_no_checks(pr_number: int, config: CiPollConfig) -> PhaseResult: def _retry_no_runs(head_branch: str, config: CiPollConfig) -> PhaseResult:
"""Retry CI query when no checks registered yet (CI may lag after push). """Retry CI query when no run registered yet (CI may lag after push).
Up to ``CI_NO_RUNS_RETRY`` total attempts, sleeping ``CI_NO_RUNS_INTERVAL`` Up to ``CI_NO_RUNS_RETRY`` total attempts (initial + retries), sleeping
between attempts. On success classify/poll; exhausted AMBIGUOUS. ``CI_NO_RUNS_INTERVAL`` between attempts. On success classify/poll;
on API error AMBIGUOUS; exhausted AMBIGUOUS.
""" """
for attempt in range(CI_NO_RUNS_RETRY): for attempt in range(CI_NO_RUNS_RETRY):
if attempt > 0: if attempt > 0:
time.sleep(CI_NO_RUNS_INTERVAL) time.sleep(CI_NO_RUNS_INTERVAL)
kind, json_str, err = _query_ci_rollup(pr_number) kind, runs_str, err = _query_ci_run(head_branch)
if kind == "error": if kind == "error":
return PhaseResult(PhaseStatus.AMBIGUOUS, err) return PhaseResult(PhaseStatus.AMBIGUOUS, err)
if kind == "rollup": if kind == "run":
return _classify_rollup_with_poll(pr_number, config, json_str) status = _extract_json_field_loose(runs_str, "status")
if status is None:
return PhaseResult(PhaseStatus.AMBIGUOUS, "не удалось распарсить status CI run")
if status == "completed":
conclusion = _extract_json_field_loose(runs_str, "conclusion")
return _classify_ci_status(status, conclusion)
return _poll_until_done(head_branch, config, runs_str, status)
return PhaseResult( return PhaseResult(
PhaseStatus.AMBIGUOUS, PhaseStatus.AMBIGUOUS,
f"нет CI checks на PR #{pr_number} — возможна проблема триггера", f"нет CI run на ветке {head_branch} — возможна проблема триггера",
) )
def _classify_rollup_with_poll(pr_number: int, config: CiPollConfig, json_str: str) -> PhaseResult: def _poll_until_done(
"""Classify rollup; if in_progress → poll until completed or timeout.""" head_branch: str, config: CiPollConfig, runs_str: str, last_status: str
result = _classify_rollup(json_str) ) -> PhaseResult:
if result.status != PhaseStatus.AMBIGUOUS or "in progress" not in result.detail.lower(): """Poll Actions API until status == completed or CI_WAIT_TIMEOUT elapsed.
return result
``runs_str``/``last_status`` are the most recent query results (avoids
re-querying immediately). Sleeps ``CI_POLL_INTERVAL`` between queries.
On timeout AMBIGUOUS (CI still running check manually). On completed
classify.
"""
elapsed = 0 elapsed = 0
while elapsed < config.wait_timeout: status = last_status
runs_str_cur = runs_str
while status != "completed" and elapsed < config.wait_timeout:
if elapsed + config.poll_interval > config.wait_timeout: if elapsed + config.poll_interval > config.wait_timeout:
break break
time.sleep(config.poll_interval) time.sleep(config.poll_interval)
elapsed += config.poll_interval elapsed += config.poll_interval
kind, json_str_new, err = _query_ci_rollup(pr_number) kind, runs_str_new, err = _query_ci_run(head_branch)
if kind == "error": if kind == "error":
return PhaseResult(PhaseStatus.AMBIGUOUS, err) return PhaseResult(PhaseStatus.AMBIGUOUS, err)
if kind == "no_checks": if kind == "no_runs":
return PhaseResult( return PhaseResult(
PhaseStatus.AMBIGUOUS, PhaseStatus.AMBIGUOUS,
f"нет CI checks на PR #{pr_number} — возможна проблема триггера", f"нет CI run на ветке {head_branch} — возможна проблема триггера",
) )
result = _classify_rollup(json_str_new) runs_str_cur = runs_str_new
if result.status != PhaseStatus.AMBIGUOUS or "in progress" not in result.detail.lower(): status_new = _extract_json_field_loose(runs_str_cur, "status")
return result if status_new is None:
return PhaseResult(PhaseStatus.AMBIGUOUS, "не удалось распарсить status CI run")
status = status_new
if status == "completed":
conclusion = _extract_json_field_loose(runs_str_cur, "conclusion")
return _classify_ci_status(status, conclusion)
return PhaseResult( return PhaseResult(
PhaseStatus.AMBIGUOUS, PhaseStatus.AMBIGUOUS,
f"CI ещё идёт после {config.wait_timeout}s — проверь вручную: gh pr checks {pr_number}", f"CI ещё идёт после {config.wait_timeout}s — проверь вручную: "
f"gh run view --branch {head_branch}",
) )
def _query_ci_rollup(pr_number: int) -> tuple[str, str, str]: def _get_pr_head_branch(pr_number: int) -> tuple[str | None, str | None]:
"""Query PR statusCheckRollup via gh CLI. """Return (head_branch, None) or (None, error_message)."""
GitHub aggregates ALL checks (CI, CI (always), ADR check) for PR head SHA.
Return ``(kind, json_str, error)`` where ``kind`` is:
- ``"error"`` API call failed (rc != 0).
- ``"no_checks"`` rollup array is empty (no checks registered yet).
- ``"rollup"`` JSON with statusCheckRollup array, ``json_str`` set.
"""
rc, out, err = run_cmd( rc, out, err = run_cmd(
[ [
"gh", "gh",
@ -486,49 +472,77 @@ def _query_ci_rollup(pr_number: int) -> tuple[str, str, str]:
"view", "view",
str(pr_number), str(pr_number),
"--json", "--json",
"statusCheckRollup", "headRefName",
"--repo", "--repo",
get_repo_full_name(), get_repo_full_name(),
] ]
) )
if rc != 0: if rc != 0:
return "error", "", f"PR API error: {err.strip()}" return None, f"не удалось получить ветку PR: {err.strip()}"
json_str = out.strip() head_branch = extract_json_field(out, "headRefName")
if not json_str: if not head_branch:
return "no_checks", "", "" return None, "не удалось распарсить headRefName PR"
if re.search(r'"statusCheckRollup"\s*:\s*\[\s*\]', json_str): return head_branch, None
return "no_checks", "", ""
return "rollup", json_str, ""
def _classify_rollup(json_str: str) -> PhaseResult: def _query_ci_run(head_branch: str) -> tuple[str, str, str]:
"""Classify CI status from statusCheckRollup JSON. """Query Actions API for latest CI run on ``head_branch``.
All checks COMPLETED + SUCCESS/SKIPPED/NEUTRAL DONE. Return ``(kind, json_str, error)`` where ``kind`` is one of:
Any check COMPLETED + non-success conclusion NOT_DONE. - ``"error"`` API call failed (rc != 0, e.g. 403), ``error`` set.
Any check IN_PROGRESS/QUEUED/PENDING AMBIGUOUS (poll). - ``"no_runs"`` jq returned null/empty (no CI run registered yet).
- ``"run"`` JSON with status/conclusion, ``json_str`` set.
""" """
statuses = re.findall(r'"status"\s*:\s*"([^"]*)"', json_str, re.IGNORECASE) jq_filter = (
if not statuses: f'[.workflow_runs[] | select(.head_branch == "{head_branch}") '
return PhaseResult(PhaseStatus.AMBIGUOUS, "no checks found in rollup") f'| select(.name == "CI")] | .[0]'
)
rc, out, err = run_cmd(
[
"gh",
"api",
f"repos/{get_repo_full_name()}/actions/runs",
"--jq",
jq_filter,
]
)
if rc != 0:
return "error", "", f"Actions API error: {err.strip()}"
runs_str = out.strip()
if not runs_str or runs_str == "null":
return "no_runs", "", ""
return "run", runs_str, ""
in_progress = [s for s in statuses if s.upper() in ("IN_PROGRESS", "QUEUED", "PENDING")]
if in_progress:
return PhaseResult(PhaseStatus.AMBIGUOUS, "CI in progress")
conclusions = re.findall(r'"conclusion"\s*:\s*"([^"]*)"', json_str, re.IGNORECASE)
null_conclusions = re.findall(r'"conclusion"\s*:\s*null', json_str, re.IGNORECASE)
for c in conclusions:
if c.upper() not in ("SUCCESS", "SKIPPED", "NEUTRAL"):
return PhaseResult(PhaseStatus.NOT_DONE, f"CI {c.lower()} — fix needed")
if null_conclusions:
return PhaseResult(PhaseStatus.AMBIGUOUS, "CI completed but conclusion missing")
def _classify_ci_status(status: str, conclusion: str | None) -> PhaseResult:
"""Map CI status+conclusion to PhaseResult."""
if status != "completed":
return PhaseResult(PhaseStatus.AMBIGUOUS, f"CI {status} — wait")
if conclusion is None:
return PhaseResult(
PhaseStatus.AMBIGUOUS,
"CI completed but conclusion missing",
)
if conclusion != "success":
return PhaseResult(PhaseStatus.NOT_DONE, f"CI {conclusion} — fix needed")
return PhaseResult(PhaseStatus.DONE, "CI green") return PhaseResult(PhaseStatus.DONE, "CI green")
def _extract_json_field_loose(json_str: str, field: str) -> str | None:
"""Extract a JSON string field handling null values (unlike extract_json_field).
``extract_json_field`` uses ``"([^"]*)"`` which never matches ``null``.
This helper accepts both ``"value"`` and ``null`` (returns None for null).
"""
match = re.search(rf'"{field}"\s*:\s*"(?P<v>[^"]*)"', json_str)
if match:
return match.group("v")
null_match = re.search(rf'"{field}"\s*:\s*null', json_str)
if null_match:
return None
return None
def _extract_comment_bodies(json_str: str) -> list[str]: def _extract_comment_bodies(json_str: str) -> list[str]:
"""Extract 'body' fields from gh pr view --json comments output. """Extract 'body' fields from gh pr view --json comments output.
@ -545,7 +559,7 @@ def _extract_comment_bodies(json_str: str) -> list[str]:
def check_review(pr_number: int) -> PhaseResult: def check_review(pr_number: int) -> PhaseResult:
"""Phase 5: REVIEW — APPROVE found in PR comments from code reviewer. """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 with '### Verdict: APPROVE'. Only the latest reviewer comment counts
if reviewer changed from APPROVE to REQUEST_CHANGES, NOT_DONE. if reviewer changed from APPROVE to REQUEST_CHANGES, NOT_DONE.
""" """
@ -595,24 +609,24 @@ def check_merge(pr_number: int) -> PhaseResult:
def check_memory(pr_number: int) -> PhaseResult: def check_memory(pr_number: int) -> PhaseResult:
"""Phase 7: MEMORY — PR#N distilled into memory file.""" """Phase 7: MEMORY — PR#N distilled into memory file."""
try: try:
files = get_memory_files() memory_file = get_memory_file_path()
except (RuntimeError, ValueError) as exc: except (RuntimeError, ValueError) as exc:
return PhaseResult(PhaseStatus.NOT_DONE, str(exc)) return PhaseResult(PhaseStatus.NOT_DONE, str(exc))
if not files: if not memory_file.exists():
return PhaseResult( return PhaseResult(
PhaseStatus.NOT_DONE, PhaseStatus.NOT_DONE,
"memory files не найдены", f"memory file не существует: {memory_file.name}",
) )
content = memory_file.read_text()
pattern = f"PR#{pr_number}" pattern = f"PR#{pr_number}"
for f in files: if pattern in content:
if pattern in f.read_text(): return PhaseResult(PhaseStatus.DONE, f"{pattern} в {memory_file.name}")
return PhaseResult(PhaseStatus.DONE, f"{pattern} в {f.name}")
return PhaseResult( return PhaseResult(
PhaseStatus.NOT_DONE, PhaseStatus.NOT_DONE,
f"{pattern} не найден в {len(files)} файл(ах)", f"{pattern} не найден в {memory_file.name}",
) )
@ -630,6 +644,7 @@ def get_pr_title(pr_number: int) -> str:
NEXT_ACTIONS: dict[str, str] = { NEXT_ACTIONS: dict[str, str] = {
"ISSUE": "dispatch subagent (subagent_type=general, template=A) for PR #N", "ISSUE": "dispatch subagent (subagent_type=general, template=A) for PR #N",
"IMPLEMENT": "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)", "CI": "проверь статус CI вручную (gh run view)",
"REVIEW": "dispatch subagent (subagent_type=reviewer, template=C) for PR #N", "REVIEW": "dispatch subagent (subagent_type=reviewer, template=C) for PR #N",
"MERGE": "call merge_pr tool with pr_number=N", "MERGE": "call merge_pr tool with pr_number=N",
@ -667,10 +682,11 @@ def get_next_action_review(result: PhaseResult) -> str:
def run_all_checks(pr_number: int) -> list[PhaseResult]: 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 [ return [
check_issue(pr_number), check_issue(pr_number),
check_implement(pr_number), check_implement(pr_number),
check_docs(pr_number),
check_ci(pr_number), check_ci(pr_number),
check_review(pr_number), check_review(pr_number),
check_merge(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 from __future__ import annotations
import functools import functools
import importlib.util
import os
import re import re
import subprocess import subprocess
import sys import sys
import urllib.error
import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from pathlib import Path 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: def _resolve_repo_root() -> Path:
"""Resolve repo root via git (cwd-aware), fallback to script location.""" """Resolve repo root via git (cwd-aware), fallback to script location."""
@ -77,10 +67,16 @@ PHASE_FILES: dict[int, str] = {
6: "roadmap.md", 6: "roadmap.md",
} }
# Re-exported from project_contract.py for backward compatibility VALID_TYPES = {"backend", "fullstack", "mcp-server", "cli", "bot", "worker"}
# (tests use ``ss.VALID_TYPES`` / ``ss.STACK_REQUIRED``).
VALID_TYPES = project_contract.VALID_TYPES STACK_REQUIRED: dict[str, list[str]] = {
STACK_REQUIRED = project_contract.STACK_REQUIRED "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 = [ PHASE_NAMES = [
"DETECT", "DETECT",
@ -94,7 +90,7 @@ PHASE_NAMES = [
"EXECUTE", "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) KV_RE = re.compile(r"^(\w+):\s*(.*?)$", re.MULTILINE)
@ -114,47 +110,8 @@ class PhaseResult:
detail: str 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]: def run_cmd(args: list[str]) -> tuple[int, str, str]:
"""Run a command, return (returncode, stdout, stderr). """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"
result = subprocess.run(args, capture_output=True, text=True, check=False) result = subprocess.run(args, capture_output=True, text=True, check=False)
return result.returncode, result.stdout, result.stderr 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]: def parse_frontmatter(content: str) -> dict[str, str]:
"""Parse simple key: value frontmatter (no nested structures). """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.
"""
match = FRONTMATTER_RE.search(content) match = FRONTMATTER_RE.search(content)
if not match: if not match:
return {} return {}
fm_text = match.group(1) fm_text = match.group(1)
parsed: dict[str, str] = {} return dict(KV_RE.findall(fm_text))
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
def read_meta() -> tuple[str, dict[str, str]]: def read_meta() -> tuple[str, dict[str, str]]:
"""Read docs/spec/meta.md content + parsed frontmatter. """Read docs/spec/meta.md content + parsed frontmatter.
Returns ``("", {})`` if meta.md is missing. Uses ``utf-8-sig`` to Returns ``("", {})`` if meta.md is missing.
transparently strip a leading BOM if present.
""" """
if not META_FILE.exists(): if not META_FILE.exists():
return "", {} return "", {}
content = META_FILE.read_text(encoding="utf-8-sig") content = META_FILE.read_text()
return content, parse_frontmatter(content) return content, parse_frontmatter(content)
@ -284,13 +229,8 @@ def check_stack() -> PhaseResult:
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/stack.md не заполнен") return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/stack.md не заполнен")
required = STACK_REQUIRED[ptype] required = STACK_REQUIRED[ptype]
stack_body = stack_file.read_text() stack_body = stack_file.read_text()
# Word-boundary regex: ``uv`` does NOT match ``uvicorn``, ``tailwind`` stack_lower = stack_body.lower()
# does NOT match ``tailwindcss``. Case-insensitive. missing = [item for item in required if item not in stack_lower]
missing = [
item
for item in required
if not re.search(rf"\b{re.escape(item)}\b", stack_body, re.IGNORECASE)
]
if missing: if missing:
return PhaseResult( return PhaseResult(
PhaseStatus.NOT_DONE, PhaseStatus.NOT_DONE,
@ -320,7 +260,7 @@ def check_modules() -> PhaseResult:
def check_db_schema() -> PhaseResult: def check_db_schema() -> PhaseResult:
"""Phase 4: DB_SCHEMA — no_db: true OR db-schema.md filled.""" """Phase 4: DB_SCHEMA — no_db: true OR db-schema.md filled."""
_content, fm = read_meta() _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 не нужна)") return PhaseResult(PhaseStatus.DONE, "no_db: true (DB не нужна)")
db_file = SPEC_DIR / PHASE_FILES[4] db_file = SPEC_DIR / PHASE_FILES[4]
if not file_filled(db_file): if not file_filled(db_file):
@ -364,7 +304,7 @@ def check_confirm() -> PhaseResult:
"""Phase 7: CONFIRM — confirmed: true in meta.md frontmatter.""" """Phase 7: CONFIRM — confirmed: true in meta.md frontmatter."""
_content, fm = read_meta() _content, fm = read_meta()
val = fm.get("confirmed", "").strip().lower() 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.NOT_DONE, "confirmed: true отсутствует в frontmatter")
return PhaseResult(PhaseStatus.DONE, "spec подтверждён юзером") return PhaseResult(PhaseStatus.DONE, "spec подтверждён юзером")
@ -396,7 +336,7 @@ def check_execute() -> PhaseResult:
"""Phase 8: EXECUTE — executed: true + issues created (gh view --repo).""" """Phase 8: EXECUTE — executed: true + issues created (gh view --repo)."""
_content, fm = read_meta() _content, fm = read_meta()
val = fm.get("executed", "").strip().lower() val = fm.get("executed", "").strip().lower()
if val != "true": if val not in {"true", '"true"'}:
return PhaseResult(PhaseStatus.NOT_DONE, "executed: true отсутствует в frontmatter") return PhaseResult(PhaseStatus.NOT_DONE, "executed: true отсутствует в frontmatter")
roadmap_file = SPEC_DIR / PHASE_FILES[6] roadmap_file = SPEC_DIR / PHASE_FILES[6]
if not roadmap_file.exists(): if not roadmap_file.exists():

View file

@ -21,7 +21,7 @@ description: Use when creating a new opencode skill. Covers file location, front
```markdown ```markdown
--- ---
name: <skill-name> name: <skill-name>
description: <when to load this skill, in English. Example: Use when ... Also when user says "русские фразы-триггеры"> description: <когда загружать. Триггеры на русском и английском. Например: Use when ... Also when user says "...">
--- ---
# Skill Title # Skill Title
@ -32,7 +32,7 @@ description: <when to load this skill, in English. Example: Use when ... Also wh
### Правила ### Правила
- `name` — kebab-case, совпадает с именем директории - `name` — kebab-case, совпадает с именем директории
- `description`must be in English. Contains specific triggers (when the agent should load this skill). Format: `Use when ... Also when user says "..."`. The `Also when user says "..."` part may contain Russian trigger phrases since the user speaks Russian. - `description`содержит конкретные триггеры (когда агент должен загрузить этот скилл)
- Язык тела — русский с английскими техническими терминами (как в существующих скиллах) - Язык тела — русский с английскими техническими терминами (как в существующих скиллах)
- Один скилл — одна директория с одним `SKILL.md` - Один скилл — одна директория с одним `SKILL.md`
@ -49,7 +49,7 @@ description: <when to load this skill, in English. Example: Use when ... Also wh
├── memory/SKILL.md ├── memory/SKILL.md
├── python-development/SKILL.md ├── python-development/SKILL.md
├── release/SKILL.md ├── release/SKILL.md
├── project-template/SKILL.md ├── repo-init/SKILL.md
├── run-pipeline/SKILL.md ├── run-pipeline/SKILL.md
├── run-tests/SKILL.md ├── run-tests/SKILL.md
├── spec/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

@ -1,15 +0,0 @@
---
name: bug-discovery
description: Use when a bug is found outside the current task scope — MANDATORY for ALL subagents. Covers duplicate check, issue creation via create-issue tool, and continuation protocol. Also when user says "нашёл баг", "bug found", "создай issue для бага", "bug outside scope", "заметил проблему", "code smell", "unexpected behavior not related to current task".
---
# Bug Discovery Protocol
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).
5. Continue the current task. Do NOT fix the bug yourself.
6. Report to orchestrator: "Created issue #N: ...".

View file

@ -1,6 +1,6 @@
--- ---
name: code-standards name: code-standards
description: Universal code standards for any language. Use when writing, refactoring, or reviewing code. Also when user says "стандарты кода", "code review", "правила разработки". description: Универсальные правила разработки для любого языка. Используй когда пишешь, рефакторишь или ревьювишь код.
--- ---
# Code Standards # Code Standards
@ -25,113 +25,3 @@ description: Universal code standards for any language. Use when writing, refact
- AGENTS.md правило «No comments unless requested» — это **default**: код без комментариев - AGENTS.md правило «No comments unless requested» — это **default**: код без комментариев
- Этот skill описывает **исключение**: Google-style docstrings на английском для публичных API — когда контракт warrants (библиотечный API, public surface) - Этот 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 конфиг нужен для изоляции в конкретном проекте) — тогда указать явно в комментарии к изменению. - **Исключение:** явный override-сценарий (project-local конфиг нужен для изоляции в конкретном проекте) — тогда указать явно в комментарии к изменению.
- **Docker (опционально):** для контейнерного запуска можно bind-mount `.opencode/``~/.config/opencode/` (см. `docker-compose.yml`) — но это optional path, не канон. Репо обязан работать и bare-`opencode` в клона. - **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. Применение изменений ## 2. Применение изменений
- `commit` + `push` в репо `slaid098/opencode-config` (через `commit` tool). - `commit` + `push` в репо `slaid098/opencode-config` (через `commit` tool).

View file

@ -1,141 +0,0 @@
---
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", "спецификация фичи".
---
# Feature Spec
Лёгкий SDD-скилл: Q&A с юзером по SDD-шаблону → план в чате → issue через `issue` скилл.
## Когда использовать
| Ситуация | Инструмент |
|----------|-----------|
| 1 файл, очевидное поведение | Прямой чат, без формальностей |
| 2+ компонента, бизнес-правила, diff >400 строк | `/feature-spec` |
| Новый проект с нуля | `/spec` (9 фаз) |
## ПРОТОКОЛ
### 1. Анализ
Юзер описывает фичу. Агент читает 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
Агент задаёт вопросы списком — НЕ гадает. Пример:
```
Не хватает информации по:
1. Контракты: какой формат запроса/ответа? Какие коды ошибок?
2. Инварианты: какие лимиты? Какой TTL? Какая модель/библиотека?
3. Граничные случаи: что если внешний сервис недоступен? Что если данных нет?
4. Влияние на связанные компоненты: какие детерминированные связи (oracle-скрипты, валидаторы, парсеры, промпты-агенты) зависят от этого изменения? Что может сломаться если поменять X? Нужны ли paired updates в других файлах? (explore subagent уже нашёл candidates на шаге 1.5 — юзер подтверждает/дополняет)
5. Вне scope: что точно НЕ делаем в этой итерации?
```
Юзер отвечает. Если ответ неполный — агент уточняет. Q&A продолжается пока все 8 секций не заполнены.
### 3. План
Когда все данные собраны, агент выводит структурированный план:
```
## Контекст
Зачем: [мотивация]
Контекст: [текущее состояние]
## Задача
[Что делаем — архитектурный подход, пошагово]
## Контракты
[API, форматы, коды ошибок]
## Инварианты
[Правила без исключений: лимиты, ограничения, выбор технологий]
## Граничные случаи
[Что при ошибках: невалидный вход, отказ сервиса, превышение лимита]
## Влияние на связанные компоненты
[Файлы/оракулы/агенты/промпты/валидаторы, которые зависят от изменения; нужен ли paired update. Если нет — явно «нет связанных компонентов»]
## Вне scope
[Что НЕ делаем]
## Критерии приемки
- [ ] Сценарий 1: "пользователь делает X → видит Y"
- [ ] Сценарий 2
```
### 4. Handoff
После готовности плана:
- Агент: "План готов. Скажи 'создай issue' чтобы создать issue, потом запусти /run-pipeline."
- Юзер: "создай issue" → загружается `issue` скилл → `create-issue` tool (8 секций) → issue создан
- Юзер: `/run-pipeline` → реализация
## Правила
- **Не гадай** — если данных не хватает, задай вопрос
- **Будь конкретным** — не "используй кеш", а "Redis с TTL 7 дней"
- **Спека описывает ЧТО, не КАК** — контракты и решения, не алгоритмы
- **Каждый пункт 1-3 предложения** — если больше, это две задачи
- **Если фича простая** (1 файл, очевидное поведение) — скажи юзеру что спека не нужна
- **Не создавай issues сам** — только планируй. Issues через `issue` скилл по команде юзера
- **Не запускай /run-pipeline** — юзер делает это сам
- **Не создавай файлы** — план живёт в чате, потом в issue
- **1 issue = 1 PR** — если фича большая, предложи разбить на подзадачи
## SDD-шаблон (8 секций)
Совпадает с `create-issue` validation (PR #169, расширено в #249):
| # | Секция | Что содержит |
|---|--------|-------------|
| 1 | `## Контекст` | Зачем (мотивация) + текущее состояние |
| 2 | `## Задача` | Что делаем — пошагово, с путями к файлам |
| 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).
```

View file

@ -1,6 +1,6 @@
--- ---
name: get-project-map 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: Используй этот навык, когда тебе нужно увидеть или актуализировать текущую структуру папок и файлов проекта (особенно после создания/удаления файлов или переключения веток), либо понять расположение пакетов в воркспейсе. Также содержит шаблон для поддержки docs/project-map/.
--- ---
# Навык получения карты проекта (Project Map) # Навык получения карты проекта (Project Map)
@ -17,3 +17,113 @@ description: Use when you need to view or update the current project folder/file
1. Запусти указанную команду в терминале. Она выведет дерево каталогов и список файлов с их размерами прямо в stdout. 1. Запусти указанную команду в терминале. Она выведет дерево каталогов и список файлов с их размерами прямо в stdout.
2. Изучи полученную структуру воркспейсов, чтобы точно знать расположение файлов и пакетов. 2. Изучи полученную структуру воркспейсов, чтобы точно знать расположение файлов и пакетов.
3. Не сохраняй вывод в файлы на диск — читай его напрямую из вывода терминала. 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

@ -1,6 +1,6 @@
--- ---
name: issue name: issue
description: Creates GitHub issues. Issues must be self-contained — an agent in an empty chat can execute without extra context. If a task is large, split it into smaller ones. Use a subagent for creation to avoid cluttering context. Also when user says "создай ишью", "создай issue", "заведи задачу", "разбей на подзадачи", "create issue". description: Создаёт GitHub issue. Issue должны быть самодостаточными — агент в пустом чате может выполнить без доп. контекста. Если задача большая — разбей на несколько маленьких. Используй subagent для создания чтобы не засорять контекст. Also when user says "создай ишью", "создай issue", "заведи задачу", "разбей на подзадачи", "create issue".
--- ---
## Принцип: один issue = один PR ## Принцип: один issue = один PR
@ -21,30 +21,39 @@ Issue должно содержать всё необходимое, чтобы
```markdown ```markdown
## Контекст ## Контекст
Зачем: [мотивация — почему это нужно] (зачем это нужно, какая проблема решается)
Контекст: [текущее состояние, что есть сейчас]
## Задача ## Что сделать
[Что делаем — пошагово, с путями к файлам и номерами строк] (пошагово, с путями к файлам)
## Контракты ### Шаг 1: ...
[Ожидаемое поведение: API, форматы запросов/ответов, коды ошибок] - Файл: `path/to/file.py`
- Изменить: ...
## Инварианты ### Шаг 2: ...
[Правила без исключений: лимиты, ограничения, выбранные технологии]
## Граничные случаи ## Проверка
[Что при ошибках: невалидный вход, отказ внешнего сервиса, превышение лимита] (команды и ожидаемый результат)
- `pytest tests/test_xxx.py -x -q --no-cov` → all passed
- `ruff check path/to/file.py` → All checks passed
- `mypy path/to/file.py` → no issues
## Влияние на связанные компоненты ## Acceptance criteria
[Связанные файлы/оракулы/агенты/промпты/валидаторы; paired updates; «нет связанных компонентов» для тривиальных задач] (явный чек-лист — что должно быть верно в результате, не команды проверки)
- [ ] Эффект A работает в случае B
- [ ] Файл C не содержит паттерн D
- [ ] Тест E покрывает ветку F
- [ ] Coverage ≥ 80% на изменённых файлах
## Вне scope ## Dependencies
[Что НЕ делаем в этой итерации] (связи с другими issue/PR — блокировки и порядок)
- Blocked by #N (этот PR нельзя начать пока #N не смержен)
- Do not merge until #N merges (этот PR готов, но ждёт #N)
- Part of #N (подзадача родительского issue)
## Критерии приемки ## Связанные ресурсы
- [ ] Проверяемый сценарий 1: "пользователь делает X → видит Y" - Ref #33
- [ ] Проверяемый сценарий 2 - [PR #34](https://github.com/...)
``` ```
## Правило дробления ## Правило дробления
@ -70,8 +79,8 @@ Issue создаёт **subagent** (general type), а не основной аг
**Subagent (полная ответственность):** **Subagent (полная ответственность):**
1. Загрузи навык `issue` 1. Загрузи навык `issue`
2. Собери контекст — прочитай файлы из intent summary, пойми задачу, оцени объём (правило дробления ниже) 2. Собери контекст — прочитай файлы из intent summary, пойми задачу, оцени объём (правило дробления ниже)
3. Составь self-contained body по шаблону (Контекст → Задача → Контракты → Инварианты → Граничные случаи → Влияние на связанные компоненты → Вне scope → Критерии приемки) 3. Составь self-contained body по шаблону (Контекст → Что сделать → Проверка → Acceptance criteria → Dependencies → Связанные ресурсы)
4. Запусти `create-issue({ title: "...", body: "...", labels: ["..."] })` tool (НЕ raw `gh issue create` — заблокирован deny; tool валидирует conventional title format и headings `## Контекст`/`## Задача`/`## Контракты`/`## Инварианты`/`## Граничные случаи`/`## Влияние на связанные компоненты`/`## Вне scope`/`## Критерии приемки`) 4. Запусти `create-issue({ title: "...", body: "...", labels: ["..."] })` tool (НЕ raw `gh issue create` — заблокирован deny; tool валидирует conventional title format и headings `## Контекст`/`## Задача`/`## Критерии приемки`)
5. Верни URL созданного issue основному агенту 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"). Main agent НЕ пишет body и НЕ запускает `create-issue` — всё через subagent. Это согласовано с `run-pipeline` skill (Phase 0: "через subagent с `issue` skill") и `AGENTS.md` (Dev Workflow, step 2: "delegate to `task` subagent").
@ -80,44 +89,27 @@ Main agent НЕ пишет body и НЕ запускает `create-issue` — в
```markdown ```markdown
## Контекст ## Контекст
Зачем: API эндпоинт /api/videos/analyze отвечает 2-5 секунд из-за повторного обращения к Claude API для тех же видео. Кеширование результата сократит время ответа до <100мс для повторных запросов. Zoom breathing падает при включённом geometry crop — crop использует probe.width вместо iw.
Контекст: сейчас AnalysisService обращается к Claude API при каждом вызове, кеша нет.
## Задача ## Что сделать
1. В `services/analysis_service.py:45` — добавить проверку кеша перед вызовом Claude API ### Шаг 1: Заменить probe dimensions на iw/ih выражения
2. В `utils/cache.py` — использовать RedisCache (уже есть в проекте) - Файл: `src/video_uniq/effects/camera.py:72`
3. TTL результата анализа — 30 дней - Заменить `w, h = probe.width, probe.height` на `iw`/`ih` выражения
4. При cache hit — пропустить обращение к TranscriptService и AnalysisService
## Контракты ## Проверка
- POST /api/videos/analyze — без изменений в API - `pytest tests/test_effects.py -x -q --no-cov` → all passed
- При cache hit: 200 OK, время ответа <100мс - `pytest tests/test_new_effects_real.py::test_geometry_crop_with_zoom_breathing_real` → passed
- При cache miss: 200 OK, время ответа 2-5 сек (как сейчас)
## Инварианты ## Acceptance criteria
- Кеш только через Redis (RedisCache из utils/cache.py) - [ ] Geometry crop использует `iw`/`ih`, не `probe.width`/`probe.height`
- TTL результата анализа — 30 дней (2592000 сек) - [ ] Zoom breathing не падает при включённом geometry crop
- Невалидный ответ Claude НЕ кешируется - [ ] Регрессионный тест покрывает комбинацию zoom breathing + geometry crop
## Граничные случаи ## Dependencies
- Redis недоступен → логировать warning, продолжить без кеша (cache miss) - Closes #33
- Кеш содержит устаревший формат → invalidate, пересчитать
- Конкурентные запросы на одно видео → первый пишет в кеш, последующие берут из кеша
## Влияние на связанные компоненты ## Связанные ресурсы
- AnalysisController зависит от AnalysisService — без изменений (API сохранён) - Closes #33
- «Нет связанных компонентов» для тривиальных задач
## Вне scope
- ❌ Кеширование субтитров (отдельная задача)
- ❌ Инвалидация по времени просмотра видео
- ❌ Админ-панель для управления кешем
## Критерии приемки
- [ ] Повторный анализ того же видео → результат мгновенно (<100мс)
- [ ] Новое видео → результат через 2-5 сек (как раньше)
- [ ] Redis недоступен → API работает (без кеша), в логах warning
- [ ] pytest tests/test_analysis_service.py проходит
``` ```
## Пример плохого issue ## Пример плохого issue
@ -138,11 +130,9 @@ create-issue({ title: "type(scope): description", body: "...", labels: ["<label>
``` ```
Tool валидирует: title соответствует conventional format (type(scope): desc, Tool валидирует: title соответствует conventional format (type(scope): desc,
≤80 chars, English), body содержит `## Контекст`, `## Задача`, `## Контракты`, ≤80 chars, English), body содержит `## Контекст`, `## Задача`, `## Критерии
`## Инварианты`, `## Граничные случаи`, `## Влияние на связанные компоненты`, приемки` headings и на русском (Cyrillic обязательна). При ошибке валидации
`## Вне scope`, `## Критерии приемки` headings и на русском (Cyrillic tool возвращает ошибку и НЕ вызывает gh — почини формат и повтори.
обязательна). При ошибке валидации tool возвращает ошибку и НЕ вызывает gh —
почини формат и повтори.
Label выбирай по типу задачи (совпадает с commit `type`): Label выбирай по типу задачи (совпадает с commit `type`):
- `enhancement` — новая функциональность (`feat`) - `enhancement` — новая функциональность (`feat`)
@ -165,10 +155,11 @@ Label выбирай по типу задачи (совпадает с commit `t
После создания issue, цикл продолжается (см. `run-pipeline` skill для деталей PR процесса): После создания issue, цикл продолжается (см. `run-pipeline` skill для деталей PR процесса):
1. **Subagent**`task(general)` читает issue, реализует, коммитит, push, создаёт PR. Оркестрация — через `run-pipeline` skill. 1. **Subagent**`task(general)` читает issue, реализует, коммитит, push, создаёт PR. Оркестрация — через `run-pipeline` skill.
2. **Code review**`@reviewer` subagent ревьюит PR (diff, skills, standards), постит `## Code Review Summary` комментарий. 2. **Docs review**`@docs-reviewer` subagent валидирует handoff + ADR, обновляет project map (pre-merge).
3. **Merge or Repeat** — APPROVE → `merge-pr({ pr_number: N })` tool (squash + 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), delete branch, без `--admin`; НЕ raw `gh pr merge` — заблокирован deny),
после CI ✅; замечания → fix subagent → re-review → merge. после 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 модель делегирования. См. `AGENTS.md` (Development Workflow) и `run-pipeline` skill — все три документа описывают одну и ту же full-subagent модель делегирования.

View file

@ -1,6 +1,6 @@
--- ---
name: memory name: memory
description: Instructions for opencode-memory file-based memory system (search + save + retro). Also when user says "память", "memory", "запомни", "найди в памяти". description: Инструкция по работе с файловой памятью opencode-memory (search + save + retro).
--- ---
# File Memory (opencode-memory) # File Memory (opencode-memory)
@ -102,20 +102,9 @@ Default: `/root/.local/share/opencode/opencode-memory` (переопределя
Не копируй содержание ADR — только указатель на файл. Не копируй содержание ADR — только указатель на файл.
### Править вместо дублирования (ОБЯЗАТЕЛЬНО) ### Править вместо дублирования
Перед добавлением записи — прочитай существующий файл. Если факт уже записан — обнови запись (bump `updated` в frontmatter, дополни детали если нужно). **НЕ создавай дубликаты.** Дубликаты раздули файлы до 600+ KB. Каждая гоча/паттерн/root cause = одна запись, не по одной на каждый PR где упоминалась. Если факт уже записан — обнови запись (bump `updated` в frontmatter). Не создавай дубликаты.
### Ротация файлов (50 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`.
### Квитанция ставится всегда ### Квитанция ставится всегда

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

@ -1,6 +1,6 @@
--- ---
name: python-development name: python-development
description: Python-specific standards: imports, logging, error handling, tests. Use alongside code-standards for Python projects. Also when user says "python", "питон", "python开发". description: Python-специфика: импорты, логирование, обработка ошибок, тесты. Используй вместе с code-standards для Python-проектов.
--- ---
# Python Development # Python Development

View file

@ -1,6 +1,6 @@
--- ---
name: release name: release
description: Performs a release after PR merge — updates CHANGELOG, creates git tag and GitHub Release. Use when the user says "сделай релиз", "выпусти версию", "опубликуй", "release", "затегай". description: Выполняет релиз после мерджа PR — обновляет CHANGELOG, создаёт git tag и GitHub Release. Используй когда пользователь говорит "сделай релиз", "выпусти версию", "опубликуй", "release", "затегай". Also when user says "сделай релиз", "выпусти версию".
--- ---
## Релиз ## Релиз

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

@ -1,323 +0,0 @@
---
name: repo-readme
description: Use when creating or updating README.md for any slaid098 repository. Calls create-readme tool for deterministic structure with delimiter tags for slaid098.dev showcase. Also when user says "оформи README", "обнови описание репо", "readme template", "базовая структура readme".
---
# Repo README
Стандартизация `README.md` во всех репозиториях slaid098 через тулзу
`create-readme`. Тулза гарантирует структуру (разделители, Features table,
Support block, Quick Start, language switcher). Скилл даёт контекст: когда
вызывать тулзу и какие параметры передавать.
## 1. Когда использовать тулзу
- **Новый репо**`create-readme` (mode: `create`) — генерирует
стандартизированный двуязычный README с нуля.
- **Проверка существующего README**`.opencode/scripts/project-status.py`
(`check_readme`) — проверяет, что структура соответствует стандарту витрины.
- **После ручных правок README** → всегда проверка через `check_readme`. Любая правка руками
агента (через Edit/Write) может нарушить разделители — после правок
обязательна проверка.
Не генерируй README вручную через Write — структура критична для парсинга
витриной. Только через тулзу `create-readme`.
## 2. GitHub metadata (отдельный шаг, НЕ в тулзе)
`create-readme` отвечает только за файл `README.md`. Метаданные репозитория
настраиваются отдельно через `gh repo edit` (это bash, не тулза):
- Описание: `gh repo edit --description "короткое описание"`
- Topics для поиска: `gh repo edit --add-topic topic1 --add-topic topic2`
- Social preview image — через настройки GitHub UI (не CLI).
Метаданные не дублируют README — они для карточки репо на GitHub и поиска.
## 3. Workflow
1. `create-readme` (mode: `create`) → генерирует README с гарантированной
структурой (включая `![Cover](assets/cover.png)` после H1).
2. `draw-image` (template `"cover"`, slots по типу репо, `title`, `subtitle`,
`out: "./assets/cover.png"`) → рендерит cover-изображение (1024×1024 PNG) на
место, на которое ссылается README. Default `out` у `draw-image` уже
`"./assets/cover.png"` — можно не передавать.
3. Ручные правки если нужно (агент редактирует файл напрямую через Edit) —
например, расширить `custom_sections`, поправить формулировки.
4. Проверка через `.opencode/scripts/project-status.py` (`check_readme`) →
структура соответствует стандарту витрины (в т.ч. наличие
`assets/cover.png` reference).
5. Если проверка fails → фикс нарушения → повторная проверка. Цикл пока не
пройдёт.
Локальный режим (по умолчанию): тулза пишет в `file_path` (default
`README.md`) через `fs.writeFileSync`, путь резолвится относительно
рабочей директории сессии (`context.worktree`) — существующий файл
перезаписывается. Удалённый режим: передай `repo`
(`owner/name`) — тулза сделает PUT через `gh api
repos/{owner}/{repo}/contents/README.md` с base64-контентом и SHA.
### Slot-выбор для cover
`draw-image` template `cover` имеет 3 опциональных слота. Значение slot —
либо имя Lucide-иконки (резолвится из `icons/lucide/<name>.svg`), либо имя
brand-logo (резолвится из `brand-logos/<name>.svg`), либо путь к файлу
(`./...` / `/...` / `../...`).
- `icon` — основная иконка (400×400, slot recolor=accent). Для репо,
ассоциированных с продуктом/брендом — brand-logo (напр. `opencode`). Для
утилит/SDK/скриптов — Lucide (напр. `square-terminal`, `code-xml`,
`brain-circuit`).
- `sub-icon` — опциональная вторая иконка (200×200, recolor=accent). Lucide
(напр. `git-branch`, `bot`, `terminal`).
- `badge` — опциональная третья иконка (180×180, bg=surface, border=accent,
radius=0.5). Lucide или путь к файлу (напр. логотип-плашка).
Cover сохраняется в `assets/cover.png` (default `draw-image` out path).
README ссылается именно на этот путь через `![Cover](assets/cover.png)`.
## 4. Зачем разделители (контекст для агента)
Витрина **slaid098.dev** скачивает raw `README.md` из каждого репо и
извлекает фрагменты между разделителями:
- `<!-- tagline-en:start -->` ... `<!-- tagline-en:end -->` — английский
tagline (короткая фраза для карточки).
- `<!-- tagline-ru:start -->` ... `<!-- tagline-ru:end -->` — русский
tagline.
- `<!-- summary-en:start -->` ... `<!-- summary-en:end -->` — английский
блок Why/What для карточки.
- `<!-- features-en:start -->` ... `<!-- features-en:end -->` — английская
таблица фич.
- `<!-- summary-ru:start -->` ... `<!-- summary-ru:end -->` — русский блок
Зачем/Что.
- `<!-- features-ru:start -->` ... `<!-- features-ru:end -->` — русская
таблица фич.
Поэтому структура README **должна быть гарантирована тулзой**, а не агентом.
Агент может менять текст *внутри* разделителей, но не должен удалять/двигать
сами разделители. `check_readme` ловит такие нарушения.
## 5. Шаблон README (reference)
Тулза `create-readme` генерирует ровно эту структуру (параметры подставляются):
```markdown
# 🚀 {repo_name}
![Cover](assets/cover.png)
<!-- tagline-en:start -->
> {tagline_en}
<!-- tagline-en:end -->
<!-- tagline-ru:start -->
> {tagline_ru}
<!-- tagline-ru:end -->
[English](#-english) | [Русский](#-русский)
---
## 🇺🇸 English
<!-- summary-en:start -->
### ❓ Why
{why_en}
### ✅ What
{what_en}
<!-- summary-en:end -->
<!-- features-en:start -->
### Features
| Feature | Description |
|---------|-------------|
| {emoji} {name} | {description} |
...
<!-- features-en:end -->
{custom_sections_en — доп. секции, если переданы}
### ⚡ Quick Start
\`\`\`bash
{git clone строка, если include_clone !== false}
{quick_start}
\`\`\`
{quick_start_steps_en — нумерованный список кликабельных шагов, если передан: 1. ... 2. ...}
{access_url строка если передан — Access at [url](url), кликабельна}
{development_en блок, если передан — ### 🔧 Development + content}
---
## 🇷🇺 Русский
<!-- summary-ru:start -->
### ❓ Зачем
{why_ru}
### ✅ Что
{what_ru}
<!-- summary-ru:end -->
<!-- features-ru:start -->
### Фичи
| Фича | Описание |
|------|----------|
| {emoji} {name} | {description} |
...
<!-- features-ru:end -->
{custom_sections_ru — доп. секции, если переданы}
### ⚡ Быстрый старт
\`\`\`bash
{git clone строка, если include_clone !== false}
{quick_start}
\`\`\`
{quick_start_steps_ru — нумерованный список кликабельных шагов, если передан: 1. ... 2. ...}
{access_url строка если передан — Доступ: [url](url), кликабельна}
{development_ru блок, если передан — ### 🔧 Разработка + content}
---
## 💬 Support and contacts / Поддержка и контакты
👉 **[slaid098.dev/contacts](https://slaid098.dev/contacts)**
```
`check_readme` (project-status.py) проверяет: наличие всех 6 пар EN/RU
разделителей (tagline + summary + features), непустой контент между ними, H1
title prefix `# 🚀 `, **cover image
reference `assets/cover.png`** (substring-чек, без проверки существования
файла), ссылку
`slaid098.dev/contacts`, секции Quick Start (EN) и Быстрый старт (RU), language
switcher `[English]` / `[Русский]`, заголовок `## 🇷🇺 Русский` (не "Русская
версия"), anchor `[Русский](#-русский)` (не `#-русская-версия`). Флагирует
ручной заголовок `## License` / `## LICENSE` / `## Лицензия` как FAIL —
дубликат GitHub sidebar (GitHub рендерит license из LICENSE-файла). Шаги
`quick_start_steps_*` не влияют на валидацию — они рендерятся вне delimiter-пар
(summary/features).
## 6. Независимость от project-template
- Скилл `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).
## 7. Параметры тулзы (кратко)
`create-readme`:
- `mode``"create"` (обязательный).
- `repo_name`, `tagline_en`, `tagline_ru`, `why_en`, `what_en`, `why_ru`,
`what_ru`, `quick_start`, `features_en`, `features_ru` — обязательны для
`create`.
- `repo_name` — должен быть **lowercase kebab-case** (regex
`^[a-z0-9]+(-[a-z0-9]+)*$`): только `a-z`, `0-9`, одиночные дефисы. Uppercase,
underscores, пробелы, leading/trailing/consecutive dashes — отвергаются.
- `tagline_en` — короткий английский tagline (1 предложение). **Не должен
содержать кириллицы** (валидируется regex `/[ЁА-яё]/`).
- `tagline_ru` — короткий русский tagline (1 предложение). **Должен содержать
кириллицу** (валидируется regex `/[ЁА-яё]/`). Гарантирует, что русский
tagline реально на русском, а не копия английского.
- `features_en` / `features_ru` — массив `{ emoji, name, description }[]`.
- `custom_sections_en` / `custom_sections_ru` — массивы
`{ title, content }` (optional).
- `access_url` — URL для Access/Доступ строки после Quick Start bash-блока
(optional). EN: `Access at {url}`, RU: `Доступ: {url}`. Omit if no web access.
Рендерится как markdown-ссылка `[url](url)` — кликабельна на GitHub.
- `include_clone` — boolean (optional, default true). `false` убирает `git clone`
из Quick Start. Для userscript, web-app, npm-package.
- `quick_start_steps_en` / `quick_start_steps_ru` — массивы raw-markdown строк
(optional). Каждая строка = один шаг, может содержать markdown-ссылки
`[text](url)`. Рендерятся как нумерованный список `1. ... 2. ...` ПОСЛЕ
bash-блока, ДО `access_url`. Кликабельные шаги для setup, где установка — это
не одна команда, а несколько ссылок (установить userscript, получить API-ключ,
настроить). Если массив пуст/не передан — шаги не рендерятся (backward compat).
- `development_en` — raw markdown (optional). `### 🔧 Development` после EN
Quick Start, вне delimiter-тегов (не на slaid098.dev).
- `development_ru` — raw markdown (optional). `### 🔧 Разработка` после RU
Быстрый старт, вне delimiter-тегов (не на slaid098.dev).
- `repo``owner/name` для удалённой операции (optional).
- `file_path` — локальный путь (default `README.md`).
## 8. Кейс: userscript / web-app / npm-package
Для репо без клонирования (userscript, web-app с demo URL, npm-package):
- `include_clone: false` — убирает `git clone` из Quick Start
- `quick_start` — команда установки (npm install, pip install, или ссылка на
установку userscript). Если установка — это несколько ссылок (а не одна
команда), лучше использовать `quick_start_steps_*` вместо/вместе с bash-блоком.
- `quick_start_steps_en` / `quick_start_steps_ru` — кликабельные шаги setup
(рекомендуется для userscript/multi-step setup): установить userscript,
получить API-ключ, настроить. Каждая строка может содержать `[text](url)`.
Рендерятся как нумерованный список ПОСЛЕ bash-блока.
- `access_url` — URL web-доступа (если есть), рендерится как `[url](url)`.
- `development_en` / `development_ru` — инструкции для разработчиков (как
собрать, как контрибьютить), рендерятся после Quick Start, вне
delimiter-тегов (не на slaid098.dev)
### Пример: userscript со шагами-ссылками
```json
{
"mode": "create",
"repo_name": "my-userscript",
"tagline_en": "One-line tagline.",
"tagline_ru": "Короткий теглайн.",
"why_en": "Why this exists.",
"what_en": "What it does.",
"why_ru": "Зачем этот проект.",
"what_ru": "Что делает.",
"quick_start": "",
"include_clone": false,
"features_en": [{ "emoji": "⚡", "name": "Fast", "description": "Instant setup" }],
"features_ru": [{ "emoji": "⚡", "name": "Быстрый", "description": "Мгновенный старт" }],
"access_url": "http://localhost:4096",
"quick_start_steps_en": [
"Install the [userscript](https://greasyfork.org/...)",
"Get a [Groq API key](https://console.groq.com/keys)",
"Configure [settings](https://example.com/settings)"
],
"quick_start_steps_ru": [
"Установи [юзерскрипт](https://greasyfork.org/...)",
"Получи [ключ Groq](https://console.groq.com/keys)",
"Настрой [параметры](https://example.com/settings)"
]
}
```
Результат (EN секция):
```markdown
### ⚡ Quick Start
1. Install the [userscript](https://greasyfork.org/...)
2. Get a [Groq API key](https://console.groq.com/keys)
3. Configure [settings](https://example.com/settings)
Access at [http://localhost:4096](http://localhost:4096)
```
`include_clone: false` + `quick_start: ""` → bash-блок не рендерится (только
шаги). Если `quick_start` непустой — bash-блок рендерится перед шагами.
## 9. Breaking change: tagline → tagline_en + tagline_ru
Параметр `tagline` **удалён** (PR #150). Раньше был один английский tagline;
теперь два — `tagline_en` (EN) и `tagline_ru` (RU) — оба обязательны для
`create`. Витрина slaid098.dev парсит оба через delimiter-теги
`<!-- tagline-en:start/end -->` и `<!-- tagline-ru:start/end -->`.
**Миграция:** замени `"tagline": "..."` на `"tagline_en": "..."` +
`"tagline_ru": "..."`. Старые вызовы с `tagline` падают с
`❌ tagline_en is required for create mode`.
**Существующие README** (без tagline delimiter-тегов) станут invalid при
проверке `check_readme``Missing <!-- tagline-en:start --> delimiter` и
`Missing <!-- tagline-ru:start --> delimiter`. Регенерация README через
`create` (с новыми параметрами) делается отдельным шагом после merge.

View file

@ -1,11 +1,11 @@
--- ---
name: run-pipeline 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: Автономный исполнитель PR-пайплайна. Делегирует 7 фаз subagent'ам, не импровизирует порядок, не мержит при красном CI.
--- ---
# Run Pipeline # Run Pipeline
Автономная процедура-loop для проведения PR через 6 фаз. Source of truth для Автономная процедура-loop для проведения PR через 7 фаз. Source of truth для
порядка и действий — `pipeline-status` tool. NEXT action из tool явно порядка и действий — `pipeline-status` tool. NEXT action из tool явно
указывает `subagent_type` + `template` (кроме MERGE — `merge-pr` 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>`. 1. Вызови tool `pipeline-status({pr_number: M})` — вернёт статус всех фаз + строку `NEXT: <action>`.
2. Если вывод содержит `Status: COMPLETE` → финальный репорт пользователю, exit. 2. Если вывод содержит `Status: COMPLETE` → финальный репорт пользователю, exit.
3. Если вывод содержит `AMBIGUOUS` → репорт пользователю с причиной, STOP. 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>`). 5. 1 строка прогресса пользователю (формат: `✅ <phase> — <action executed>`).
6. Re-loop (шаг 1). 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. 1. Checkout new branch `type/scope/kebab-description` от master.
2. Реализуй по спеке issue (точно, без отклонений). Если спека содержит ошибки, 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 raw `git commit` — заблокирован deny). Формат: ≤72 chars, English, no
period, no body unless necessary. Минимум 3-4 логических коммита. period, no body unless necessary. Минимум 3-4 логических коммита.
Перед КАЖДЫМ `commit``git status` для проверки staged set. `commit` Перед КАЖДЫМ `commit``git status` для проверки staged set. `commit`
@ -53,13 +56,42 @@ description: Autonomous PR pipeline executor. Delegates 6 phases to subagents, d
`git add <конкретные-пути>`, НЕ `git add -A` (иначе лишние файлы уйдут в `git add <конкретные-пути>`, НЕ `git add -A` (иначе лишние файлы уйдут в
коммит). Если в индексе лишнее (например `memory-save` stage'нул всё коммит). Если в индексе лишнее (например `memory-save` stage'нул всё
через `git add -A`) — не коммить: сначала `git restore --staged <file>`. через `git add -A`) — не коммить: сначала `git restore --staged <file>`.
4. Push ветку (`git push -u origin HEAD`), затем создай PR через tool: 5. 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 })`. `create-pr({ title: "type(scope): description", body: "## Что сделано\n...\n\n## Почему\n...\n\nCloses #N", issue_number: N })`.
PR body ОБЯЗАТЕЛЬНО содержит 4 heading'а: `## Что сделано`, `## Почему`, 6. После получения PR номера — исправь placeholder `<PR-NUMBER>` в handoff
`## Watch out`, `## Pending`. Заполни осмысленно (для Watch out/Pending frontmatter, отдельный коммит `docs(handoff): set PR number` через
можно `—` если реально нет контента). `commit` tool, push.
5. Верни PR номер M. 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).
``` ```
### Template C (code_review) ### Template C (code_review)
@ -67,11 +99,11 @@ description: Autonomous PR pipeline executor. Delegates 6 phases to subagents, d
``` ```
Review PR#M в текущем репо. Review PR#M в текущем репо.
1. `gh pr view M --json headRefName,body,title`. 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"`, грузи каждый 3. Load project skills: `find .opencode/skills/ -name "SKILL.md"`, грузи каждый
через `skill("<name>")`. через `skill("<name>")`.
4. Проверь: code quality, architecture, error handling, security, testing, 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` 5. Оставь review через tool `post-review` (НЕ `gh pr review --approve`
GitHub блокирует self-approve; НЕ raw bash-вызов `gh`) — tool гарантирует GitHub блокирует self-approve; НЕ raw bash-вызов `gh`) — tool гарантирует
heading `## Code Review Summary` + verdict-enum которые парсит heading `## Code Review Summary` + verdict-enum которые парсит
@ -81,7 +113,6 @@ Review PR#M в текущем репо.
вручную. Если tool вернул строку начинающуюся с `⚠️ ...failed` — СООБЩИ вручную. Если tool вернул строку начинающуюся с `⚠️ ...failed` — СООБЩИ
оркестратору о сбое и STOP (не fallback на raw bash). оркестратору о сбое и STOP (не fallback на raw bash).
6. НЕ МЕРДЖИТЬ — merge делает основной агент через run-pipeline. 6. НЕ МЕРДЖИТЬ — merge делает основной агент через run-pipeline.
Если найдёшь баг вне scope текущей задачи — загрузи skill `bug-discovery` через `skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам. Сообщи оркестратору: "Created issue #N: ...".
``` ```
### Template D (fix_ci) ### Template D (fix_ci)
@ -99,7 +130,6 @@ Log: `gh run view <run-id> --log-failed` output:
`commit({ message: "fix(ci): <description>" })` tool (НЕ raw `commit({ message: "fix(ci): <description>" })` tool (НЕ raw
`git commit`), push. `git commit`), push.
5. Не трогай логику unrelated файлов. 5. Не трогай логику unrelated файлов.
Если найдёшь баг вне scope текущей задачи — загрузи skill `bug-discovery` через `skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам. Сообщи оркестратору: "Created issue #N: ...".
``` ```
### Template E (memory_sync) ### Template E (memory_sync)
@ -110,20 +140,16 @@ Log: `gh run view <run-id> --log-failed` output:
(default `~/.local/share/opencode/opencode-memory`, override через `OPENCODE_MEMORY_DIR`; (default `~/.local/share/opencode/opencode-memory`, override через `OPENCODE_MEMORY_DIR`;
`{host}/{org}/{repo}` вычисли через `{host}/{org}/{repo}` вычисли через
`git remote get-url origin` — см. `memory-syncer.md:38`). `git remote get-url origin` — см. `memory-syncer.md:38`).
1. Прочитай PR body через `gh pr view M --json body,title` (+ `gh issue view` 1. Прочитай `docs/handoff/pr-M-*.md` и `docs/decisions/*-pr-M-*.md` с master
для контекста issue, если PR ссылается на issue). Текущее состояние — уже (`git checkout master && git pull`).
смерженный default branch (checkout/pull НЕ нужны, запрещены permission
set'ом memory-syncer'а).
2. Найди durable gotchas (не статусы, не "сейчас делаем"). Паттерны, указатели, 2. Найди durable gotchas (не статусы, не "сейчас делаем"). Паттерны, указатели,
non-obvious API quirks. Источник: PR body `## Watch out` (gotchas) + non-obvious API quirks.
`## Pending` (follow-ups) + diff (`git diff` текущего state vs parent).
3. Добавь записи формата `- [YYYY-MM-DD, PR#M] <summary>` в конец файла 3. Добавь записи формата `- [YYYY-MM-DD, PR#M] <summary>` в конец файла
(секция "Handoff digest"). (секция "Handoff digest").
4. Квитанция ВСЕГДА (даже если durable нет): `- [date, PR#M] — (нет durable-записей)`. 4. Квитанция ВСЕГДА (даже если durable нет): `- [date, PR#M] — (нет durable-записей)`.
5. `memory-save` для commit + reindex + push. 5. `memory-save` для commit + reindex + push.
6. Проверь `git status` основного репо — если staged что-то в memory dir, 6. Проверь `git status` основного репо — если staged что-то в memory dir,
репорт пользователю (guard от случайного коммита в master). репорт пользователю (guard от случайного коммита в master).
Если найдёшь баг вне scope текущей задачи — загрузи skill `bug-discovery` через `skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам. Сообщи оркестратору: "Created issue #N: ...".
``` ```
### Template F (merge) ### Template F (merge)
@ -139,7 +165,7 @@ merge-pr({ pr_number: M })
Если вернулась `⚠️ merge-pr failed ...` → репорт пользователю, STOP (НЕ retry Если вернулась `⚠️ merge-pr failed ...` → репорт пользователю, STOP (НЕ retry
через raw bash — это нарушит orchestrator-контракт). Если `PR #M merged через raw bash — это нарушит orchestrator-контракт). Если `PR #M merged
successfully ...` → 1 строка прогресса и re-loop (`pipeline-status` покажет successfully ...` → 1 строка прогресса и re-loop (`pipeline-status` покажет
`Status: COMPLETE` или перейдёт на MEMORY phase — 6-я фаза). `Status: COMPLETE` или перейдёт на MEMORY phase).
## API Restrictions ## API Restrictions

View file

@ -1,6 +1,6 @@
--- ---
name: run-tests name: run-tests
description: Use when the user asks to run tests, verify code works, fix errors after edits, or run pytest. Also when user says "запусти тесты", "run tests", "проверь код", "pytest". description: Используй этот навык, когда пользователь просит запустить тесты, проверить работоспособность кода, исправить ошибки после правок или запустить pytest.
--- ---
# Навык запуска тестов и исправления ошибок через Pytest # Навык запуска тестов и исправления ошибок через Pytest

View file

@ -1,6 +1,6 @@
--- ---
name: spec name: spec
description: Autonomous spec generation executor for new projects. Deterministically guides the agent through 9 phases via spec-status tool. Main agent is orchestrator, delegates ALL work to subagents. Also when user says "создай спеку", "новый проект", "спецификация проекта", "spec", "project spec". description: Автономный исполнитель spec-генерации для нового проекта. Детерминированно ведёт агента по 9 фазам через spec-status tool. Главный агент — оркестратор, делегирует ВСЮ работу subagent'ам. Also when user says "создай спеку", "новый проект", "спецификация проекта", "spec", "project spec".
--- ---
# Spec # Spec
@ -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. Общий для всех типов: 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`) - **backend**: FastAPI + uvicorn, Tortoise ORM + Aerich, pydantic-settings, Loguru
- **fullstack**: backend + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip + mobile-first (PWA + axe + Playwright mobile)) - **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 - **mcp-server**: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
- **cli**: Typer (default) / click / argparse, hatchling build - **cli**: Typer (default) / click / argparse, hatchling build
- **bot**: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.) - **bot**: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
- **worker**: Prefect flows + tasks, prefect.yaml, docker-compose worker profile - **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 фаз ## 9 фаз
### Phase 0: DETECT (subagent, без вопроса юзеру) ### Phase 0: DETECT (subagent, без вопроса юзеру)
@ -65,7 +61,7 @@ Prompt template A (см. ниже).
``` ```
Выбери тип проекта: Выбери тип проекта:
[1] backend — FastAPI + Tortoise, REST API, без frontend [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) [3] mcp-server — MCP + REST сервер (Patchright/Playwright over CDP)
[4] cli — Python CLI tool (Typer) [4] cli — Python CLI tool (Typer)
[5] bot — Telegram bot (aiogram 3) [5] bot — Telegram bot (aiogram 3)
@ -88,7 +84,7 @@ backend:
- Auth: [1] none v1 / [2] JWT / [3] X-API-Key - Auth: [1] none v1 / [2] JWT / [3] X-API-Key
fullstack: 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) - DB: (same as backend)
- Auth: (same as backend) - Auth: (same as backend)
@ -160,7 +156,7 @@ Prompt template F (см. ниже).
``` ```
Дефолтный roadmap (можешь править): Дефолтный 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> — ... 2. core: <module 1> — ...
3. core: <module 2> — ... 3. core: <module 2> — ...
4. auth (если выбран auth в Phase 2) 4. auth (если выбран auth в Phase 2)
@ -235,8 +231,8 @@ Spec complete. Issues: #N1, #N2, ...
Тип проекта: <type> (из frontmatter meta.md). Тип проекта: <type> (из frontmatter meta.md).
Default stack для типа (хардкод, добавить всегда): Default stack для типа (хардкод, добавить всегда):
- Общий: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI - Общий: 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`) - backend: FastAPI + uvicorn, Tortoise ORM + Aerich, pydantic-settings, Loguru
- fullstack: + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip + mobile-first (PWA + axe + Playwright mobile)) - fullstack: + frontend/ (React 19 + Vite + Biome + TS strict + Vitest + Knip + happy-dom)
- mcp-server: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key - mcp-server: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
- cli: Typer (default) / click / argparse, hatchling build - cli: Typer (default) / click / argparse, hatchling build
- bot: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.) - bot: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
@ -307,17 +303,11 @@ Default stack для типа (хардкод, добавить всегда):
3. Для каждого пункта roadmap (по порядку): 3. Для каждого пункта roadmap (по порядку):
- Сформируй самодостаточный issue body (issue-skill format): - Сформируй самодостаточный issue body (issue-skill format):
## Контекст ## Контекст
(Зачем: мотивация; Контекст: текущее состояние) ## Что сделать (пошагово с путями к файлам)
## Задача (пошагово с путями к файлам) ## Проверка (команды)
## Контракты (ожидаемое поведение / API)
## Инварианты (правила без исключений)
## Граничные случаи (что при ошибках)
## Влияние на связанные компоненты (зависящие файлы/оракулы/агенты/промпты/валидаторы; paired updates; «нет связанных компонентов» для тривиальных задач)
## Вне scope (что НЕ делаем)
## Критерии приемки (как проверяем)
## Связанные ресурсы (Part of spec, ref к docs/spec/roadmap.md) ## Связанные ресурсы (Part of spec, ref к docs/spec/roadmap.md)
- Issue #1 (scaffolding) body ДОЛЖЕН включать: - 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) - `create-issue({ title: "type(scope): description", body: "<body>", labels: ["enhancement", "from-spec"] })` tool (НЕ raw `gh issue create` — заблокирован deny)
4. Собери реальные номера issues из вывода tool. 4. Собери реальные номера issues из вывода tool.
5. Update docs/spec/roadmap.md: добавь реальные #N номера. Update docs/spec/meta.md: executed=true, phase=8. 5. Update docs/spec/roadmap.md: добавь реальные #N номера. Update docs/spec/meta.md: executed=true, phase=8.

View file

@ -1,6 +1,6 @@
--- ---
name: tunnel name: tunnel
description: Set up a Cloudflare tunnel when the user asks to expose a port or create a tunnel. Also when user says "подними тоннель", "пробрось порт", "tunnel". description: Подними cloudflare туннель когда пользователь просит "подними тоннель", "пробрось порт", "tunnel"
--- ---
# Tunnel # Tunnel

View file

@ -1 +0,0 @@
node_modules/

View file

@ -1,62 +0,0 @@
import { loadConfig } from "./src/config.ts"
import { sendMessage, sendDocument, sendPhoto } from "./src/api.ts"
function parseArgs(argv: string[]): { action: string; opts: Record<string, string> } {
const action = argv[0] ?? ""
const opts: Record<string, string> = {}
for (let i = 1; i < argv.length; i++) {
const a = argv[i]
if (a.startsWith("--")) {
const key = a.slice(2)
const val = argv[i + 1] ?? ""
opts[key] = val
i++
}
}
return { action, opts }
}
async function main(): Promise<void> {
const argv = process.argv.slice(2)
if (argv.length < 1) {
process.stderr.write('usage: node cli.ts <text|document|photo> --text "..." [--chat-id ...] [--path ...] [--caption ...] [--parse-mode MarkdownV2|HTML|plain]\n')
process.exit(2)
}
const { action, opts } = parseArgs(argv)
const chatIdOverride = opts["chat-id"] ? { chatId: opts["chat-id"] } : undefined
const { token, chatId } = loadConfig(chatIdOverride)
const parseMode = (opts["parse-mode"] ?? "MarkdownV2") as "MarkdownV2" | "HTML" | "plain"
let result
if (action === "text") {
if (!opts.text) {
process.stderr.write('error: --text is required for action "text"\n')
process.exit(2)
}
result = await sendMessage(token, chatId, opts.text, parseMode)
} else if (action === "document") {
if (!opts.path) {
process.stderr.write('error: --path is required for action "document"\n')
process.exit(2)
}
result = await sendDocument(token, chatId, opts.path, opts.caption, parseMode)
} else if (action === "photo") {
if (!opts.path) {
process.stderr.write('error: --path is required for action "photo"\n')
process.exit(2)
}
result = await sendPhoto(token, chatId, opts.path, opts.caption, parseMode)
} else {
process.stderr.write(`error: unknown action "${action}" (expected: text|document|photo)\n`)
process.exit(2)
}
console.log(JSON.stringify(result))
}
main().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err)
process.stderr.write(`⚠️ telegram-send failed (exit 1): ${msg}\n`)
process.exit(1)
})

File diff suppressed because it is too large Load diff

View file

@ -1,15 +0,0 @@
{
"name": "telegram",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Telegram Bot API client for sending messages, documents, and photos",
"scripts": {
"test": "vitest run"
},
"devDependencies": {
"@types/node": "^26.1.1",
"typescript": "^7.0.2",
"vitest": "^3.2.4"
}
}

View file

@ -1,87 +0,0 @@
import { readFileSync } from "node:fs"
import { basename } from "node:path"
export interface TelegramResult {
ok: true
message_id: number
chat_id: string
}
type ParseMode = "MarkdownV2" | "HTML" | "plain"
const API_BASE = "https://api.telegram.org/bot"
async function telegramFetch(
token: string,
method: string,
body: BodyInit,
headers?: Record<string, string>,
): Promise<TelegramResult> {
const url = `${API_BASE}${token}/${method}`
const res = await fetch(url, { method: "POST", body, headers })
const data = await res.json() as { ok: boolean; description?: string; result?: { message_id: number; chat: { id: number | string } } }
if (!data.ok) {
throw new Error(data.description ?? `Telegram API error (HTTP ${res.status})`)
}
if (!data.result) {
throw new Error("Telegram API returned no result")
}
return {
ok: true,
message_id: data.result.message_id,
chat_id: String(data.result.chat.id),
}
}
function shouldIncludeParseMode(parseMode?: ParseMode): parseMode is "MarkdownV2" | "HTML" {
return parseMode === "MarkdownV2" || parseMode === "HTML"
}
export async function sendMessage(
token: string,
chatId: string,
text: string,
parseMode?: ParseMode,
): Promise<TelegramResult> {
const body: Record<string, string> = { chat_id: chatId, text }
if (shouldIncludeParseMode(parseMode)) {
body.parse_mode = parseMode
}
return telegramFetch(token, "sendMessage", JSON.stringify(body), {
"Content-Type": "application/json",
})
}
export async function sendDocument(
token: string,
chatId: string,
filePath: string,
caption?: string,
parseMode?: ParseMode,
): Promise<TelegramResult> {
const buf = readFileSync(filePath)
const blob = new Blob([buf])
const form = new FormData()
form.append("chat_id", chatId)
form.append("document", blob, basename(filePath))
if (caption) form.append("caption", caption)
if (shouldIncludeParseMode(parseMode)) form.append("parse_mode", parseMode)
return telegramFetch(token, "sendDocument", form)
}
export async function sendPhoto(
token: string,
chatId: string,
filePath: string,
caption?: string,
parseMode?: ParseMode,
): Promise<TelegramResult> {
const buf = readFileSync(filePath)
const blob = new Blob([buf])
const form = new FormData()
form.append("chat_id", chatId)
form.append("photo", blob, basename(filePath))
if (caption) form.append("caption", caption)
if (shouldIncludeParseMode(parseMode)) form.append("parse_mode", parseMode)
return telegramFetch(token, "sendPhoto", form)
}

View file

@ -1,18 +0,0 @@
export interface TelegramConfig {
token: string
chatId: string
}
export function loadConfig(overrides?: { chatId?: string }): TelegramConfig {
const token = process.env.TELEGRAM_BOT_TOKEN
if (!token) {
throw new Error("TELEGRAM_BOT_TOKEN env var is required (see .env.example)")
}
const chatId = overrides?.chatId ?? process.env.TELEGRAM_CHAT_ID
if (!chatId) {
throw new Error("chat_id is required: pass --chat-id or set TELEGRAM_CHAT_ID env var")
}
return { token, chatId }
}

View file

@ -1,15 +0,0 @@
export const MARKDOWN_V2_SPECIAL_CHARS = [
"_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!",
] as const
export function escapeMarkdownV2(text: string): string {
let out = ""
for (const ch of text) {
if ((MARKDOWN_V2_SPECIAL_CHARS as readonly string[]).includes(ch)) {
out += "\\" + ch
} else {
out += ch
}
}
return out
}

View file

@ -1,130 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi, beforeAll, afterAll } from "vitest"
import { writeFileSync, unlinkSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { sendMessage, sendDocument, sendPhoto } from "../src/api.ts"
const TOKEN = "TESTOKEN"
const CHAT = "123"
const TMP_DOC = join(tmpdir(), "tg-test-doc.txt")
const TMP_PHOTO = join(tmpdir(), "tg-test-photo.png")
function okResponse(result: unknown) {
const payload = { ok: true as const, result }
return { ...payload, json: async () => payload }
}
beforeAll(() => {
writeFileSync(TMP_DOC, "hello document")
writeFileSync(TMP_PHOTO, "fake-png-bytes")
})
afterAll(() => {
unlinkSync(TMP_DOC)
unlinkSync(TMP_PHOTO)
})
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe("sendMessage", () => {
it("POSTs to sendMessage with chat_id, text; parse_mode omitted when undefined", async () => {
vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 42, chat: { id: 999 } }) as never)
await sendMessage(TOKEN, CHAT, "hi")
const [url, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/sendMessage`)
expect(opts.method).toBe("POST")
expect((opts.headers as Record<string, string>)["Content-Type"]).toBe("application/json")
const body = JSON.parse(opts.body as string)
expect(body).toEqual({ chat_id: CHAT, text: "hi" })
})
it("POSTs to sendMessage with parse_mode=MarkdownV2 when explicitly passed", async () => {
vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 1, chat: { id: 1 } }) as never)
await sendMessage(TOKEN, CHAT, "hi", "MarkdownV2")
const [, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
const body = JSON.parse(opts.body as string)
expect(body).toEqual({ chat_id: CHAT, text: "hi", parse_mode: "MarkdownV2" })
})
it("parseMode='plain' omits parse_mode from body", async () => {
vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 1, chat: { id: 1 } }) as never)
await sendMessage(TOKEN, CHAT, "hi", "plain")
const [, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
const body = JSON.parse(opts.body as string)
expect(body).toEqual({ chat_id: CHAT, text: "hi" })
expect(body).not.toHaveProperty("parse_mode")
})
it("parseMode='HTML' sets parse_mode=HTML", async () => {
vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 1, chat: { id: 1 } }) as never)
await sendMessage(TOKEN, CHAT, "hi", "HTML")
const [, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
const body = JSON.parse(opts.body as string)
expect(body.parse_mode).toBe("HTML")
})
it("returns {ok, message_id, chat_id} on success", async () => {
vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 42, chat: { id: 999 } }) as never)
const r = await sendMessage(TOKEN, CHAT, "hi")
expect(r).toEqual({ ok: true, message_id: 42, chat_id: "999" })
})
it("throws on Telegram API error (ok:false)", async () => {
const errPayload = { ok: false, description: "Unauthorized" }
vi.mocked(fetch).mockResolvedValue({ ...errPayload, json: async () => errPayload } as never)
await expect(sendMessage(TOKEN, CHAT, "hi")).rejects.toThrow(/Unauthorized/)
})
it("throws on network error (fetch rejects)", async () => {
vi.mocked(fetch).mockRejectedValue(new Error("network"))
await expect(sendMessage(TOKEN, CHAT, "hi")).rejects.toThrow(/network/)
})
})
describe("sendDocument", () => {
it("POSTs to sendDocument with FormData (chat_id, document, caption)", async () => {
vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 7, chat: { id: CHAT } }) as never)
const appendSpy = vi.spyOn(FormData.prototype, "append")
await sendDocument(TOKEN, CHAT, TMP_DOC, "cap", "MarkdownV2")
const [url, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/sendDocument`)
expect(opts.method).toBe("POST")
const form = opts.body as FormData
expect(form.get("chat_id")).toBe(CHAT)
expect(form.get("caption")).toBe("cap")
expect(form.get("parse_mode")).toBe("MarkdownV2")
const doc = form.get("document")
expect(doc).toBeInstanceOf(Blob)
expect((doc as File).name).toBe("tg-test-doc.txt")
expect(appendSpy).toHaveBeenCalledWith("chat_id", CHAT)
expect(appendSpy).toHaveBeenCalledWith("document", expect.any(Blob), "tg-test-doc.txt")
appendSpy.mockRestore()
})
})
describe("sendPhoto", () => {
it("POSTs to sendPhoto with FormData (chat_id, photo field)", async () => {
vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 9, chat: { id: CHAT } }) as never)
const appendSpy = vi.spyOn(FormData.prototype, "append")
await sendPhoto(TOKEN, CHAT, TMP_PHOTO, "cover", "HTML")
const [url, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/sendPhoto`)
expect(opts.method).toBe("POST")
const form = opts.body as FormData
expect(form.get("chat_id")).toBe(CHAT)
expect(form.get("caption")).toBe("cover")
expect(form.get("parse_mode")).toBe("HTML")
const photo = form.get("photo")
expect(photo).toBeInstanceOf(Blob)
expect((photo as File).name).toBe("tg-test-photo.png")
expect(appendSpy).toHaveBeenCalledWith("photo", expect.any(Blob), "tg-test-photo.png")
appendSpy.mockRestore()
})
})

View file

@ -1,54 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"
import { loadConfig } from "../src/config.ts"
describe("loadConfig", () => {
let savedToken: string | undefined
let savedChatId: string | undefined
beforeEach(() => {
savedToken = process.env.TELEGRAM_BOT_TOKEN
savedChatId = process.env.TELEGRAM_CHAT_ID
delete process.env.TELEGRAM_BOT_TOKEN
delete process.env.TELEGRAM_CHAT_ID
})
afterEach(() => {
if (savedToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN
} else {
process.env.TELEGRAM_BOT_TOKEN = savedToken
}
if (savedChatId === undefined) {
delete process.env.TELEGRAM_CHAT_ID
} else {
process.env.TELEGRAM_CHAT_ID = savedChatId
}
})
it("returns {token, chatId} when both env vars present", () => {
process.env.TELEGRAM_BOT_TOKEN = "tok"
process.env.TELEGRAM_CHAT_ID = "cid"
expect(loadConfig()).toEqual({ token: "tok", chatId: "cid" })
})
it("throws when token missing", () => {
process.env.TELEGRAM_CHAT_ID = "cid"
expect(() => loadConfig()).toThrow(/TELEGRAM_BOT_TOKEN/)
})
it("throws when chatId missing without override", () => {
process.env.TELEGRAM_BOT_TOKEN = "tok"
expect(() => loadConfig()).toThrow(/chat_id/)
})
it("override chatId takes priority over env", () => {
process.env.TELEGRAM_BOT_TOKEN = "tok"
process.env.TELEGRAM_CHAT_ID = "env-id"
expect(loadConfig({ chatId: "argv-id" })).toEqual({ token: "tok", chatId: "argv-id" })
})
it("override chatId works even when env chatId missing", () => {
process.env.TELEGRAM_BOT_TOKEN = "tok"
expect(loadConfig({ chatId: "argv-id" })).toEqual({ token: "tok", chatId: "argv-id" })
})
})

View file

@ -1,18 +0,0 @@
// Manual E2E test — run with:
// node --experimental-strip-types tests/e2e.manual.ts
// Requires real TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID env vars.
import { sendMessage, sendDocument, sendPhoto } from "../src/api.ts"
import { loadConfig } from "../src/config.ts"
async function main() {
const { token, chatId } = loadConfig()
console.log("[e2e] sending text to", chatId)
const r1 = await sendMessage(token, chatId, "E2E test: *bold* _italic_ `code`", "MarkdownV2")
console.log("[e2e] text sent:", r1)
// document + photo — раскомментируй и подставь пути к реальным файлам:
// const r2 = await sendDocument(token, chatId, "/path/to/file.md", "caption")
// console.log("[e2e] document sent:", r2)
// const r3 = await sendPhoto(token, chatId, "/path/to/cover.png", "cover")
// console.log("[e2e] photo sent:", r3)
}
main().catch((e) => { console.error("[e2e] failed:", e); process.exit(1) })

View file

@ -1,41 +0,0 @@
import { describe, it, expect } from "vitest"
import { escapeMarkdownV2, MARKDOWN_V2_SPECIAL_CHARS } from "../src/markdown.ts"
describe("escapeMarkdownV2", () => {
it("empty string → empty", () => {
expect(escapeMarkdownV2("")).toBe("")
})
it("string without special chars → unchanged", () => {
expect(escapeMarkdownV2("hello world 123 abc")).toBe("hello world 123 abc")
})
it("cyrillic → unchanged", () => {
expect(escapeMarkdownV2("Привет мир")).toBe("Привет мир")
})
it.each([...MARKDOWN_V2_SPECIAL_CHARS])("escapes single special char %j", (ch) => {
expect(escapeMarkdownV2(ch)).toBe("\\" + ch)
})
it("mix cyrillic + special chars: cyrillic untouched, special escaped", () => {
// comma and space are NOT in the special set — stay as-is
expect(escapeMarkdownV2("Привет, *мир*!")).toBe("Привет, \\*мир\\*\\!")
})
it("all special chars at once", () => {
const all = [...MARKDOWN_V2_SPECIAL_CHARS].join("")
const expected = [...MARKDOWN_V2_SPECIAL_CHARS].map((c) => "\\" + c).join("")
expect(escapeMarkdownV2(all)).toBe(expected)
})
it("backtick is escaped", () => {
expect(escapeMarkdownV2("`code`")).toBe("\\`code\\`")
})
it("backslash is NOT in MARKDOWN_V2_SPECIAL_CHARS and passes through", () => {
expect(MARKDOWN_V2_SPECIAL_CHARS as readonly string[]).not.toContain("\\")
// input `\*` (2 chars): backslash stays, asterisk escaped → `\\*` (3 chars)
expect(escapeMarkdownV2("\\*")).toBe("\\\\*")
})
})

View file

@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts", "cli.ts", "tests/**/*.ts"]
}

View file

@ -1,9 +0,0 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
testTimeout: 30000,
},
})

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.

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