feat: migrate .opencode/ config from opencode (#23)

* feat: migrate .opencode/ config from opencode

* refactor: rename repo refs and sanitize for public

* docs(handoff): add pr-7 handoff + ADR-002

* docs(handoff): fix PR number

* docs: update project map with .opencode/ structure

---------

Co-authored-by: opencode-agent <agent@slaid098.dev>
This commit is contained in:
Sergey 2026-07-23 22:57:39 +03:00 committed by GitHub
parent 1dbec56140
commit a8f9aa0bc6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 4873 additions and 3 deletions

1
.opencode/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

View file

@ -0,0 +1,241 @@
---
description: Reviews and updates project map documentation before code review. Auto-commits updates to PR branch.
mode: subagent
temperature: 0.1
steps: 100
doom_loop: deny
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 commit*": 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 config/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 config/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-driver был запущен):
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-документации).
- Коммит: `git commit -m "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, include all docs:
```bash
git add docs/project-map/ docs/handoff/ docs/decisions/
git commit -m "docs: update project map + handoff + ADR"
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:
```bash
git commit -m "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 with 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.
Comment format:
```
## Docs Review Summary
- 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>
### Verdict: <APPROVE|FIXED|NO_CHANGES>
```
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)
Command:
```bash
gh pr comment <PR_NUMBER> --body "<comment text above>"
```
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 required exactly — `check_docs` matches regex `Docs Review` (case-insensitive).
4. Never skip the comment, even on edge cases — use `Verdict: NO_CHANGES` instead of silence.
## 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

@ -0,0 +1,98 @@
---
description: Distills durable knowledge from merged PR handoffs into global memory. Read-only on repo, write-only on memory.
mode: subagent
temperature: 0.1
steps: 100
doom_loop: deny
permission:
edit: allow
doom_loop: deny
bash:
"*": deny
"git log*": allow
"git show*": allow
"git diff*": allow
"git status*": allow
"git remote -v*": allow
"git remote get-url origin*": allow
"git branch": allow
"git branch --show-current": allow
"gh pr merge*": deny
"rg *": allow
"find *": allow
"ls *": allow
"cat *": allow
"head*": allow
"tail*": allow
"wc*": allow
"grep*": allow
"printenv*": allow
"pwd": allow
"echo *": allow
"gh pr view*": allow
"gh issue view*": allow
---
You are a memory-syncer agent. Your job: distill durable knowledge from a merged PR handoff into the global memory file at `app_data/opencode-memory/repos/{host}/{org}/{repo}.md`.
You are **read-only on the repository** and **write-only on memory**. You CANNOT commit, push, or add files to the repo — the permission set physically prevents it (`git push`, `git commit`, `git add` are absent from the allow-list; catch-all `"*": deny` blocks them). This is a deterministic guard against pushing to master, replacing the prompt-level rule that was previously bypassed by invocation prompts.
## Setup
1. 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. 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 is `app_data/opencode-memory/` for local dev) → `<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.
## Distillation
Distill durable-only records from the handoff:
- gotchas / workarounds (non-obvious behavior)
- patterns, repository conventions
- pointers: «for X use Y, careful with Z»
- root causes of bugs
- ADR pointers: `- [date, PR#N] ADR-NN: <суть> → docs/decisions/NN-title.md` (do NOT copy ADR content — only the pointer)
DO NOT distill: statuses, «currently working on», current tasks, ephemeral context.
### Format
```
- [YYYY-MM-DD, PR#N] <суть>
```
Date and PR-number in the text are for RAG-search and verification (which PR brought the knowledge).
### Receipt is ALWAYS placed
Even if there are no durable records, the receipt is mandatory:
```
- [date, PR#N] — (нет durable-записей)
```
This confirms the memory-sync phase was executed (audit trail).
### Edit instead of duplicate
If a fact is already recorded — update the entry (bump `updated` in frontmatter). Do not create duplicates.
## Save
1. After editing the memory file, call `memory_save` to commit + re-index the isolated memory repo.
2. **Guard**: run `git status` on the main repo. If anything under `app_data/` is staged (should not happen — `memory_save` commits to the isolated memory repo, not the main repo), report it to the user. **You CANNOT fix this yourself**`git restore` is not in the allow-list (the agent must not touch the repo). Inform the user so they can run `git restore --staged app_data/` manually.
## Rules
1. NEVER call `git push`, `git commit`, `git add` — they are not in the allow-list and will be denied by the catch-all rule.
2. NEVER checkout branches or pull — you operate on the current state of the default branch (already merged).
3. ONLY edit files under `app_data/opencode-memory/repos/{host}/{org}/{repo}.md`.
4. ONLY read files under `docs/handoff/` and `docs/decisions/`.
5. Receipt is mandatory even if no durable records found.
6. If memory file doesn't exist — create it with proper frontmatter (title, tags, summary, created, updated, importance).
7. Для debug-вывода используй `pwd`/`ls`/`cat`/`printenv`НЕ `echo` (не в allow-list).
8. Для статуса PR используй нативный tool `pipeline_status` (НЕ bash `python3 .../pipeline-status.py` — детерминированный deny-rule, см. ADR-019).
9. НЕ используй `git -C <path>` — работай в текущем cwd (memory-syncer читает уже смерженный default branch).
10. НЕ делай `git checkout`/`git pull` — работаешь на уже смерженном default branch, переключаться не нужно.

View file

@ -0,0 +1,311 @@
---
description: Global code reviewer. Reviews PRs against project skills and universal code standards. Invoke via @reviewer. Uses gh pr comment to approve or request changes. Does NOT merge — merge is done by main agent via pipeline-driver.
mode: subagent
temperature: 0.1
steps: 100
doom_loop: deny
permission:
edit: deny
doom_loop: deny
bash:
"*": deny
"git fetch*": allow
"git show*": allow
"git blame*": allow
"git remote -v*": allow
"git remote show*": allow
"git diff*": allow
"git log*": allow
"git status*": allow
"rg *": allow
"find *": allow
"ls *": allow
"cat *": allow
"head*": allow
"tail*": allow
"wc*": allow
"diff*": allow
"gh pr diff*": allow
"gh pr checkout*": allow
"gh issue*": allow
"gh pr view*": allow
"gh pr review*": allow
"gh pr comment*": allow
"uv run *": allow
"pytest*": allow
"npm run *": allow
"npm view *": allow
"npm ls *": allow
"npm audit*": allow
"npx *": allow
"gh api repos/*/actions/runs*": allow
"gh api repos/*/contents*": allow
"gh api repos/*/branches*": allow
"gh api repos/*/pulls*": allow
"gh api repos/*/issues*": allow
"gh repo clone*": allow
"gh repo view*": allow
"gh run list*": allow
"gh run view*": allow
"gh run*": allow
"git -C * status*": allow
"git -C * diff*": allow
"git -C * log*": allow
"git -C * show*": allow
"git -C * branch*": allow
"git -C * blame*": allow
"git -C * fetch*": allow
"git -C * remote -v*": allow
"git -C * remote show*": allow
"git -C * ls-tree*": allow
"git -C * ls-files*": allow
"git branch*": allow
"git checkout*": allow
"gh pr merge*": deny
"git clone*": allow
"git ls-tree*": allow
"git ls-files*": allow
"echo *": allow
"grep *": allow
"python3*": allow
"python *": allow
"python3 *pipeline-status.py*": deny
"python3 config/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 config/scripts/spec-status.py*": deny
"python3 */spec-status.py*": deny
"python *spec-status.py*": deny
"python */spec-status.py*": deny
"node --version*": allow
"which *": allow
"mkdir*": allow
"bash -n *": allow
"head *": allow
"tail *": allow
---
You are a global code reviewer. Your job: review PRs against project skills and universal code standards, leave GitHub PR reviews as comments. You do NOT merge — merge is done by the main agent via pipeline-driver after CI ✅.
## Setup
1. Run `gh pr view <PR_NUMBER> --json headRefName,body,title` to get branch and PR context.
2. Run `git diff main...HEAD --stat` to see what files changed.
3. Run `git diff main...HEAD` to see the actual changes.
4. Check if the repo has project-specific skills:
- 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.
5. Check CI status: используй нативный tool `pipeline_status({pr_number: <PR_NUMBER>})` OR `gh run list --branch <headRefName> --limit 3`. **Запрещено `gh pr checks`** — 403 на fine-grained PAT (scope `Checks: read` не существует). Только Actions API (`gh run list`, `gh run view`, `gh api repos/.../actions/runs`). **Запрещено** bash-запуск `python3 config/scripts/pipeline-status.py` — детерминированный deny-rule (см. ADR-019).
6. Also check for `.opencode/agents/` project-level agents that may define conventions.
## Review Checklist
### 1. Code Quality
**Python:**
- Functions/methods < 50 lines. If longer suggest splitting.
- Files < 300 lines. If longer suggest decomposition.
- No dead code, no unused imports (ruff would catch, but double-check).
- No `global` keyword.
- Absolute imports only (no relative `.` or `..`).
- No comments unless explicitly requested by the project.
- Loguru for logging (not print, not logging module).
- try/except/else pattern — else block for code without exceptions.
- Config/constants in separate file, not inline.
**JavaScript/TypeScript:**
- Functions < 30 lines.
- No `any` types (TypeScript strict).
- No unused exports (knip would catch, but double-check).
- No direct DOM manipulation (use React patterns if React project).
- Consistent naming (camelCase for vars, PascalCase for components/types).
**Universal:**
- Names are descriptive, no single-letter variables (except loop counters i, j, k).
- No magic numbers — extract to named constants.
- Functions do one thing (single responsibility).
### 2. Architecture & Structure
- Code follows project structure (src/, tests/, config/ — match what exists).
- No files in wrong place (logic in tests, configs in src, etc.).
- No circular imports.
- Modules separated by responsibility.
- New files follow existing directory structure.
### 3. Error Handling
- No bare `except:` or `catch {}` without handling.
- Errors are logged (not silently swallowed).
- Exception types are specific (not bare `Exception` or `Error`).
- `finally` blocks for cleanup (closing connections, profiles, files).
- Error messages are meaningful (not just "Error occurred").
### 4. Security
- No secrets in code (passwords, tokens, API keys, private keys).
- No hardcoded URLs or IPs (should be in config/env).
- No SQL injection (parameterized queries).
- No `eval`/`exec` on user input.
- `.env` files not committed (check .gitignore).
- No sensitive data in log statements.
### 5. Testing
- Tests exist for new functionality.
- Test names are descriptive (`test_upload_returns_id` not `test_1`).
- Tests don't depend on execution order.
- No skipped/ignored tests without explanation.
- Test coverage meets project threshold (check pyproject.toml or vitest.config).
### 6. Code Duplication
- Search for similar patterns using `rg` in the codebase.
- If 3+ similar blocks found → suggest abstraction.
- No copy-paste between modules without shared utility.
- Check if similar function/class already exists before approving new one.
### 7. Project-Specific (from skills)
If the repo has `skills/*/SKILL.md`:
- Load each skill via `skill("<name>")`.
- Add all project-specific rules from skills to the review.
- Check code against these rules with higher priority than universal rules.
- If code violates a project-specific rule → CRITICAL.
Examples of project-specific rules:
- "No Playwright/BitBrowser code — use MCP REST only"
- "Profile always closed in finally block"
- "Plugins extend BasePlugin"
- "No direct DB access from frontend"
### 8. PR Hygiene
- PR title follows project convention (usually `type(scope): description`).
- PR body explains what and why.
- No debug code (console.log, print, breakpoints).
- No `.env` or secret files in the diff.
- Branch name is descriptive.
### 9. Documentation (if docs/project-map/ exists)
- Project map files accurately reflect current project structure
- New modules have corresponding map files in `docs/project-map/`
- Deleted/renamed modules have updated or removed map files
- No stale references to files or directories that no longer exist
- Map files follow the template (frontmatter + structure + purpose)
### 10. Handoff & ADR (quick check)
- `docs/handoff/pr-<N>-<slug>.md` exists in the diff (N = PR number)
- Handoff has all sections: Что сделано, Почему, Pending, Watch out
- Handoff content is meaningful — not empty placeholders
- If PR introduces architectural changes → `docs/decisions/<NN>-<title>.md` exists
- ADR has: Статус, Контекст, Решение, Альтернативы
- If handoff/ADR missing or empty → REQUEST_CHANGES
## Output Format
After reviewing, leave a GitHub PR comment using `gh pr comment`:
### If approving (no critical or blocking warnings):
Run:
```
gh pr comment <PR_NUMBER> --body "<review text>"
```
Review body format:
```
## Code Review Summary
<1-2 sentence overview of the changes and overall quality>
### Positives
- <what was done well>
### Suggestions (info, not blocking)
- **file.py:30** [style] Suggestion description
### Verdict: APPROVE
```
Do NOT attempt merge. Stop. Main agent merges via pipeline-driver after CI ✅.
After this command, you MUST respond with your review text only. Do NOT call any more tools.
### If requesting changes (critical issues found):
Run:
```
gh pr comment <PR_NUMBER> --body "<review text>"
```
Review body format:
```
## Code Review Summary
### Summary
<1-2 sentence overview>
### Critical (must fix before merge)
- **path/to/file.py:42** [category] Description of the issue
Fix: suggested fix
- **path/to/file.tsx:15** [category] Description
Fix: suggestion
### Warnings (should fix)
- **path/to/file.py:80** [category] Description
Fix: suggestion
### Verdict: REQUEST_CHANGES
```
Do NOT attempt merge. Stop and wait for fixes.
After this command, you MUST respond with your review text only. Do NOT call any more tools.
### If needs discussion (questions, unclear decisions):
Run:
```
gh pr comment <PR_NUMBER> --body "<review text>"
```
Comment body format:
```
## Code Review Summary — Needs Discussion
### Questions
1. **file.py:42** Why was this approach chosen over <alternative>?
2. **file.tsx:15** Is this the intended behavior?
### Verdict: NEEDS_DISCUSSION
```
Do NOT attempt merge. Stop and wait for discussion.
After this command, you MUST respond with your review text only. Do NOT call any more tools.
## Severity Levels
| Level | Meaning | Action |
|---|---|---|
| **Critical** | Code violates architecture, security risk, will break things | REQUEST_CHANGES |
| **Warning** | Code quality issue, should fix but won't break | Mention, but can approve if no critical |
| **Info** | Suggestion, style preference, improvement idea | Mention, always approve |
## Rules
1. ALWAYS load project skills first — they override universal rules.
2. NEVER edit files — you are read-only.
3. NEVER approve a PR with critical issues — always request changes.
4. ALWAYS provide file:line references in issues.
5. ALWAYS suggest a fix, not just describe the problem.
6. If unsure about something → NEEDS_DISCUSSION, don't guess.
7. After `gh pr comment` (APPROVE or REQUEST_CHANGES), STOP.
Respond with final text only. ANY further tool call is a protocol violation.
Main agent merges via pipeline-driver.
8. After `gh pr comment` 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).
10. Для debug-вывода используй `pwd`/`ls`/`cat`НЕ `echo` (не в allow-list).

View file

@ -0,0 +1,5 @@
---
description: Edit opencode.json config (MCP, providers, permissions, agents)
agent: build
---
Load the `opencode-config` skill via `skill({name: "opencode-config"})` and apply its canonical rules to the user's request about opencode.json changes (MCP servers, providers, permissions, agents, plugins). Follow the skill's rules strictly: always write to `config/opencode.json` in slaid098/opencode-config repo, never project-local.

View file

@ -0,0 +1,5 @@
---
description: Run pipeline-driver — autonomous 7-phase PR pipeline
agent: build
---
Load the `pipeline-driver` skill via `skill({name: "pipeline-driver"})` 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

@ -0,0 +1,5 @@
---
description: Run spec-driver — interactive spec generation for new project
agent: build
---
Load the `spec-driver` skill via `skill({name: "spec-driver"})` and follow its ПРОТОКОЛ strictly. Главный агент — оркестратор: `spec_status` tool (read-only) + вопрос юзеру + task(general) делегирование. Не делает edit/memory_search/gh сам. Каждая фаза = 1 subagent. Стоп на issues — дальше юзер сам /pipeline-driver.

351
.opencode/opencode.json Normal file
View file

@ -0,0 +1,351 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
[
"@mathew-cf/opencode-memory",
{
"memoryDir": "{env:OPENCODE_MEMORY_DIR}"
}
]
],
"skills": {
"paths": [
".opencode/skills"
]
},
"compaction": {
"auto": true,
"prune": true,
"reserved": 100000
},
"disabled_providers": [],
"provider": {
"new_api": {
"name": "new api",
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "{env:AI_PROVIDER_BASE_URL}",
"apiKey": "{env:AI_PROVIDER_API_KEY}"
},
"models": {
"gemini-3.5-flash": {
"name": "Gemini 3.5 Flash",
"reasoning": true,
"attachment": true,
"modalities": {
"input": [
"text",
"image"
],
"output": [
"text"
]
},
"limit": {
"context": 250000,
"input": 250000,
"output": 16384
}
}
}
},
"openrouter": {
"models": {
"deepseek/deepseek-v4-flash": {
"name": "DeepSeek V4 Flash",
"reasoning": true,
"modalities": {
"input": [
"text"
],
"output": [
"text"
]
},
"limit": {
"context": 250000,
"input": 250000,
"output": 16384
},
"variants": {
"low": {
"options": {
"reasoning": {
"effort": "low"
}
}
},
"medium": {
"options": {
"reasoning": {
"effort": "medium"
}
}
},
"high": {
"options": {
"reasoning": {
"effort": "high"
}
}
},
"max": {
"options": {
"reasoning": {
"effort": "xhigh"
}
}
}
}
},
"deepseek/deepseek-v4-pro": {
"name": "DeepSeek V4 Pro",
"reasoning": true,
"modalities": {
"input": [
"text"
],
"output": [
"text"
]
},
"limit": {
"context": 250000,
"input": 250000,
"output": 16384
},
"variants": {
"low": {
"options": {
"reasoning": {
"effort": "low"
}
}
},
"medium": {
"options": {
"reasoning": {
"effort": "medium"
}
}
},
"high": {
"options": {
"reasoning": {
"effort": "high"
}
}
},
"max": {
"options": {
"reasoning": {
"effort": "xhigh"
}
}
}
}
}
}
}
},
"permission": {
"edit": "allow",
"external_directory": "allow",
"doom_loop": "deny",
"read": {
"*.env": "deny",
".env": "deny",
"**/.env": "deny",
"**/.env*": "deny",
"**/id_ed25519": "deny",
"**/id_rsa": "deny",
"**/id_ed25519.pub": "allow"
},
"bash": {
"cat .env*": "deny",
"*cat */.env*": "deny",
"env": "deny",
"printenv*": "deny",
"declare*": "deny",
"set": "deny",
"git add *": "allow",
"git commit *": "allow",
"git push *": "allow",
"git checkout *": "allow",
"git branch *": "allow",
"git status*": "allow",
"git diff*": "allow",
"git log*": "allow",
"git pull*": "allow",
"git -C * add *": "allow",
"git -C * commit *": "allow",
"git -C * push *": "allow",
"git -C * checkout *": "allow",
"git -C * branch *": "allow",
"git -C * status*": "allow",
"git -C * diff*": "allow",
"git -C * log*": "allow",
"git -C * reset *": "deny",
"git -C * clean *": "deny",
"git -C * branch -D *": "ask",
"git -C * branch -d *": "ask",
"git -C * push --delete *": "ask",
"git -C * push origin --delete *": "ask",
"gh pr create*": "allow",
"gh pr merge*": "allow",
"gh pr list*": "allow",
"gh pr status*": "allow",
"gh run*": "allow",
"gh issue*": "allow",
"gh pr diff*": "allow",
"gh pr view*": "allow",
"gh pr comment*": "allow",
"gh pr review*": "allow",
"gh api repos/*/actions/runs*": "allow",
"gh api repos/*/issues*": "allow",
"gh run list*": "allow",
"gh run view*": "allow",
"docker ps*": "allow",
"docker logs*": "allow",
"uv run pytest*": "allow",
"uv run ruff*": "allow",
"uv run poe*": "allow",
"uv *": "allow",
"npx *": "allow",
"npm *": "allow",
"python*": "allow",
"python3 *pipeline-status.py*": "deny",
"python3 config/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 config/scripts/spec-status.py*": "deny",
"python3 */spec-status.py*": "deny",
"python *spec-status.py*": "deny",
"python */spec-status.py*": "deny",
"mkdir*": "allow",
"git branch -D *": "ask",
"git branch -d *": "ask",
"git push --delete *": "ask",
"git push origin --delete *": "ask",
"gh repo delete *": "deny",
"gh pr close *": "ask",
"docker restart *": "ask",
"docker stop *": "ask",
"docker rm *": "ask",
"docker exec *": "ask",
"docker system prune*": "ask",
"docker volume rm *": "ask",
"rm *": "ask",
"del *": "ask",
"Remove-Item *": "ask",
"git reset *": "deny",
"git clean *": "deny",
"vastai *": "ask",
"vastai show*": "allow",
"vastai search*": "allow",
"kill *": "ask",
"nohup *": "ask",
"cat *id_ed25519*": "deny",
"*cat *ssh/id_ed25519*": "deny",
"ssh * mkfs*": "deny",
"ssh * dd *": "deny",
"ssh * fdisk*": "deny",
"ssh * hostname": "allow",
"ssh * uname *": "allow",
"ssh * df *": "allow",
"ssh * du *": "allow",
"ssh * free *": "allow",
"ssh * ls *": "allow",
"ssh * cat /etc/*": "allow",
"ssh * tail *": "allow",
"ssh * head *": "allow",
"ssh * grep *": "allow",
"ssh * find *": "allow",
"ssh * ps *": "allow",
"ssh * systemctl status *": "allow",
"ssh * docker ps*": "allow",
"ssh * docker logs*": "allow",
"ssh * docker inspect*": "allow",
"ssh * docker images*": "allow",
"ssh * docker stats*": "allow",
"ssh * git pull*": "allow",
"ssh * netstat *": "allow",
"ssh * ss *": "allow",
"ssh * uptime": "allow",
"ssh * uptime*": "allow",
"ssh * whoami": "allow",
"ssh * ip *": "allow",
"ssh * powershell -Command Get-*": "allow",
"ssh * powershell -Command Test-*": "allow",
"ssh * cat *.env*": "ask",
"ssh * cat .env*": "ask",
"ssh * rm *": "ask",
"ssh * rmdir *": "ask",
"ssh * docker restart*": "ask",
"ssh * docker stop*": "ask",
"ssh * docker rm*": "ask",
"ssh * docker rmi*": "ask",
"ssh * docker update*": "ask",
"ssh * systemctl restart*": "ask",
"ssh * systemctl stop*": "ask",
"ssh * apt *": "ask",
"ssh * yum *": "ask",
"ssh * mv *": "ask",
"ssh * chmod *": "ask",
"ssh * chown *": "ask",
"ssh * reboot*": "ask",
"ssh * shutdown*": "ask",
"ssh * powershell -Command Set-*": "ask",
"ssh * powershell -Command New-*": "ask",
"ssh * powershell -Command Remove-*": "ask",
"ssh * powershell -Command Restart-*": "ask",
"ssh * powershell -Command Stop-*": "ask",
"ssh *": "ask"
}
},
"agent": {
"general": {
"steps": 100
}
},
"mcp": {
"browser": {
"type": "local",
"command": [
"npx",
"-y",
"@modelcontextprotocol/server-puppeteer"
],
"enabled": true
},
"context7": {
"type": "local",
"command": [
"npx",
"-y",
"@upstash/context7-mcp",
"--api-key",
"{env:CONTEX7_API_KEY}"
],
"enabled": true
},
"antidetect-browser": {
"type": "remote",
"url": "{env:ANTIDETECT_BROWSER_MCP_URL}",
"enabled": true,
"timeout": 300000
},
"integrations": {
"type": "remote",
"url": "https://integrations.sh/mcp",
"enabled": true,
"timeout": 300000
}
}
}

8
.opencode/package.json Normal file
View file

@ -0,0 +1,8 @@
{
"scripts": {
"test": "bun test config/plugins/"
},
"dependencies": {
"@opencode-ai/plugin": "1.18.3"
}
}

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

@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Check agent permission configs for dangerous allow rules."""
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
AGENTS_DIR = REPO_ROOT / "config" / "agents"
OPENCODE_JSON = REPO_ROOT / "config" / "opencode.json"
DANGEROUS_PATTERNS = [
(r"^gh api\*?$", "broad — allows gh api -X DELETE/PUT/POST"),
(r"^gh api \*$", "broad — allows gh api -X DELETE/PUT/POST"),
(r"^gh api -X (DELETE|PUT|POST|PATCH)\*?$", "destructive HTTP method"),
(r"^gh api --method (DELETE|PUT|POST|PATCH)\*?$", "destructive HTTP method"),
(
r"^gh pr checks\*?$",
"uses GraphQL statusCheckRollup → Checks API → 403 on fine-grained PAT "
"(scope 'Checks: read' does not exist). Use 'gh run list'/'gh run view' or "
"'gh api repos/.../actions/runs' (actions=read scope). See ADR-005.",
),
(
r"^gh pr view \*--json statusCheckRollup\*?$",
"GraphQL statusCheckRollup → Checks API → 403 on fine-grained PAT. "
"Use 'gh pr view --json headRefName,body,title' (metadata only) or "
"'gh run list' for CI status. See ADR-005.",
),
(
r"^gh pr merge\*?$",
"merge is done by main agent via pipeline-driver, not subagents. "
"Use 'gh pr merge' from primary build/plan agent only (global "
"opencode.json:195 has 'gh pr merge*: allow'). Per-agent 'gh pr "
"merge*: deny' is the defense (see ADR-016). See ADR-006 for "
"DANGEROUS_PATTERNS mechanism.",
"agent",
),
(
r"^python3? .*pipeline-status\.py",
"use pipeline_status tool, not bash. See ADR-019",
"agent",
),
(
r"^python .*pipeline-status\.py",
"use pipeline_status tool, not bash. See ADR-019",
"agent",
),
(
r"^python3? .*spec-status\.py",
"use spec_status tool, not bash. See ADR-NNN",
"agent",
),
(
r"^python .*spec-status\.py",
"use spec_status tool, not bash. See ADR-NNN",
"agent",
),
(r"^gh repo\*?$", "broad — allows gh repo delete"),
(r"^gh repo \*$", "broad — allows gh repo delete"),
(r"^gh repo (delete|edit|rename|archive|unarchive|sync)\*?$", "destructive repo operation"),
(r"^git -C \*$", "broad — allows any git command in any dir"),
(r"^git reset \*", "destroys uncommitted changes"),
(r"^git clean \*", "deletes untracked files"),
(r"^git push --force\*", "rewrites remote history"),
(r"^git push --delete\*", "deletes remote branches"),
(r"^git push -f\*", "rewrites remote history"),
(r"^git push origin --delete\*", "deletes remote branches"),
(r"^rm -rf", "recursive force delete"),
(r"^rm -f /", "force delete system files"),
(r"^rm /", "delete system files"),
(r"^rmdir \*", "delete directories"),
(r"^shred \*", "secure delete"),
(r"^sudo\*?$", "privilege escalation"),
(r"^sudo \*", "privilege escalation"),
(r"^chmod \*", "change permissions"),
(r"^chown \*", "change ownership"),
(r"^mkfs\*", "format filesystem"),
(r"^dd \*", "raw disk write"),
(r"^fdisk\*", "modify partitions"),
(r"^shutdown\*", "shutdown system"),
(r"^reboot\*", "reboot system"),
(r"^halt\*", "halt system"),
(r"^poweroff\*", "power off system"),
(r"^docker system prune\*", "prune everything"),
(r"^docker rm \*", "remove containers"),
(r"^docker rmi \*", "remove images"),
(r"^docker volume rm \*", "remove volumes"),
(r"^docker network rm \*", "remove networks"),
(r"^ssh \* mkfs\*", "format remote disk"),
(r"^ssh \* dd \*", "raw disk write on remote"),
(r"^ssh \* fdisk\*", "modify partitions on remote"),
(r"^ssh \* reboot\*", "reboot remote"),
(r"^ssh \* shutdown\*", "shutdown remote"),
(r"^ssh \* halt\*", "halt remote"),
(r"^ssh \* poweroff\*", "power off remote"),
(r"^ssh \* rm \*", "delete files on remote"),
(r"^ssh \* chmod\*", "change permissions on remote"),
(r"^ssh \* chown\*", "change ownership on remote"),
(r"^kubectl delete \*", "delete k8s resources"),
(r"^kubectl scale \*", "scale k8s resources"),
(r"^kill -9 \*", "force kill processes"),
(r"^killall\*", "kill processes by name"),
(r"^pkill\*", "kill processes by pattern"),
(r"^npm install\*", "install arbitrary packages"),
(r"^npm i \*", "install arbitrary packages"),
(r"^npm add\*", "install arbitrary packages"),
(r"^pip install\*", "install arbitrary packages"),
(r"^uv pip install\*", "install arbitrary packages"),
(r"^uv add\*", "install arbitrary packages"),
(r"^cargo install\*", "install arbitrary packages"),
(r"^gem install\*", "install arbitrary packages"),
]
def parse_agent_bash_rules(filepath: Path) -> dict[str, str]:
content = filepath.read_text()
parts = content.split("---", 2)
if len(parts) < 3:
return {}
frontmatter = parts[1]
lines = frontmatter.split("\n")
rules: dict[str, str] = {}
in_bash = False
for line in lines:
if line.strip() == "bash:":
in_bash = True
continue
if in_bash:
if line.startswith(" "):
match = re.match(r'\s*"([^"]+)":\s*(\w+)', line)
if match:
rules[match.group(1)] = match.group(2)
elif line.strip() and not line.startswith(" "):
break
return rules
def parse_global_bash_rules(filepath: Path) -> dict[str, str]:
with open(filepath) as f:
config = json.load(f)
return config.get("permission", {}).get("bash", {})
def check_rules(rules: dict[str, str], source: str) -> list[str]:
is_global = source == "opencode.json"
violations = []
for pattern, action in rules.items():
if action != "allow":
continue
for entry in DANGEROUS_PATTERNS:
regex, reason = entry[0], entry[1]
scope = entry[2] if len(entry) > 2 else "all"
if scope == "agent" and is_global:
continue
if re.match(regex, pattern):
violations.append(f' [{source}] "{pattern}": {action}\n -> {reason}')
break
return violations
def main() -> None:
all_violations: list[str] = []
if OPENCODE_JSON.exists():
rules = parse_global_bash_rules(OPENCODE_JSON)
all_violations.extend(check_rules(rules, "opencode.json"))
if AGENTS_DIR.exists():
for agent_file in sorted(AGENTS_DIR.glob("*.md")):
rules = parse_agent_bash_rules(agent_file)
all_violations.extend(check_rules(rules, f"agents/{agent_file.name}"))
if not all_violations:
print("OK: No dangerous permission rules found.")
sys.exit(0)
print("FAIL: Dangerous permission rules detected:\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

@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Parse opencode log for permission denials and tool errors."""
import re
import sys
from collections import defaultdict
from pathlib import Path
LOG_PATH = Path.home() / ".local" / "share" / "opencode" / "log" / "opencode.log"
def parse_line(line):
"""Extract fields from a log line."""
ts_match = re.search(r"timestamp=(\S+)", line)
run_match = re.search(r"run=(\S+)", line)
ts = ts_match.group(1) if ts_match else "?"
run = run_match.group(1) if run_match else "?"
return ts, run
def _process_line(line, sessions, denials, errors):
"""Classify a single log line into sessions/denials/errors."""
if "message=created" in line and "agent=" in line:
ts, run = parse_line(line)
agent_match = re.search(r"agent=(\S+)", line)
session_match = re.search(r"id=(\S+)", line)
title_match = re.search(r'title="([^"]*)"', line)
agent = agent_match.group(1) if agent_match else "unknown"
session = session_match.group(1) if session_match else "?"
title = title_match.group(1) if title_match else ""
sessions[run] = {"agent": agent, "session": session, "title": title}
elif "action.action=deny" in line:
ts, run = parse_line(line)
pattern_match = re.search(r'pattern="([^"]*)"', line)
perm_match = re.search(r"permission=(\S+)", line)
pattern = pattern_match.group(1) if pattern_match else "?"
perm = perm_match.group(1) if perm_match else "?"
denials.append({"ts": ts, "run": run, "pattern": pattern, "perm": perm})
elif "message=process" in line and "level=ERROR" in line:
ts, run = parse_line(line)
error_match = re.search(r"error=(\S+)", line)
session_match = re.search(r"session\.id=(\S+)", line)
error = error_match.group(1) if error_match else "unknown"
session = session_match.group(1) if session_match else "?"
errors.append({"ts": ts, "run": run, "error": error, "session": session})
def main():
if not LOG_PATH.exists():
print("Log file not found: {LOG_PATH}")
sys.exit(1)
sessions = {}
denials = []
errors = []
with open(LOG_PATH) as f:
for line in f:
_process_line(line, sessions, denials, errors)
if not denials and not errors:
print("No denials or errors found in log.")
return
print("# Observability Report")
print(f"# Log: {LOG_PATH}")
print(f"# Denials: {len(denials)} | Errors: {len(errors)}")
print()
if denials:
print("## Permission Denials\n")
by_agent = defaultdict(list)
for d in denials:
info = sessions.get(d["run"], {"agent": "unknown", "session": "?", "title": ""})
by_agent[info["agent"]].append(d)
for agent, items in sorted(by_agent.items()):
print(f"### {agent} ({len(items)} denials)\n")
for d in items:
info = sessions.get(d["run"], {"session": "?"})
print(f"- {d['ts']} | `{d['pattern']}` | {d['perm']} | {info['session']}")
print()
if errors:
print("## Process Errors\n")
for e in errors[-20:]:
info = sessions.get(e["run"], {"agent": "unknown"})
print(f"- {e['ts']} | {info['agent']} | {e['error']} | {e['session']}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,771 @@
#!/usr/bin/env python3
"""Pipeline-status oracle: determine PR phase in 7-phase PR pipeline.
Reads facts from GitHub (gh CLI), git, and memory files to deterministically
derive the current pipeline phase of a PR no state file, like ``git status``
for the PR pipeline. CI gate via Actions API (read-only): CI blocks MERGE
(transitive guard first phase blocks all subsequent phases).
Usage:
python3 config/scripts/pipeline-status.py <PR_NUMBER> # single PR status
python3 config/scripts/pipeline-status.py # table of open PRs
Seven phases:
1. ISSUE GitHub issue exists and linked via Closes/Fixes #N
2. IMPLEMENT PR exists + handoff file docs/handoff/pr-N-slug.md in diff
3. DOCS handoff valid (4 sections) + mandatory ADR
4. CI latest CI run on PR branch completed & success
5. REVIEW APPROVE found in PR comments
6. MERGE PR state is MERGED
7. MEMORY PR#N distilled into repos/{host}/{org}/{repo}.md
"""
from __future__ import annotations
import functools
import os
import re
import subprocess
import sys
import time
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
def _resolve_repo_root() -> Path:
"""Resolve repo root via git (cwd-aware), fallback to script location."""
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False
)
if result.returncode == 0 and result.stdout.strip():
return Path(result.stdout.strip()).resolve()
return Path(__file__).resolve().parent.parent.parent
REPO_ROOT = _resolve_repo_root()
_MEMORY_BASE = os.environ.get(
"OPENCODE_MEMORY_DIR",
str(REPO_ROOT / "app_data" / "opencode-memory"),
)
MEMORY_DIR = Path(_MEMORY_BASE) / "repos"
HANDOFF_DIR = REPO_ROOT / "docs" / "handoff"
ADR_DIR = REPO_ROOT / "docs" / "decisions"
REQUIRED_SECTIONS = ["## Что сделано", "## Почему", "## Pending", "## Watch out"]
PHASE_NAMES = ["ISSUE", "IMPLEMENT", "DOCS", "CI", "REVIEW", "MERGE", "MEMORY"]
CI_WAIT_TIMEOUT = 300
CI_POLL_INTERVAL = 10
CI_NO_RUNS_RETRY = 3
CI_NO_RUNS_INTERVAL = 5
CLOSURE_RE = re.compile(r"(?:Closes|Fixes|Resolves)\s+#(\d+)", re.IGNORECASE)
REVIEW_APPROVE_RE = re.compile(
r"## Code Review Summary.*?###\s*Verdict:\s*APPROVE\b",
re.IGNORECASE | re.DOTALL,
)
REVIEW_VERDICT_RE = re.compile(
r"## Code Review Summary.*?###\s*Verdict:\s*(\w+)",
re.IGNORECASE | re.DOTALL,
)
DOCS_REVIEW_RE = re.compile(r"Docs Review", re.IGNORECASE)
JSON_FIELD_RE = re.compile(r'"(\w+)"\s*:\s*"?([^",}]*)"?', re.IGNORECASE)
class PhaseStatus(StrEnum):
"""Phase check result."""
DONE = "DONE"
NOT_DONE = "NOT_DONE"
AMBIGUOUS = "AMBIGUOUS"
@dataclass(frozen=True)
class PhaseResult:
"""Result of a single phase check."""
status: PhaseStatus
detail: str
def run_cmd(args: list[str]) -> tuple[int, str, str]:
"""Run a command, return (returncode, stdout, stderr)."""
result = subprocess.run(args, capture_output=True, text=True, check=False)
return result.returncode, result.stdout, result.stderr
@dataclass(frozen=True)
class CiPollConfig:
"""CI polling parameters.
Priority: CLI flag > env var > constant in code.
"""
wait_timeout: int
poll_interval: int
def _env_int(name: str, default: int) -> int:
"""Read int from env var, fallback to default on missing/invalid."""
raw = os.environ.get(name)
if raw is None:
return default
try:
return int(raw)
except ValueError:
return default
def _load_ci_config(argv: list[str] | None = None) -> CiPollConfig:
"""Build CiPollConfig from CLI flags + env vars + constants.
Priority: CLI flag > env var > constant. CLI flags ``--ci-wait-timeout N``
and ``--ci-poll-interval N`` parsed manually from ``argv`` (last wins).
"""
args = argv if argv is not None else sys.argv[1:]
cli_timeout: int | None = None
cli_interval: int | None = None
i = 0
while i < len(args):
if args[i] == "--ci-wait-timeout" and i + 1 < len(args):
try:
cli_timeout = int(args[i + 1])
except ValueError:
cli_timeout = None
i += 2
continue
if args[i] == "--ci-poll-interval" and i + 1 < len(args):
try:
cli_interval = int(args[i + 1])
except ValueError:
cli_interval = None
i += 2
continue
i += 1
timeout = (
cli_timeout
if cli_timeout is not None
else _env_int("OPENCODE_CI_WAIT_TIMEOUT", CI_WAIT_TIMEOUT)
)
interval = (
cli_interval
if cli_interval is not None
else _env_int("OPENCODE_CI_POLL_INTERVAL", CI_POLL_INTERVAL)
)
return CiPollConfig(wait_timeout=timeout, poll_interval=interval)
def parse_remote_url(url: str) -> tuple[str, str, str]:
"""Parse git remote URL into (host, org, repo).
Supports both HTTPS and SSH formats:
https://github.com/org/repo.git -> (github.com, org, repo)
git@github.com:org/repo.git -> (github.com, org, repo)
"""
ssh_match = re.match(r"git@([^:]+):([^/]+)/(.+?)(?:\.git)?$", url)
if ssh_match:
return ssh_match.group(1), ssh_match.group(2), ssh_match.group(3)
https_match = re.match(r"https?://([^/]+)/([^/]+)/(.+?)(?:\.git)?$", url)
if https_match:
return https_match.group(1), https_match.group(2), https_match.group(3)
raise ValueError(f"Cannot parse remote URL: {url}")
def get_memory_file_path() -> Path:
"""Derive memory file path from ``git remote get-url origin``."""
rc, out, err = run_cmd(["git", "remote", "get-url", "origin"])
if rc != 0:
raise RuntimeError(f"Cannot get git remote URL: {err.strip()}")
host, org, repo = parse_remote_url(out.strip())
return MEMORY_DIR / host / org / f"{repo}.md"
@functools.cache
def get_repo_full_name() -> str:
"""Return ``org/repo`` from git remote (cached, one call per run).
Used for GitHub Actions API URL: ``repos/{org}/{repo}/actions/runs``.
Cached via ``functools.cache`` (one git call per process); tests reset
via ``get_repo_full_name.cache_clear()``.
"""
rc, out, err = run_cmd(["git", "remote", "get-url", "origin"])
if rc != 0:
raise RuntimeError(f"Cannot get git remote URL: {err.strip()}")
_host, org, repo = parse_remote_url(out.strip())
return f"{org}/{repo}"
def extract_json_field(json_str: str, field: str) -> str | None:
"""Extract a string field value from JSON (simple regex, no json import)."""
match = re.search(rf'"{field}"\s*:\s*"([^"]*)"', json_str)
return match.group(1) if match else None
def check_gh_auth() -> str | None:
"""Check if gh CLI is authenticated. Returns error message or None."""
rc, _, err = run_cmd(["gh", "auth", "status"])
if rc != 0:
return f"gh CLI не авторизован: {err.strip()}"
return None
def check_issue(pr_number: int) -> PhaseResult:
"""Phase 1: ISSUE — issue exists and linked via Closes/Fixes #N."""
rc, out, _ = run_cmd(
["gh", "pr", "view", str(pr_number), "--json", "body", "--repo", get_repo_full_name()]
)
if rc != 0:
return PhaseResult(PhaseStatus.NOT_DONE, f"PR #{pr_number} не существует")
matches = CLOSURE_RE.findall(out)
if not matches:
return PhaseResult(PhaseStatus.NOT_DONE, "Closes/Fixes #N не найден в body")
issue_numbers = list({int(m) for m in matches})
if len(issue_numbers) > 1:
return PhaseResult(
PhaseStatus.AMBIGUOUS,
f"несколько issue в body: {', '.join(f'#{n}' for n in issue_numbers)}",
)
issue_num = issue_numbers[0]
rc2, _, err2 = run_cmd(["gh", "issue", "view", str(issue_num), "--repo", get_repo_full_name()])
if rc2 != 0:
return PhaseResult(
PhaseStatus.NOT_DONE,
f"issue #{issue_num} не существует: {err2.strip()}",
)
return PhaseResult(PhaseStatus.DONE, f"#{issue_num} связан через Closes #{issue_num}")
def check_implement(pr_number: int) -> PhaseResult:
"""Phase 2: IMPLEMENT — PR exists + handoff file in diff."""
rc, out, _ = run_cmd(
["gh", "pr", "view", str(pr_number), "--json", "files", "--repo", get_repo_full_name()]
)
if rc != 0:
return PhaseResult(PhaseStatus.NOT_DONE, f"PR #{pr_number} не существует")
files = re.findall(r'"path"\s*:\s*"([^"]+)"', out)
pattern = f"docs/handoff/pr-{pr_number}-"
handoff_files = [f for f in files if pattern in f]
if not handoff_files:
return PhaseResult(PhaseStatus.NOT_DONE, f"handoff {pattern}*.md не найден в diff")
return PhaseResult(PhaseStatus.DONE, f"handoff: {Path(handoff_files[0]).name}")
def check_docs(pr_number: int) -> PhaseResult:
"""Phase 3: DOCS — handoff valid (4 sections) + mandatory ADR + 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. PR comment with heading `## Docs Review Summary` from docs-reviewer (proves it ran).
Without the docs-reviewer comment, DOCS is NOT_DONE subagent may have written
handoff+ADR without docs-reviewer actually validating them. Symmetric to
``check_review`` which looks for ``## Code Review Summary`` in PR comments.
"""
handoff_files = sorted(HANDOFF_DIR.glob(f"pr-{pr_number}-*.md"))
if not handoff_files:
return PhaseResult(PhaseStatus.NOT_DONE, f"handoff pr-{pr_number}-*.md не найден")
content = handoff_files[0].read_text()
missing = [s for s in REQUIRED_SECTIONS if s not in content]
if missing:
return PhaseResult(PhaseStatus.NOT_DONE, f"отсутствуют секции: {', '.join(missing)}")
adr_result = check_adr(pr_number)
if adr_result.status != PhaseStatus.DONE:
return adr_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_ci(pr_number: int) -> PhaseResult:
"""Phase 4: CI — latest CI run on PR branch completed & success.
Uses Actions API (read-only, ``Actions: read`` scope). Polls until
``status == completed`` or ``CI_WAIT_TIMEOUT`` elapsed. One tool call
final status (DONE on green, NOT_DONE on failure, AMBIGUOUS on
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()
return _run_ci_loop(head_branch, config)
def _run_ci_loop(head_branch: str, config: CiPollConfig) -> PhaseResult:
"""Initial CI query + edge-case dispatch + delegate to poll/no-runs helpers."""
kind, runs_str, err = _query_ci_run(head_branch)
if kind == "error":
return PhaseResult(PhaseStatus.AMBIGUOUS, err)
if kind == "no_runs":
return _retry_no_runs(head_branch, config)
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_runs(head_branch: str, config: CiPollConfig) -> PhaseResult:
"""Retry CI query when no run registered yet (CI may lag after push).
Up to ``CI_NO_RUNS_RETRY`` total attempts (initial + retries), sleeping
``CI_NO_RUNS_INTERVAL`` between attempts. On success classify/poll;
on API error AMBIGUOUS; exhausted AMBIGUOUS.
"""
for attempt in range(CI_NO_RUNS_RETRY):
if attempt > 0:
time.sleep(CI_NO_RUNS_INTERVAL)
kind, runs_str, err = _query_ci_run(head_branch)
if kind == "error":
return PhaseResult(PhaseStatus.AMBIGUOUS, err)
if kind == "run":
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(
PhaseStatus.AMBIGUOUS,
f"нет CI run на ветке {head_branch} — возможна проблема триггера",
)
def _poll_until_done(
head_branch: str, config: CiPollConfig, runs_str: str, last_status: str
) -> PhaseResult:
"""Poll Actions API until status == completed or CI_WAIT_TIMEOUT elapsed.
``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
status = last_status
runs_str_cur = runs_str
while status != "completed" and elapsed < config.wait_timeout:
if elapsed + config.poll_interval > config.wait_timeout:
break
time.sleep(config.poll_interval)
elapsed += config.poll_interval
kind, runs_str_new, err = _query_ci_run(head_branch)
if kind == "error":
return PhaseResult(PhaseStatus.AMBIGUOUS, err)
if kind == "no_runs":
return PhaseResult(
PhaseStatus.AMBIGUOUS,
f"нет CI run на ветке {head_branch} — возможна проблема триггера",
)
runs_str_cur = runs_str_new
status_new = _extract_json_field_loose(runs_str_cur, "status")
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(
PhaseStatus.AMBIGUOUS,
f"CI ещё идёт после {config.wait_timeout}s — проверь вручную: "
f"gh run view --branch {head_branch}",
)
def _get_pr_head_branch(pr_number: int) -> tuple[str | None, str | None]:
"""Return (head_branch, None) or (None, error_message)."""
rc, out, err = run_cmd(
[
"gh",
"pr",
"view",
str(pr_number),
"--json",
"headRefName",
"--repo",
get_repo_full_name(),
]
)
if rc != 0:
return None, f"не удалось получить ветку PR: {err.strip()}"
head_branch = extract_json_field(out, "headRefName")
if not head_branch:
return None, "не удалось распарсить headRefName PR"
return head_branch, None
def _query_ci_run(head_branch: str) -> tuple[str, str, str]:
"""Query Actions API for latest CI run on ``head_branch``.
Return ``(kind, json_str, error)`` where ``kind`` is one of:
- ``"error"`` API call failed (rc != 0, e.g. 403), ``error`` set.
- ``"no_runs"`` jq returned null/empty (no CI run registered yet).
- ``"run"`` JSON with status/conclusion, ``json_str`` set.
"""
jq_filter = (
f'[.workflow_runs[] | select(.head_branch == "{head_branch}") '
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, ""
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")
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]:
"""Extract 'body' fields from gh pr view --json comments output.
Handles JSON string escaping (\\n, \\", \\\\).
"""
bodies = []
for match in re.finditer(r'"body"\s*:\s*"((?:[^"\\]|\\.)*)"', json_str):
raw = match.group(1)
body = raw.encode().decode("unicode_escape")
bodies.append(body)
return bodies
def check_review(pr_number: int) -> PhaseResult:
"""Phase 5: REVIEW — APPROVE found in PR comments from code reviewer.
Looks for '## Code Review Summary' heading (NOT '## Docs Review Summary')
with '### Verdict: APPROVE'. Only the latest reviewer comment counts
if reviewer changed from APPROVE to REQUEST_CHANGES, NOT_DONE.
"""
rc, out, _ = run_cmd(
["gh", "pr", "view", str(pr_number), "--json", "comments", "--repo", get_repo_full_name()]
)
if rc != 0:
return PhaseResult(PhaseStatus.NOT_DONE, "не удалось получить комментарии PR")
comment_bodies = _extract_comment_bodies(out)
if not comment_bodies:
return PhaseResult(PhaseStatus.NOT_DONE, "нет комментариев PR")
reviewer_verdict = None
for body in comment_bodies:
if REVIEW_VERDICT_RE.search(body):
match = REVIEW_VERDICT_RE.search(body)
reviewer_verdict = match.group(1).upper() if match else None
if reviewer_verdict is None:
return PhaseResult(PhaseStatus.NOT_DONE, "Code Review Summary не найден в комментариях")
if reviewer_verdict == "APPROVE":
return PhaseResult(PhaseStatus.DONE, "APPROVE найден в Code Review Summary")
return PhaseResult(PhaseStatus.NOT_DONE, f"последний verdict reviewer'а: {reviewer_verdict}")
def check_merge(pr_number: int) -> PhaseResult:
"""Phase 6: MERGE — PR state is MERGED."""
rc, out, _ = run_cmd(
["gh", "pr", "view", str(pr_number), "--json", "state", "--repo", get_repo_full_name()]
)
if rc != 0:
return PhaseResult(PhaseStatus.NOT_DONE, f"PR #{pr_number} не существует")
state = extract_json_field(out, "state")
if state is None:
return PhaseResult(PhaseStatus.AMBIGUOUS, "не удалось распарсить state PR")
if state == "MERGED":
return PhaseResult(PhaseStatus.DONE, "merged")
return PhaseResult(PhaseStatus.NOT_DONE, f"state={state}")
def check_memory(pr_number: int) -> PhaseResult:
"""Phase 7: MEMORY — PR#N distilled into memory file."""
try:
memory_file = get_memory_file_path()
except (RuntimeError, ValueError) as exc:
return PhaseResult(PhaseStatus.NOT_DONE, str(exc))
if not memory_file.exists():
return PhaseResult(
PhaseStatus.NOT_DONE,
f"memory file не существует: {memory_file.name}",
)
content = memory_file.read_text()
pattern = f"PR#{pr_number}"
if pattern in content:
return PhaseResult(PhaseStatus.DONE, f"{pattern} в {memory_file.name}")
return PhaseResult(
PhaseStatus.NOT_DONE,
f"{pattern} не найден в {memory_file.name}",
)
def get_pr_title(pr_number: int) -> str:
"""Get PR title via gh CLI."""
rc, out, _ = run_cmd(
["gh", "pr", "view", str(pr_number), "--json", "title", "--repo", get_repo_full_name()]
)
if rc != 0:
return f"PR #{pr_number}"
title = extract_json_field(out, "title")
return title if title else f"PR #{pr_number}"
NEXT_ACTIONS: dict[str, str] = {
"ISSUE": "создать issue и связать через Closes #N в body PR",
"IMPLEMENT": "добавить handoff docs/handoff/pr-N-slug.md в diff",
"DOCS": "запустить docs-reviewer (режим pre-merge)",
"CI": "проверь статус CI вручную (gh run view)",
"REVIEW": "запустить reviewer (task subagent_type=reviewer)",
"MERGE": "смержить PR (gh pr merge N --squash --delete-branch)",
"MEMORY": "запустить memory-syncer",
}
REVIEW_NEXT_REQUEST_CHANGES = (
"запусти fix subagent (general) с prompt 'fix reviewer comments: <list>', "
"commit, push → re-loop (pipeline_status проверит CI автоматически)"
)
REVIEW_NEXT_NEEDS_DISCUSSION = "уточни вопросы с автором PR (verdict: NEEDS_DISCUSSION)"
REVIEW_NEXT_DEFAULT = NEXT_ACTIONS["REVIEW"]
STATUS_ICONS: dict[PhaseStatus, str] = {
PhaseStatus.DONE: "",
PhaseStatus.NOT_DONE: "",
PhaseStatus.AMBIGUOUS: "⚠️",
}
def get_next_action(phase_name: str, pr_number: int) -> str:
"""Get NEXT action description for a not-done phase."""
action = NEXT_ACTIONS.get(phase_name, "уточнить статус")
return action.replace("N", str(pr_number))
def get_next_action_review(result: PhaseResult) -> str:
"""REVIEW-фаза: NEXT зависит от вердикта reviewer'а в result.detail."""
detail = result.detail.upper()
if "REQUEST_CHANGES" in detail:
return REVIEW_NEXT_REQUEST_CHANGES
if "NEEDS_DISCUSSION" in detail:
return REVIEW_NEXT_NEEDS_DISCUSSION
return REVIEW_NEXT_DEFAULT
def run_all_checks(pr_number: int) -> list[PhaseResult]:
"""Run all 7 phase checks, return results in order."""
return [
check_issue(pr_number),
check_implement(pr_number),
check_docs(pr_number),
check_ci(pr_number),
check_review(pr_number),
check_merge(pr_number),
check_memory(pr_number),
]
def find_current_phase(results: list[PhaseResult]) -> int | None:
"""Return index of first not-done phase, or None if all done."""
for i, result in enumerate(results):
if result.status != PhaseStatus.DONE:
return i
return None
def format_single_pr(pr_number: int, results: list[PhaseResult]) -> str:
"""Format detailed output for a single PR."""
title = get_pr_title(pr_number)
lines = [f"PR #{pr_number}: {title}", ""]
for i, (name, result) in enumerate(zip(PHASE_NAMES, results, strict=True)):
icon = STATUS_ICONS[result.status]
lines.append(f"{icon} {i + 1}. {name:<12} {result.detail}")
lines.append("")
current = find_current_phase(results)
if current is None:
lines.append("Status: COMPLETE")
else:
result = results[current]
if result.status == PhaseStatus.AMBIGUOUS:
lines.append(f"AMBIGUOUS: {result.detail}")
lines.append("NEXT: уточните статус вручную")
else:
if PHASE_NAMES[current] == "REVIEW":
action = get_next_action_review(result)
else:
action = get_next_action(PHASE_NAMES[current], pr_number)
lines.append(f"NEXT: {action}")
return "\n".join(lines)
def list_open_pr_numbers() -> list[int]:
"""List numbers of all open PRs."""
rc, out, _ = run_cmd(
["gh", "pr", "list", "--state", "open", "--json", "number", "--repo", get_repo_full_name()]
)
if rc != 0:
return []
numbers = re.findall(r'"number"\s*:\s*(\d+)', out)
return sorted(int(n) for n in numbers)
def format_pr_row(pr_number: int) -> str:
"""Format a single row for the open-PRs table."""
results = run_all_checks(pr_number)
title = get_pr_title(pr_number)
icon_str = "".join(STATUS_ICONS[r.status] for r in results)
current = find_current_phase(results)
if current is None:
next_action = "COMPLETE"
elif PHASE_NAMES[current] == "REVIEW":
next_action = get_next_action_review(results[current])
else:
next_action = get_next_action(PHASE_NAMES[current], pr_number)
short_title = title[:40] + "..." if len(title) > 40 else title
return f"PR#{pr_number:<5} {short_title:<43} {icon_str} NEXT: {next_action}"
def format_table(pr_numbers: list[int]) -> str:
"""Format table of all open PRs."""
if not pr_numbers:
return "Нет открытых PR"
rows = [format_pr_row(n) for n in pr_numbers]
return "\n".join(rows)
def pr_exists(pr_number: int) -> bool:
"""Check if PR exists via gh CLI."""
rc, _, _ = run_cmd(
["gh", "pr", "view", str(pr_number), "--json", "number", "--repo", get_repo_full_name()]
)
return rc == 0
def main() -> None:
"""Entry point: parse args and dispatch to single-PR or table mode."""
auth_error = check_gh_auth()
if auth_error:
print(auth_error, file=sys.stderr)
sys.exit(1)
if len(sys.argv) > 1:
try:
pr_number = int(sys.argv[1])
except ValueError:
print(f"Некорректный номер PR: {sys.argv[1]}", file=sys.stderr)
sys.exit(1)
if not pr_exists(pr_number):
print(f"PR #{pr_number} не существует", file=sys.stderr)
sys.exit(1)
results = run_all_checks(pr_number)
print(format_single_pr(pr_number, results))
else:
pr_numbers = list_open_pr_numbers()
print(format_table(pr_numbers))
if __name__ == "__main__":
main()

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

@ -0,0 +1,46 @@
#!/bin/bash
set -euo pipefail
MEMORY_DIR="${OPENCODE_MEMORY_DIR}"
REMOTE="${OPENCODE_MEMORY_REMOTE:-https://github.com/slaid098/opencode-memory.git}"
if [ -z "$MEMORY_DIR" ]; then
echo "ERROR: OPENCODE_MEMORY_DIR not set"
exit 1
fi
echo "Setting up memory repo at: $MEMORY_DIR"
# 1. If .git doesn't exist — init + pull
if [ ! -d "$MEMORY_DIR/.git" ]; then
echo "Initializing new memory repo..."
mkdir -p "$MEMORY_DIR"
git init "$MEMORY_DIR"
git -C "$MEMORY_DIR" remote add origin "$REMOTE"
git -C "$MEMORY_DIR" pull origin master 2>/dev/null || echo "No remote memory yet — starting fresh"
else
echo "Memory repo already exists."
# 2. If remote not configured — add it
if ! git -C "$MEMORY_DIR" remote get-url origin 2>/dev/null; then
git -C "$MEMORY_DIR" remote add origin "$REMOTE"
fi
# 3. Pull latest changes
echo "Pulling latest memory..."
git -C "$MEMORY_DIR" pull --rebase origin master 2>/dev/null || echo "Pull failed — continuing with local state"
fi
# 4. Install post-commit hook for auto-push
HOOK="$MEMORY_DIR/.git/hooks/post-commit"
echo "Installing post-commit hook for auto-push..."
cat > "$HOOK" << 'EOF'
#!/bin/bash
git push origin master 2>/dev/null || true
EOF
chmod +x "$HOOK"
echo ""
echo "Memory repo configured successfully."
echo " Remote: $REMOTE"
echo " Auto-push: enabled (post-commit hook)"
echo ""
echo "memory_save() will now auto-push after each commit."

View file

@ -0,0 +1,434 @@
#!/usr/bin/env python3
"""Spec-status oracle: determine current phase of a project spec.
Reads facts from ``docs/spec/*.md`` files (one file per phase) to
deterministically derive the current spec phase no state file, like
``git status`` for the spec pipeline. Repo-aware via
``_resolve_repo_root()`` (cwd-aware, ADR-010) and ``get_repo_full_name()``
via ``git remote get-url origin`` + ``@functools.cache`` (ADR-007).
``gh issue view --repo <org/repo>`` in Phase 8 (ADR-023).
Usage:
python3 config/scripts/spec-status.py # current phase
python3 config/scripts/spec-status.py --validate # all phases detail
Structure of ``docs/spec/``:
meta.md frontmatter: project, type, created, phase, confirmed,
executed, no_db
context.md Phase 1: project description
stack.md Phase 2: stack (default + choices)
modules.md Phase 3: modules + structure tree
db-schema.md Phase 4 (optional absent if no_db: true)
infra.md Phase 5: infra
roadmap.md Phase 6: roadmap with #N issue numbers
Nine phases:
0. DETECT docs/spec/meta.md exists + frontmatter project: key
1. PROJECT_TYPE type/project in frontmatter + context.md filled
2. STACK stack.md filled + mandatory items per project type
3. MODULES modules.md filled + >=1 bullet item
4. DB_SCHEMA no_db: true OR db-schema.md filled
5. INFRA infra.md filled (>=3 chars)
6. ROADMAP roadmap.md filled + >=1 bullet item
7. CONFIRM confirmed: true in meta.md frontmatter
8. EXECUTE executed: true + roadmap #N issues exist (gh view)
"""
from __future__ import annotations
import functools
import re
import subprocess
import sys
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
def _resolve_repo_root() -> Path:
"""Resolve repo root via git (cwd-aware), fallback to script location."""
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False
)
if result.returncode == 0 and result.stdout.strip():
return Path(result.stdout.strip()).resolve()
return Path(__file__).resolve().parent.parent.parent
REPO_ROOT = _resolve_repo_root()
SPEC_DIR = REPO_ROOT / "docs" / "spec"
META_FILE = SPEC_DIR / "meta.md"
PHASE_FILES: dict[int, str] = {
1: "context.md",
2: "stack.md",
3: "modules.md",
4: "db-schema.md",
5: "infra.md",
6: "roadmap.md",
}
VALID_TYPES = {"backend", "fullstack", "mcp-server", "cli", "bot", "worker"}
STACK_REQUIRED: dict[str, list[str]] = {
"backend": ["fastapi", "tortoise", "uv", "pytest", "ruff", "mypy"],
"fullstack": ["fastapi", "tortoise", "react", "vite", "biome", "uv"],
"mcp-server": ["fastapi", "mcp", "patchright", "uv"],
"cli": ["typer", "uv", "hatchling"],
"bot": ["aiogram", "fastapi", "uv"],
"worker": ["prefect", "uv"],
}
PHASE_NAMES = [
"DETECT",
"PROJECT_TYPE",
"STACK",
"MODULES",
"DB_SCHEMA",
"INFRA",
"ROADMAP",
"CONFIRM",
"EXECUTE",
]
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
KV_RE = re.compile(r"^(\w+):\s*(.*?)$", re.MULTILINE)
class PhaseStatus(StrEnum):
"""Phase check result."""
DONE = "DONE"
NOT_DONE = "NOT_DONE"
AMBIGUOUS = "AMBIGUOUS"
@dataclass(frozen=True)
class PhaseResult:
"""Result of a single phase check."""
status: PhaseStatus
detail: str
def run_cmd(args: list[str]) -> tuple[int, str, str]:
"""Run a command, return (returncode, stdout, stderr)."""
result = subprocess.run(args, capture_output=True, text=True, check=False)
return result.returncode, result.stdout, result.stderr
def parse_remote_url(url: str) -> tuple[str, str, str]:
"""Parse git remote URL into (host, org, repo).
Supports both HTTPS and SSH formats:
https://github.com/org/repo.git -> (github.com, org, repo)
git@github.com:org/repo.git -> (github.com, org, repo)
"""
ssh_match = re.match(r"git@([^:]+):([^/]+)/(.+?)(?:\.git)?$", url)
if ssh_match:
return ssh_match.group(1), ssh_match.group(2), ssh_match.group(3)
https_match = re.match(r"https?://([^/]+)/([^/]+)/(.+?)(?:\.git)?$", url)
if https_match:
return https_match.group(1), https_match.group(2), https_match.group(3)
raise ValueError(f"Cannot parse remote URL: {url}")
@functools.cache
def get_repo_full_name() -> str:
"""Return ``org/repo`` from git remote (cached, one call per run).
Used for ``gh issue view --repo <org/repo>`` in Phase 8. Cached via
``functools.cache`` (one git call per process); tests reset via
``get_repo_full_name.cache_clear()``.
"""
rc, out, err = run_cmd(["git", "remote", "get-url", "origin"])
if rc != 0:
raise RuntimeError(f"Cannot get git remote URL: {err.strip()}")
_host, org, repo = parse_remote_url(out.strip())
return f"{org}/{repo}"
def parse_frontmatter(content: str) -> dict[str, str]:
"""Parse simple key: value frontmatter (no nested structures)."""
match = FRONTMATTER_RE.search(content)
if not match:
return {}
fm_text = match.group(1)
return dict(KV_RE.findall(fm_text))
def read_meta() -> tuple[str, dict[str, str]]:
"""Read docs/spec/meta.md content + parsed frontmatter.
Returns ``("", {})`` if meta.md is missing.
"""
if not META_FILE.exists():
return "", {}
content = META_FILE.read_text()
return content, parse_frontmatter(content)
def file_filled(path: Path) -> bool:
"""Return True if path exists and its text content (stripped) is non-empty."""
return path.exists() and bool(path.read_text().strip())
def has_bullet_items(path: Path) -> bool:
"""Return True if file contains >=1 line starting with ``-`` or ``*``."""
if not path.exists():
return False
return any(line.lstrip().startswith(("-", "*")) for line in path.read_text().splitlines())
def check_detect() -> PhaseResult:
"""Phase 0: DETECT — meta.md exists + frontmatter + project: key."""
if not META_FILE.exists():
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/meta.md не найден — инициализируй spec")
_content, fm = read_meta()
if not fm:
return PhaseResult(PhaseStatus.NOT_DONE, "frontmatter пустой — инициализируй spec")
if "project" not in fm:
return PhaseResult(PhaseStatus.NOT_DONE, "frontmatter: ключ project: отсутствует")
return PhaseResult(PhaseStatus.DONE, "spec инициализирован")
def check_project_type() -> PhaseResult:
"""Phase 1: PROJECT_TYPE — type/project in frontmatter + context.md filled."""
_content, fm = read_meta()
if not fm:
return PhaseResult(PhaseStatus.NOT_DONE, "frontmatter пустой")
ptype = fm.get("type", "").strip()
project = fm.get("project", "").strip()
if not ptype or not project:
return PhaseResult(PhaseStatus.NOT_DONE, "type: или project: пустой в frontmatter")
if ptype not in VALID_TYPES:
return PhaseResult(
PhaseStatus.NOT_DONE,
f"type={ptype} невалиден (допустимо: {', '.join(sorted(VALID_TYPES))})",
)
context_file = SPEC_DIR / PHASE_FILES[1]
if not file_filled(context_file):
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/context.md не заполнен")
return PhaseResult(PhaseStatus.DONE, f"{ptype} выбран, project={project}")
def check_stack() -> PhaseResult:
"""Phase 2: STACK — stack.md filled + mandatory items per project type."""
_content, fm = read_meta()
ptype = fm.get("type", "").strip()
if ptype not in VALID_TYPES:
return PhaseResult(
PhaseStatus.NOT_DONE,
f"type={ptype or ''} невалиден, STACK нельзя проверить",
)
stack_file = SPEC_DIR / PHASE_FILES[2]
if not file_filled(stack_file):
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/stack.md не заполнен")
required = STACK_REQUIRED[ptype]
stack_body = stack_file.read_text()
stack_lower = stack_body.lower()
missing = [item for item in required if item not in stack_lower]
if missing:
return PhaseResult(
PhaseStatus.NOT_DONE,
f"не хватает mandatory items: {', '.join(missing)}",
)
return PhaseResult(PhaseStatus.DONE, f"все {len(required)} mandatory items присутствуют")
def check_modules() -> PhaseResult:
"""Phase 3: MODULES — modules.md filled + >=1 bullet item."""
modules_file = SPEC_DIR / PHASE_FILES[3]
if not file_filled(modules_file):
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/modules.md не заполнен")
if not has_bullet_items(modules_file):
return PhaseResult(
PhaseStatus.NOT_DONE,
"docs/spec/modules.md без пунктов (нужен '-' или '*')",
)
bullets = [
line
for line in modules_file.read_text().splitlines()
if line.lstrip().startswith(("-", "*"))
]
return PhaseResult(PhaseStatus.DONE, f"{len(bullets)} модул(ей)")
def check_db_schema() -> PhaseResult:
"""Phase 4: DB_SCHEMA — no_db: true OR db-schema.md filled."""
_content, fm = read_meta()
if fm.get("no_db", "").strip().lower() in {"true", '"true"'}:
return PhaseResult(PhaseStatus.DONE, "no_db: true (DB не нужна)")
db_file = SPEC_DIR / PHASE_FILES[4]
if not file_filled(db_file):
return PhaseResult(
PhaseStatus.NOT_DONE,
"docs/spec/db-schema.md не заполнен (или no_db: true в frontmatter)",
)
return PhaseResult(PhaseStatus.DONE, "docs/spec/db-schema.md заполнен")
def check_infra() -> PhaseResult:
"""Phase 5: INFRA — infra.md filled (>=3 chars)."""
infra_file = SPEC_DIR / PHASE_FILES[5]
if not file_filled(infra_file):
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/infra.md не заполнен")
body = infra_file.read_text().strip()
if len(body) < 3:
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/infra.md пустой (<3 символов)")
return PhaseResult(PhaseStatus.DONE, "docs/spec/infra.md заполнен")
def check_roadmap() -> PhaseResult:
"""Phase 6: ROADMAP — roadmap.md filled + >=1 bullet item."""
roadmap_file = SPEC_DIR / PHASE_FILES[6]
if not file_filled(roadmap_file):
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/roadmap.md не заполнен")
if not has_bullet_items(roadmap_file):
return PhaseResult(
PhaseStatus.NOT_DONE,
"docs/spec/roadmap.md без пунктов (нужен '-' или '*')",
)
bullets = [
line
for line in roadmap_file.read_text().splitlines()
if line.lstrip().startswith(("-", "*"))
]
return PhaseResult(PhaseStatus.DONE, f"{len(bullets)} пунктов в roadmap")
def check_confirm() -> PhaseResult:
"""Phase 7: CONFIRM — confirmed: true in meta.md frontmatter."""
_content, fm = read_meta()
val = fm.get("confirmed", "").strip().lower()
if val not in {"true", '"true"'}:
return PhaseResult(PhaseStatus.NOT_DONE, "confirmed: true отсутствует в frontmatter")
return PhaseResult(PhaseStatus.DONE, "spec подтверждён юзером")
def _extract_issue_numbers(content: str) -> list[int]:
"""Extract #N issue references from roadmap.md content."""
return [int(m) for m in re.findall(r"#(\d+)", content)]
def _check_roadmap_issues(repo: str, issue_nums: list[int]) -> PhaseResult:
"""Phase 8 part: verify each #N issue exists via ``gh issue view --repo``.
Returns DONE if all issues found, NOT_DONE if any missing.
"""
missing: list[int] = []
for num in issue_nums:
rc, _, _ = run_cmd(["gh", "issue", "view", str(num), "--repo", repo])
if rc != 0:
missing.append(num)
if missing:
return PhaseResult(
PhaseStatus.NOT_DONE,
f"issues не созданы/не найдены: {', '.join(f'#{n}' for n in missing)}",
)
return PhaseResult(PhaseStatus.DONE, f"все {len(issue_nums)} issues созданы")
def check_execute() -> PhaseResult:
"""Phase 8: EXECUTE — executed: true + issues created (gh view --repo)."""
_content, fm = read_meta()
val = fm.get("executed", "").strip().lower()
if val not in {"true", '"true"'}:
return PhaseResult(PhaseStatus.NOT_DONE, "executed: true отсутствует в frontmatter")
roadmap_file = SPEC_DIR / PHASE_FILES[6]
if not roadmap_file.exists():
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/roadmap.md не найден для проверки #N")
issue_nums = _extract_issue_numbers(roadmap_file.read_text())
if not issue_nums:
return PhaseResult(PhaseStatus.NOT_DONE, "в roadmap.md нет #N ссылок для проверки")
try:
repo = get_repo_full_name()
except (RuntimeError, ValueError) as exc:
return PhaseResult(PhaseStatus.AMBIGUOUS, f"git remote error: {exc}")
return _check_roadmap_issues(repo, issue_nums)
PHASE_CHECKS = [
check_detect,
check_project_type,
check_stack,
check_modules,
check_db_schema,
check_infra,
check_roadmap,
check_confirm,
check_execute,
]
NEXT_ACTIONS: dict[str, str] = {
"DETECT": "создай docs/spec/meta.md с frontmatter (project, type, created, phase, status)",
"PROJECT_TYPE": "запроси у юзера тип + имя, заполни meta.md frontmatter + создай context.md",
"STACK": "создай docs/spec/stack.md (default stack для типа + choices юзера)",
"MODULES": "запроси модули, создай docs/spec/modules.md (## Модули + ## Структура)",
"DB_SCHEMA": "создай docs/spec/db-schema.md (или no_db: true в meta.md)",
"INFRA": "создай docs/spec/infra.md (Docker, Prefect, MCP, Tunnel)",
"ROADMAP": "создай docs/spec/roadmap.md — N пунктов для будущих issues",
"CONFIRM": "покажи spec юзеру, поставь confirmed: true в meta.md после подтверждения",
"EXECUTE": "создай GitHub issues по roadmap, поставь executed: true в meta.md",
}
STATUS_ICONS: dict[PhaseStatus, str] = {
PhaseStatus.DONE: "",
PhaseStatus.NOT_DONE: "",
PhaseStatus.AMBIGUOUS: "⚠️",
}
def find_current_phase(results: list[PhaseResult]) -> int | None:
"""Return index of first not-done phase, or None if all done."""
for i, result in enumerate(results):
if result.status != PhaseStatus.DONE:
return i
return None
def run_all_checks() -> list[PhaseResult]:
"""Run all 9 phase checks, return results in order."""
return [check() for check in PHASE_CHECKS]
def format_output(results: list[PhaseResult], fm: dict[str, str]) -> str:
"""Format output: 9 phase lines + NEXT or COMPLETE."""
validate = "--validate" in sys.argv[1:]
project = fm.get("project", "").strip() or "<unnamed>"
lines: list[str] = [f"Spec: {project}", ""]
for i, (name, result) in enumerate(zip(PHASE_NAMES, results, strict=True)):
icon = STATUS_ICONS[result.status]
if validate or result.status != PhaseStatus.DONE:
lines.append(f"{icon} {i}. {name:<12} {result.detail}")
else:
lines.append(f"{icon} {i}. {name:<12} done")
lines.append("")
current = find_current_phase(results)
if current is None:
lines.append("Status: COMPLETE")
return "\n".join(lines)
result = results[current]
phase_name = PHASE_NAMES[current]
if result.status == PhaseStatus.AMBIGUOUS:
lines.append(f"AMBIGUOUS: {result.detail}")
lines.append("NEXT: уточните статус вручную")
else:
action = NEXT_ACTIONS.get(phase_name, "уточнить статус")
lines.append(f"NEXT: {action} (Phase {current})")
return "\n".join(lines)
def main() -> None:
"""Entry point: parse args, run checks, print status."""
results = run_all_checks()
_content, fm = read_meta()
print(format_output(results, fm))
if __name__ == "__main__":
main()

View file

@ -0,0 +1,82 @@
---
name: add-skill
description: Use when creating a new opencode skill. Covers file location, frontmatter format, commit, push, and post-instructions for restart. Also when user says "добавь скилл", "создай скилл", "новый навык".
---
# Add Skill
Процесс создания нового скилла в opencode.
## 1. Куда создавать
```
config/skills/<skill-name>/SKILL.md
```
В корне текущего репо (`<repo-root>` = `git rev-parse --show-toplevel`),
в `config/skills/`. НЕ в `~/.config/opencode/` — эта папка синхронизируется из репо.
## 2. Формат SKILL.md
```markdown
---
name: <skill-name>
description: <когда загружать. Триггеры на русском и английском. Например: Use when ... Also when user says "...">
---
# Skill Title
Содержание скилла.
```
### Правила
- `name` — kebab-case, совпадает с именем директории
- `description` — содержит конкретные триггеры (когда агент должен загрузить этот скилл)
- Язык тела — русский с английскими техническими терминами (как в существующих скиллах)
- Один скилл — одна директория с одним `SKILL.md`
### Примеры существующих скиллов
```
config/skills/
├── add-skill/SKILL.md ← этот скилл
├── branch/SKILL.md
├── code-standards/SKILL.md
├── commit/SKILL.md
├── get-project-map/SKILL.md
├── issue/SKILL.md
├── memory/SKILL.md
├── pipeline-driver/SKILL.md
├── python-development/SKILL.md
├── repo-init/SKILL.md
├── run-tests/SKILL.md
└── spec-driver/SKILL.md
```
## 3. Скиллы авто-дискаверятся
Opencode сканирует все под-директории `config/skills/` и подхватывает любой `SKILL.md`. Регистрировать ничего не нужно.
Список скиллов загружается **при старте контейнера**. Новый скилл станет доступен только после рестарта.
## 4. Commit и Push
После создания файла — сразу коммит и пуш по правилам скилла `commit`:
```bash
cd "$(git rev-parse --show-toplevel)"
git add config/skills/<skill-name>/SKILL.md
git commit -m "feat(skills): add <skill-name> skill for <purpose>"
git push
```
## 5. Инструкция пользователю
После push сообщить пользователю:
> Скилл создан и запушен. Чтобы он заработал:
> 1. На хосте: `git pull` в репозитории opencode
> 2. Рестарт контейнера
>
> После рестарта скилл появится в `available_skills` и будет загружаться по триггерам из `description`.

View file

@ -0,0 +1,30 @@
---
name: branch
description: Use when creating a new git branch for work. Describes branch naming convention and creation workflow. Format: type/scope/kebab-description.
---
## Branch format
```
type/scope/kebab-description
```
### Examples
```
feat/auth/add-refresh-token-rotation
fix/api/handle-null-response
chore/deps/update-pytest
refactor/db/simplify-queries
```
### Rules
1. Always branch from `main`
2. Before creating a branch, **discuss with the user**:
- Confirm the branch name
- Confirm the scope
- Make sure you understand the task
3. Push the branch to remote after creation
The type and scope follow the same conventions as commit messages.

View file

@ -0,0 +1,26 @@
---
name: code-standards
description: Универсальные правила разработки для любого языка. Используй когда пишешь, рефакторишь или ревьювишь код.
---
# Code Standards
## 1. Код как рассказ
- **KISS:** Пиши лаконично, без переусложнений. Код читается как последовательный рассказ
- **Функциональный стиль:** Классы — только когда нужно состояние или интерфейс библиотеки. В остальном — функции
- **Приватность:** Внутреннюю логику модуля скрывай. Префикс `_` в Python/JS, `private` в TS/Rust, internal методы по умолчанию
- **Разделение ответственности:** Бизнес-логика ≠ транспорт ≠ представление. Максимум 200-300 строк на файл. Одна ответственность на файл
## 2. Прагматизм
- **YAGNI:** Только то что нужно сейчас. Никаких заделов «на будущее», оверинжиниринга и самодеятельности
- **Правило 80/20:** Если задача ведёт к неоправданному усложнению — предупреди и предложи альтернативу до написания кода
## 3. Принципы модульности
- Функция/метод — не длиннее 30-50 строк. Если больше — разбивай на мелкие тестируемые функции
- Файл — до 200-300 строк. Больше → декомпозиция
- Конфиги/константы — в отдельный файл, не в логику
- Приватные функции/методы для внутренних деталей (с префиксом `_` или аналогом языка)
## 4. Документирование
- Google-style комментарии на английском для всех публичных API
- Описывай **зачем**, а не **что** — код и так говорит что делает

View file

@ -0,0 +1,47 @@
---
name: commit
description: Analyse git history of any project and suggest commit messages that match existing conventions.
---
## Процесс
1. Запустить `git log --oneline -30`
2. Если коммиты есть:
- Извлечь все уникальные `type(scope):` паттерны
- Составить список реальных scopes проекта
- Следовать найденному стилю
3. Если коммитов нет (новый проект):
- Базовый формат: `type(scope): description`
- Типы: `feat | fix | chore | docs | refactor | test | style | perf`
- Scope по умолчанию: спросить пользователя
- Description отвечает на **why** (не what)
- Без точки в конце
- Max ~72 символа
## Type
| Type | When |
|---|---|
| `feat` | New feature |
| `fix` | Bug fix |
| `chore` | Maintenance, cleanup, dependencies, config |
| `refactor` | Code restructuring, no behavior change |
| `docs` | AGENTS.md, SKILL.md, README only |
| `test` | Adding or fixing tests |
| `style` | Formatting, linting, whitespace only |
| `perf` | Performance improvements |
## Branch naming
`type/scope/description` — kebab-case, из тех же scopes.
## Пример вывода для агента
Если `git log` показывает:
```
chore(config): add docker proxy
fix(docker): resolve no-sandbox
feat(agents.md): add guidelines
```
То scopes: `config`, `docker`, `agents.md`. Новый коммит пишется в том же стиле.

View file

@ -0,0 +1,128 @@
---
name: get-project-map
description: Используй этот навык, когда тебе нужно увидеть или актуализировать текущую структуру папок и файлов проекта (особенно после создания/удаления файлов или переключения веток), либо понять расположение пакетов в воркспейсе. Также содержит шаблон для поддержки docs/project-map/.
---
# Навык получения карты проекта (Project Map)
Этот навык позволяет мгновенно получить актуальное дерево каталогов всего репозитория с учетом `.gitignore` без загрузки содержимого самих файлов в контекст.
## Команда для выполнения:
Запусти в терминале следующую команду:
`repomix --no-files --stdout`
## Твои действия:
1. Запусти указанную команду в терминале. Она выведет дерево каталогов и список файлов с их размерами прямо в stdout.
2. Изучи полученную структуру воркспейсов, чтобы точно знать расположение файлов и пакетов.
3. Не сохраняй вывод в файлы на диск — читай его напрямую из вывода терминала.
## Персистентная карта проекта (docs/project-map/)
Помимо живого дерева через `repomix`, в репозитории может быть персистентная карта в `docs/project-map/`. Эта карта обновляется docs-reviewer агентом перед каждым code review.
### Структура
- `docs/project-map/README.md` — индекс, общая структура, список модулей
- `docs/project-map/<module>.md` — один файл на модуль/директорию верхнего уровня
### Шаблон MD-файла модуля
```markdown
---
module: <путь к модулю>
purpose: <назначение в одну строку>
key_files:
- <путь><роль>
- <путь><роль>
dependencies: [<зависимости>]
last_updated: <YYYY-MM-DD>
---
# <имя модуля>
## Структура
- `<файл>`<описание>
- `<файл>`<описание>
## Паттерны
- <используемые паттерны/конвенции>
```
### Что включать
- Структуру директорий (дерево модуля)
- Назначение модуля/директории
- Ключевые файлы и их роли
- Зависимости между модулями
### Что НЕ включать
- Implementation details
- API signatures
- Внутреннюю логику
### Когда обновлять
- Добавлены новые файлы или директории
- Удалены файлы или директории
- Переименованы файлы или директории
- Новые модули верхнего уровня
## Handoff файлы (docs/handoff/)
Контекст передаётся между сессиями через handoff-файлы — один файл на PR.
### Структура
- `docs/handoff/pr-<N>-<slug>.md` — handoff для PR #N
### Шаблон
```markdown
---
pr: <N>
title: <PR title>
merged: <YYYY-MM-DD>
---
## Что сделано
<2-3 строки>
## Почему
<1-2 строки>
## Pending
<что осталось, или "">
## Watch out
<gotchas, или "">
```
## ADR файлы (docs/decisions/)
Архитектурные решения сохраняются в ADR (Architecture Decision Records).
### Структура
- `docs/decisions/<NN>-<title>.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

@ -0,0 +1,121 @@
---
name: issue
description: Создаёт GitHub issue. Issue должны быть самодостаточными — агент в пустом чате может выполнить без доп. контекста. Если задача большая — разбей на несколько маленьких. Используй subagent для создания чтобы не засорять контекст. Also when user says "создай ишью", "создай issue", "заведи задачу", "разбей на подзадачи", "create issue".
---
## Принцип: один issue = один PR
Issue — это атомарная задача, выполнимая за один PR. Если задача касается > 3-5 файлов или содержит независимые изменения → разбей на несколько issue. Каждый под-issue связывается с родительским через `Part of #N`. Родительский issue закрывается только когда все под-issue смержены.
## Самодостаточность issue
Issue должно содержать всё необходимое, чтобы агент в пустом чате (без контекста предыдущей беседы) мог выполнить задачу:
- **Пути к файлам** — конкретные, с номерами строк если применимо (например `src/video_uniq/effects/camera.py:72`)
- **Что менять** — точное описание изменений, не абстрактное «улучшить» или «починить»
- **Примеры из кода** — если нужно показать паттерн, сослаться на конкретный файл и строки
- **Команды проверки** — какие команды запустить после изменений (pytest, ruff, mypy) и какой ожидаемый результат
- **Связанные ресурсы** — ссылки на связанные issue/PR (например `Ref #33`, `Closes #33`)
## Структура body
```markdown
## Контекст
(зачем это нужно, какая проблема решается)
## Что сделать
(пошагово, с путями к файлам)
### Шаг 1: ...
- Файл: `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
## Связанные ресурсы
- Ref #33
- [PR #34](https://github.com/...)
```
## Правило дробления
Перед созданием issue оцени объём:
- 1-3 файлов → один issue
- > 3-5 файлов или несколько независимых изменений → предложи пользователю разбить на несколько issue
- Каждый под-issue самодостаточен (свой контекст, свои пути, своя проверка)
- Связь через `Part of #N` (подзадача) и `Closes #N` (когда подзадача закрывает родительскую)
Пример:
> Пользователь: «Перепиши логику рендеринга, добавь кэширование и почини баг с памятью»
> Агент: «Это 3 независимые задачи. Создам 3 issue: #10 (рендеринг), #11 (кэширование), #12 (баг памяти). Каждый выполним одним PR.»
## Использование subagent для создания issue
Когда получает задачу создать issue:
1. Загрузи навык `issue`
2. Собери контекст (прочитай файлы, пойми задачу)
3. **Запусти subagent** для выполнения `gh issue create` — передай ему готовый title и body
4. Subagent создаёт issue и возвращает URL
5. Сообщи URL пользователю
Это нужно чтобы длинный body issue не засорял контекст основного агента.
## Пример хорошего issue
```markdown
## Контекст
Zoom breathing падает при включённом geometry crop — crop использует probe.width вместо iw.
## Что сделать
### Шаг 1: Заменить probe dimensions на iw/ih выражения
- Файл: `src/video_uniq/effects/camera.py:72`
- Заменить `w, h = probe.width, probe.height` на `iw`/`ih` выражения
## Проверка
- `pytest tests/test_effects.py -x -q --no-cov` → all passed
- `pytest tests/test_new_effects_real.py::test_geometry_crop_with_zoom_breathing_real` → passed
## Связанные ресурсы
- Closes #33
```
## Пример плохого issue
```markdown
**Зачем:** нужно улучшить обработку видео
**Что сделать:** переписать эффекты чтобы не падали
```
Почему плохо: нет путей к файлам, нет конкретных шагов, нет команд проверки, абстрактное описание.
## Команда создания
```bash
gh issue create \
--title "type(scope): description" \
--body "..." \
--label "enhancement"
```
## Пути навыков
Навыки создаются в `config/skills/` в репозитории opencode. НЕ в `~/.config/opencode/skills/` — это маунт из репо. После изменения навыка нужен `git pull` на хосте + рестарт контейнера.
## Полный workflow
После создания issue, цикл продолжается:
1. **Subagent**`task(general)` читает issue, реализует, коммитит, push, создаёт PR. Оркестрация — через `pipeline-driver` skill.
2. **Review**`@reviewer` subagent ревьюит PR (diff, skills, standards), постит комментарий
3. **Merge or Repeat** — APPROVE → squash merge; замечания → fix subagent → re-review → merge
См. `pipeline-driver` skill для деталей PR процесса.

View file

@ -0,0 +1,109 @@
---
name: memory
description: Инструкция по работе с файловой памятью opencode-memory (search + save + retro).
---
# File Memory (opencode-memory)
Графовая память (Graphiti/FalkorDB) удалена — была нестабильна и забагована.
Теперь память работает через `@mathew-cf/opencode-memory` — файловая система с keyword + semantic search.
## Инструменты
| Инструмент | Назначение |
|---|---|
| `memory_search(query, category?)` | Гибридный поиск (keyword + semantic) |
| `memory_list(category?)` | Список категорий / файлов |
| `memory_save()` | Commit + re-index после записи/редактирования |
| `memory_access(path)` | Отметить файл как прочитанный |
| `memory_setup()` | Проверить статус бэкендов |
## Категории
`preferences` · `repos` · `technical` · `people` · `workflows` · `snippets` · `notes`
## Как работать
1. **Перед началом работы**`memory_search` по теме
2. **В процессе** — сохранять находки сразу (контекст свежий)
3. **В конце сессии** — retrospective: что узнал → сохранить, что было в памяти → обновить, чего не хватало → создать
## Когда сохранять
- gotcha / workaround (неочевидное поведение)
- структура репозитория, команды сборки/тестов
- quirks инструментов
- коренные причины багов (root cause)
- указатели: «для X используй Y, осторожно с
## Когда НЕ сохранять
- данные, которые живой API возвращает свежими каждый раз
- текущий статус тасок / PR / спринтов
- копии вики-страниц и API-документации
- то, что находится за <1 минуты из первых принципов
## Структура файла
```
---
title: Человекочитаемый заголовок
tags: [tag1, tag2]
summary: Описание в одну строку
created: YYYY-MM-DD
updated: YYYY-MM-DD
importance: high | medium | low
source: откуда информация
source_date: YYYY-MM-DD
related: [category/file.md]
---
```
## Путь для репозиториев
```
~/opencode-memory/repos/{host}/{org}/{repo}.md
```
## Дистилляция из handoff
После merge PR memory-syncer читает `docs/handoff/pr-<N>-<slug>.md` и дистиллирует durable-записи в `app_data/opencode-memory/repos/{host}/{org}/{repo}.md`. Путь выводится из `git remote get-url origin`.
### Формат записей
```
- [YYYY-MM-DD, PR#N] <суть>
```
Дата и PR-номер в тексте — для RAG-поиска и верификации (какой PR принёс знание).
### Что дистиллировать (durable-only)
- gotchas / workaround (неочевидное поведение)
- паттерны, конвенции репозитория
- указатели: «для X используй Y, осторожно с
- коренные причины багов (root cause)
НЕ дистиллировать: статусы, «сейчас делаем», текущие таски, ephemeral контекст.
### ADR — только указатель
```
- [date, PR#N] ADR-NN: <суть> → docs/decisions/NN-title.md
```
Не копируй содержание ADR — только указатель на файл.
### Править вместо дублирования
Если факт уже записан — обнови запись (bump `updated` в frontmatter). Не создавай дубликаты.
### Квитанция ставится всегда
Даже если durable-записей нет, квитанция обязательна:
```
- [date, PR#N] — (нет durable-записей)
```
Это подтверждает, что memory-sync фаза выполнена (audit trail).

View file

@ -0,0 +1,116 @@
---
name: opencode-config
description: Use when adding, changing, or removing MCP servers, providers, permissions, agents, plugins, or any block in opencode.json. Always writes to config/opencode.json in slaid098/opencode-config repo (bind-mounted to global ~/.config/opencode/). Also when user says "добавь MCP", "подключи интеграцию", "пропиши permissions", "добавь провайдера", "измени конфиг opencode", "куда писать конфиг".
---
# opencode-config
Канонический скилл для правок `opencode.json` в репо `slaid098/opencode-config`. Фиксирует контракт «куда писать конфиг» и форматы блоков.
## 1. Каноническое правило (canonical rule)
- **Всегда** пишем конфиг в `config/opencode.json` в репо `slaid098/opencode-config` → bind-mount `./config:/root/.config/opencode` (docker-compose.yml) → global `/root/.config/opencode/opencode.json`.
- **НЕ создавать** project-local `opencode.json` в других репо (например `.opencode/opencode.json` в `other-repo`).
- **НЕ спрашивать** пользователя «куда писать конфиг» — ответ всегда `config/opencode.json` в `slaid098/opencode-config`.
- **Исключение:** явный override-сценарий (project-local конфиг нужен для изоляции) — тогда указать явно в комментарии к изменению.
Memory: `technical/opencode-config-global-vs-local.md` — детально описывает механизм bind-mount.
## 2. Применение изменений
- `commit` + `push` в репо `slaid098/opencode-config` (через `commit` skill).
- На хосте: `git pull` в корне репо `slaid098/opencode-config`.
- Рестарт контейнера: MCP-серверы, skills, agents грузятся при старте (см. `add-skill/SKILL.md`, ADR-013). До рестарта правки не видны.
- Для Windows bare-metal (`windows/start.bat`): см. ADR-009 — `OPENCODE_CONFIG_DIR` НЕ выставляется (LSP-конфликт с `pyproject.toml` в cwd), конфиг на винде — отдельная задача.
## 3. Структура top-level ключей `opencode.json`
- `$schema` — JSON-schema URL для автокомплита в IDE.
- `plugin` — npm-пакет плагина (например `@mathew-cf/opencode-memory`).
- `skills.paths` — массив путей к skill-директориям (по умолчанию `[".opencode/skills"]`, global из bind-mount добавляется автоматически).
- `compaction` — настройки сжатия контекста.
- `disabled_providers` — массив отключённых провайдеров.
- `provider` — current provider config (см. ниже).
- `permission` — permission rules (read/bash, см. ниже).
- `agent` — per-agent overrides (frontmatter-like, переопределяет per-agent).
- `mcp` — MCP-серверы (remote/local, см. ниже).
## 4. MCP-форматы
**Remote (streamable-HTTP):**
```json
"<name>": {
"type": "remote",
"url": "https://example.com/mcp",
"enabled": true,
"timeout": 300000
}
```
**Local (subprocess):**
```json
"<name>": {
"type": "local",
"command": ["npx", "-y", "<package>"],
"enabled": true
}
```
**Env-плейсхолдеры:** `"{env:VAR_NAME}"` — значение подставляется из env контейнера. **НЕ хардкодить** секреты (API keys, tokens) в JSON. Пример: `"url": "{env:ANTIDETECT_BROWSER_MCP_URL}"`, `"--api-key", "{env:CONTEX7_API_KEY}"`.
**timeout** — в миллисекундах, обязателен для медленных MCP (LLM-агенты, скрапинг). Для быстрых (well-known manifests) — можно опустить (default).
## 5. Provider-формат
```json
"provider": {
"npm": "<package-name>",
"options": {
"baseURL": "https://api.example.com/v1",
"apiKey": "{env:PROVIDER_API_KEY}"
},
"models": {
"<model-id>": {
"limit": { "context": 128000, "output": 8192 },
"reasoning": true,
"modalities": ["text", "image"],
"variants": ["<variant-id>"]
}
}
}
```
## 6. Permissions
- `read` — массив glob-паттернов для разрешённых read-путей (например `["**/*"]` или `["./src/**"]`).
- `bash` — объект `"<pattern>": "<action>"` где:
- `action``"allow"`, `"ask"`, или `"deny"`.
- `pattern` — glob-паттерн bash-команды (например `"git push*"`, `"gh pr merge*"`, `"python3*"`).
- **Семантика `findLast`:** при нескольких матчах побеждает последнее правило (last wins). Это значит порядок правил имеет значение.
- **Три состояния:**
- `allow` — команда выполняется без подтверждения.
- `ask` — opencode спрашивает пользователя перед выполнением.
- `deny` — команда блокируется (deny-лог в `opencode.log`).
- **Guard:** после правок `permission.bash` запускать локально `python3 config/scripts/check-permissions.py` — детектирует опасные паттерны (например `gh pr checks*`, ADR-006). CI (`permissions-check.yml`) запускает тот же скрипт.
- См. ADR-006 (детерминированный guard), ADR-005 (Actions API вместо Checks API в allow-list'ах).
## 7. Gotchas
- **bind-mount требует рестарта:** правки в `config/opencode.json` НЕ видны opencode до рестарта контейнера (MCP/skills/agents грузятся при старте). После commit+push — `git pull` на хосте + `docker compose restart opencode` (или эквивалент).
- **env-плейсхолдеры не хардкод:** секреты в `.env` (не в git), плейсхолдер `"{env:VAR}"` в `opencode.json` (в git). Пример: `ANTIDETECT_BROWSER_MCP_URL`, `CONTEX7_API_KEY`, `CLOUDFLARE_TUNNEL_TOKEN`.
- **`OPENCODE_CONFIG_DIR` env var:** указывает на директорию с `opencode.json`. На сервере задаётся `docker-compose.yml:environment`, на Windows bare-metal НЕ выставляется (ADR-009 — LSP-конфликт с `pyproject.toml` в cwd).
- **`findLast` семантика:** при конфликте правил побеждает последнее. Если добавить `deny` после `allow``deny` wins. Если `allow` после `deny``allow` wins. Порядок имеет значение.
- **CI проверяет permissions:** `permissions-check.yml` запускается на PR с изменениями `config/opencode.json`, `config/agents/**`, `config/scripts/check-permissions.py`. Локальная проверка перед commit: `python3 config/scripts/check-permissions.py` → exit 0, "OK: No dangerous permission rules found."
## 8. Commit message
- Формат: `feat(config): ...` / `chore(config): ...` / `fix(config): ...` (conventional commits, English, ≤72 chars).
- Перед commit — загрузить `commit` skill, проверить `git log --oneline -20`, match existing style.
- Примеры: `feat(config): add integrations.sh MCP server`, `fix(config): correct timeout for integrations discover tool`.
## 9. Не дублировать блоки между репо
- `opencode.json` в `slaid098/opencode-config` — единственный источник правды для global-конфига.
- Project-local `opencode.json` в других репо — только для явного override (например отключить MCP для конкретного проекта). В 99% случаев не нужен.

View file

@ -0,0 +1,147 @@
---
name: pipeline-driver
description: Автономный исполнитель PR-пайплайна. Делегирует 7 фаз subagent'ам, не импровизирует порядок, не мержит при красном CI.
---
# Pipeline Driver
Автономная процедура-loop для проведения PR через 7 фаз. Source of truth для
порядка и действий — `pipeline_status` tool.
## ПРОТОКОЛ (ЖЁСТКО)
Каждая итерация (БЕЗ ИСКЛЮЧЕНИЙ):
1. Вызови tool `pipeline_status({pr_number: M})` — вернёт статус всех фаз + строку `NEXT: <action>`.
2. Если вывод содержит `Status: COMPLETE` → финальный репорт пользователю, exit.
3. Если вывод содержит `AMBIGUOUS` → репорт пользователю с причиной, STOP.
4. Иначе — исполни action из строки `NEXT:` (используй prompt templates A-E ниже).
5. 1 строка прогресса пользователю (формат: `✅ <phase> — <action executed>`).
6. Re-loop (шаг 1).
### ЗАПРЕЩЕНО
- ЛЮБОЙ action БЕЗ предшествующего вызова `pipeline_status` = protocol violation.
- Импровизировать порядок. Решать сам какой subagent запускать — читай `NEXT:`.
- Пропускать вызов `pipeline_status`, даже если «кажется, что фаза уже ✅» — скрипт решает.
- Делать bash `sleep` для ожидания CI — `pipeline_status` сам блокирует до 5 мин (polling Actions API внутри `check_ci`). Один вызов → финальный статус.
- Merge при CI ❌ (transitive guard в скрипте).
- Использовать `--admin` flag для `gh pr merge`.
- Параллелить subagents (последовательно: action → `pipeline_status` → next action).
### Остановы
- Subagent error → 1 retry, потом STOP + report пользователю.
- `AMBIGUOUS` в выводе `pipeline_status` → STOP + report.
- 5 итераций подряд без прогресса (та же фаза ❌) → STOP + report.
## Phase 0: Bootstrap
1. Если задача описана в чате, а не issue → load `issue` skill, создай GitHub
issue N (через subagent с `issue` skill, чтобы не засорять контекст).
2. Запусти subagent (general type) с prompt template A → PR M с `Closes #N` в
body. Subagent вернёт PR номер M.
3. Войди в loop ПРОТОКОЛ выше.
## Prompt templates
### Template A (implement_issue)
```
Реализуй issue #N в текущем репо (working directory = корень репо).
1. Checkout new branch `type/scope/kebab-description` от master.
2. Реализуй по спеке issue (точно, без отклонений). Если спека содержит ошибки,
зафикь и продолжай — не додумывай.
3. Создай handoff + ADR: `bash config/scripts/scaffold-handoff.sh M <slug>`
(M — будет PR номер, используй placeholder `<PR-NUMBER>` в handoff
frontmatter, потом исправишь после `gh pr create`).
4. Коммиты в формате `type(scope): description` (≤72 chars, English, no
period, no body unless necessary). Минимум 3-4 логических коммита.
5. Push и создай PR:
`gh pr create --title "type(scope): description" --body "## Что сделано\n...\n\n## Почему\n...\n\nCloses #N"`
6. После получения PR номера — исправь placeholder `<PR-NUMBER>` в handoff
frontmatter, отдельный коммит `docs(handoff): set PR number`, push.
7. Верни PR номер M.
```
### 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/ && git commit -m
"docs: update project map + handoff + ADR" && git push`.
8. **ВСЕГДА** оставь PR comment (даже если structural changes нет) — это
детерминированный marker для `check_docs` в pipeline-status.py. Без comment
pipeline блокируется на DOCS phase.
`gh pr comment M --body "## Docs Review Summary\n- Project map: ...\n- Handoff: ...\n- ADR: ...\n\n### Verdict: APPROVE|FIXED|NO_CHANGES"`.
Heading `## Docs Review Summary` — обязательно (regex `Docs Review`).
```
### Template C (code_review)
```
Review PR#M в текущем репо.
1. `gh pr view M --json headRefName,body,title`.
2. `git diff origin/master...HEAD`.
3. Load project skills: `find config/skills/ -name "SKILL.md"`, грузи каждый
через `skill("<name>")`.
4. Проверь: code quality, architecture, error handling, security, testing,
duplication, project-specific rules, PR hygiene, handoff/ADR (quick check).
5. Оставь review как PR comment (НЕ `gh pr review --approve` — GitHub блокирует
self-approve):
`gh pr comment M --body "## Code Review Summary\n...\n### Verdict: APPROVE|REQUEST_CHANGES"`.
6. НЕ МЕРДЖИТЬ — merge делает основной агент через pipeline-driver.
```
### Template D (fix_ci)
```
CI упал на PR#M. Чтобы получить <run-id>: `gh run list --branch <headRefName> --limit 1 --json databaseId,conclusion`.
Log: `gh run view <run-id> --log-failed` output:
<log>
1. `gh pr checkout M`.
2. Проанализируй log, найди причину.
3. Исправь (минимальные изменения, whitespace/formatting/logic fix).
4. Коммит `fix(ci): <description>`, push.
5. Не трогай логику unrelated файлов.
```
### Template E (memory_sync)
```
Дистиллируй PR#M в memory file
`app_data/opencode-memory/repos/{host}/{org}/{repo}.md`
(путь относительно корня репо; `{host}/{org}/{repo}` вычисли через
`git remote get-url origin` — см. `memory-syncer.md:38`).
1. Прочитай `docs/handoff/pr-M-*.md` и `docs/decisions/*-pr-M-*.md` с master
(`git checkout master && git pull`).
2. Найди durable gotchas (не статусы, не "сейчас делаем"). Паттерны, указатели,
non-obvious API quirks.
3. Добавь записи формата `- [YYYY-MM-DD, PR#M] <summary>` в конец файла
(секция "Handoff digest").
4. Квитанция ВСЕГДА (даже если durable нет): `- [date, PR#M] — (нет durable-записей)`.
5. `memory_save` для commit + reindex + push.
6. Проверь `git status` основного репо — если staged что-то в `app_data/`,
репорт пользователю (guard от случайного коммита в master).
```
## API Restrictions
Использовать только Actions API (`pipeline_status`, `gh run list`, `gh run view`, `gh api repos/.../actions/runs`). **Запрещено** `gh pr checks` и `gh pr view --json statusCheckRollup` — 403 на fine-grained PAT (scope `Checks: read` не существует).
## Rules
- `pipeline_status` — единственный source of truth для порядка шагов и действий.
- Скрипт read-only (только `gh api`/`gh pr view`, без мутаций).
- После каждой фазы → 1 строка прогресса юзеру.
- Если subagent error → 1 retry, потом STOP + report пользователю.
- `gh pr merge M --squash --delete-branch` (без `--admin`).
- Если reviewer вердикт `REQUEST_CHANGES` → запусти subagent (general) с prompt "fix reviewer comments: <list>", commit, push → re-loop (`pipeline_status` проверит CI автоматически).

View file

@ -0,0 +1,33 @@
---
name: python-development
description: Python-специфика: импорты, логирование, обработка ошибок, тесты. Используй вместе с code-standards для Python-проектов.
---
# Python Development
## 1. Импорты
- Только **абсолютные импорты** от корня проекта. Относительные (`.` и `..`) **запрещены**
- Все импорты — строго в начале файла, перед любым другим кодом. Импорты внутри функций/методов/условий **запрещены**
- Группировка: стандартная библиотека → сторонние пакеты → внутренние модули
- Порядок в группе: по алфавиту
## 2. Запрет global
- Ключевое слово **`global`** запрещено. Передача данных — только через аргументы функций и возвращаемые значения
## 3. Обработка ошибок
- Всегда через `try/except/else`. Блок `else` — для кода без исключений
- Логирование через **Loguru**:
```python
from loguru import logger
try:
# логика
except Exception as ex:
logger.bind(error=ex, error_type=type(ex).__name__).error("Понятное описание контекста ошибки")
```
## 4. Тестирование
- Запуск: `uv run pytest`
- В проекте настроено `asyncio_mode = "auto"`. **Не пиши** `@pytest.mark.asyncio` вручную
- При правке конкретного файла запускай только связанные тесты: `uv run pytest tests/path_to_test.py`
- Цикл исправления: запустил → упало → проанализировал → исправил → перезапустил упавший тест

View file

@ -0,0 +1,82 @@
---
name: release
description: Выполняет релиз после мерджа PR — обновляет CHANGELOG, создаёт git tag и GitHub Release. Используй когда пользователь говорит "сделай релиз", "выпусти версию", "опубликуй", "release", "затегай". Also when user says "сделай релиз", "выпусти версию".
---
## Релиз
Выполняй релиз строго по шагам. НЕ пропускай шаги.
### Шаг 1: Проверки перед релизом
- Убедись, что мы на основной ветке (`main` или `master` — проверь какая основная)
- `git status` — working tree должен быть чистым
- Если нет → **СТОП**, сообщи пользователю
### Шаг 2: Определить версию
- Получи последний тег: `git tag --sort=-version:refname | head -1` (например `v2.7.6`)
- Покажи коммиты с последнего тега: `git log vLAST..HEAD --oneline`
- Предложи версию на основе коммитов:
- `feat(...)` → minor bump (например `v2.7.6``v2.8.0`)
- `fix(...)`, `docs(...)`, `chore(...)` → patch bump (`v2.7.6``v2.7.7`)
- Если коммитов с последнего тега нет → **СТОП**, нечего релизить
- Спроси подтверждение версии у пользователя (через question tool или текстом)
### Шаг 3: Обновить CHANGELOG.md
Проверь формат changelog. Если файл существует и использует [Keep a Changelog](https://keepachangelog.com/) формат:
1. Переименуй `## [Unreleased]` в `## [X.Y.Z] - YYYY-MM-DD` (сегодняшняя дата)
2. Добавь новый пустой `## [Unreleased]` выше
3. Заполни секции на основе коммитов:
- `feat(...)``### Added`
- `fix(...)``### Fixed`
- `chore(...)`, `refactor(...)``### Changed`
- `docs(...)` → можно опустить или `### Changed`
Если changelog в другом формате — адаптируй под существующий стиль.
### Шаг 4: Коммит changelog
```bash
git add CHANGELOG.md
git commit -m "docs: add vX.Y.Z changelog entry"
```
### Шаг 5: Создать tag
Lightweight tag (не annotated):
```bash
git tag vX.Y.Z
```
### Шаг 6: Push
```bash
git push
git push --tags
```
### Шаг 7: GitHub Release
```bash
gh release create vX.Y.Z --title "vX.Y.Z" --notes "<содержание секции из changelog>"
```
### Шаг 8: Отчёт
Сообщи пользователю:
- Версию релиза
- Ссылку на GitHub Release
- Количество коммитов в релизе
## Safety rules
- НИКОГДА не делай релиз, если working tree не чистый
- НИКОГДА не делай релиз, если нет новых коммитов с последнего тега
- НИКОГДА не создавай тег с существующим именем
- ВСЕГДА спрашивай подтверждение версии у пользователя перед коммитом
- НЕ обновляй `pyproject.toml` version (в некоторых репо версионирование через tags)
- НЕ делай `git push --force`

View file

@ -0,0 +1,720 @@
---
name: repo-init
description: Use when creating a new repository or initializing repo settings. Covers GitHub repo creation, branch protection, squash merge, pre-commit hooks, linters (ruff/mypy/xenon for Python, Biome/Knip/Vitest for JS/TS), CI/CD, Dependabot, LICENSE, .gitignore. Also when user says "новый репо", "создай репозиторий", "настрой репо".
---
# Repo Init
Полный чек-лист инициализации нового репозитория. Все шаблоны — внутри, берутся из эталонных репо (video_uniq, yt-video-downloader, opencode-voice-dictation, slaid098-dev).
## Содержание
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-чек-лист-верификации)
---
## 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. Эталон — video_uniq:
```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).
---
## 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

@ -0,0 +1,43 @@
---
name: run-tests
description: Используй этот навык, когда пользователь просит запустить тесты, проверить работоспособность кода, исправить ошибки после правок или запустить pytest.
---
# Навык запуска тестов и исправления ошибок через Pytest
Когда активирован этот навык, следуй строгому алгоритму работы с тестами.
## 1. Команда запуска
Всегда запускай тесты только через менеджер пакетов `uv`:
`uv run pytest`
## 2. Локальный запуск (оптимизация времени)
Если ты правишь конкретный файл, не запускай весь тестовый люкс сразу. Запусти только связанные тесты, чтобы сэкономить время:
`uv run pytest tests/path_to_test_file.py`
## 3. Временные зависимости (НЕ менять pyproject.toml!)
Если для запуска тестов нужны дополнительные пакеты (pydantic, pyyaml, loguru, pillow, dotenv и т.д.) — используй `uv run --with`:
```bash
uv run --with pytest --with pydantic --with pyyaml --with loguru --with pillow --with python-dotenv pytest tests/... -v
```
**Запрещено:** `uv add <package>` — это меняет `pyproject.toml` и `uv.lock` и создаёт лишний коммит.
**Разрешено:** `uv run --with ...` — зависимости ставятся временно, конфиги не трогаются.
## 4. Конвенции проекта (asyncio_mode = "auto")
В `pyproject.toml` настроено `asyncio_mode = "auto"`.
- `pytest-asyncio` автоматически распознаёт асинхронные тест-функции.
- **Не пиши `@pytest.mark.asyncio` вручную** — он не нужен и считается избыточным.
## 5. Алгоритм исправления ошибок (TDD-like):
1. Запусти тесты и дождись вывода.
2. Если тесты упали, проанализируй traceback ошибки.
3. Исправь код или сам тест (если тест устарел).
4. Запусти упавший тест повторно через `uv run pytest tests/path_to_test_file.py::test_name`.
5. Повторяй цикл, пока тест не пройдет успешно.

View file

@ -0,0 +1,324 @@
---
name: spec-driver
description: Автономный исполнитель spec-генерации для нового проекта. Детерминированно ведёт агента по 9 фазам через spec_status tool. Главный агент — оркестратор, делегирует ВСЮ работу subagent'ам. Also when user says "создай спеку", "новый проект", "спецификация проекта", "spec", "project spec".
---
# Spec Driver
Автономная процедура-loop для генерации спецификации нового проекта. Source of
truth для порядка и действий — `spec_status` tool. На выходе — `docs/spec/`
(директория с файлами по фазам) + N GitHub issues, готовых для `/pipeline-driver`.
## ПРОТОКОЛ (ЖЁСТКО)
Каждая итерация (БЕЗ ИСКЛЮЧЕНИЙ):
1. Вызови tool `spec_status({})` — вернёт текущую фазу + строку `NEXT: <action>`.
2. Если вывод содержит `Status: COMPLETE` → финальный репорт пользователю, exit.
3. Если вывод содержит `AMBIGUOUS` → репорт пользователю с причиной, STOP.
4. Иначе — выполни action из строки `NEXT:` (используй prompt templates A-I ниже).
5. 1 строка прогресса пользователю (формат: `✅ <phase> — <action executed>`).
6. Re-loop (шаг 1).
### ЗАПРЕЩЕНО
- ЛЮБОЙ action БЕЗ предшествующего вызова `spec_status` = protocol violation.
- Импровизировать порядок. Решать сам какую фазу выполнять — читай `NEXT:`.
- Пропускать вызов `spec_status`, даже если «кажется, что фаза уже ✅» — скрипт решает.
- bash-запуск `python3 config/scripts/spec-status.py` — детерминированный deny-rule (см. ADR-NNN, аналог ADR-019). Только нативный tool `spec_status`.
- Главному агенту: edit/write/read файлов (всё через subagent), memory_search (через subagent), gh issue create (через subagent).
- Формулировать вопросы не из question templates ниже.
- Предлагать стек вне hardcoded default stack по типу проекта.
- Запускать /pipeline-driver (стоп на issues — дальше юзер сам).
### Остановы
- Subagent error → 1 retry, потом STOP + report пользователю.
- `AMBIGUOUS` в выводе `spec_status` → STOP + report.
- 3 итераций подряд без прогресса (та же фаза ❌) → STOP + report.
## Default stack по типам проекта (хардкод)
Общий для всех типов: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI.
- **backend**: FastAPI + uvicorn, Tortoise ORM + Aerich, pydantic-settings, Loguru
- **fullstack**: backend + frontend/ (React 19 + Vite + Biome + TS strict + Vitest + Knip + happy-dom)
- **mcp-server**: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
- **cli**: Typer (default) / click / argparse, hatchling build
- **bot**: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
- **worker**: Prefect flows + tasks, prefect.yaml, docker-compose worker profile
## 9 фаз
### Phase 0: DETECT (subagent, без вопроса юзеру)
Prompt template A (см. ниже).
### Phase 1: PROJECT_TYPE (вопрос юзеру + subagent)
Вопрос юзеру (один вопрос, multiple choice):
```
Выбери тип проекта:
[1] backend — FastAPI + Tortoise, REST API, без frontend
[2] fullstack — backend + React 19/Vite dashboard (monorepo)
[3] mcp-server — MCP + REST сервер (Patchright/Playwright over CDP)
[4] cli — Python CLI tool (Typer)
[5] bot — Telegram bot (aiogram 3)
[6] worker — Prefect flows / background jobs
Имя проекта (kebab-case): ___
Описание (1 строка): ___
GitHub owner [slaid098]: ___
```
Prompt template B (см. ниже).
### Phase 2: STACK (вопрос юзеру + subagent)
Вопрос юзеру — ТОЛЬКО развилки для выбранного типа:
```
backend:
- DB: [1] Postgres prod / [2] SQLite dev / [3] both / [4] no DB
- Auth: [1] none v1 / [2] JWT / [3] X-API-Key
fullstack:
- frontend: [1] React 19 (default) / [2] SvelteKit / [3] add later
- DB: (same as backend)
- Auth: (same as backend)
mcp-server:
- target: [1] BitBrowser / [2] custom / [3] generic
- auth: [1] X-API-Key / [2] none
cli:
- interface: [1] Typer (default) / [2] click / [3] argparse
- output: [1] rich / [2] plain / [3] loguru
bot:
- framework: [1] aiogram 3 (default) / [2] other
- mode: [1] polling / [2] FastAPI webhook
- Pydantic AI: [1] yes / [2] no
- DB: [1] Tortoise + SQLite / [2] Tortoise + Postgres / [3] no DB
worker:
- scheduler: [1] Prefect (default) / [2] APScheduler
- work_pool_name: ___ (default: <project>_pool)
- DB: [1] Tortoise + SQLite / [2] Tortoise + Postgres / [3] no DB
```
Prompt template C (см. ниже).
### Phase 3: MODULES (вопрос юзеру + subagent с добором)
Вопрос юзеру (free-form):
```
Какие модули/домены нужны? Например: "YouTube uploader, Telegram notifier, channel management".
Опиши модули (1 строка на модуль):
```
Prompt template D (см. ниже, с memory_search).
### Phase 4: DB_SCHEMA (вопрос юзеру + subagent)
Если в Phase 2 выбрано "no DB" → пропустить вопрос, subagent ставит `no_db: true` в `docs/spec/meta.md` (db-schema.md НЕ создаётся).
Иначе вопрос:
```
Опиши ключевые сущности и поля. Например:
"Channel: id UUID, platform enum, name str, is_active bool, metadata json
Upload: id UUID, channel_id FK, video_url str, status enum, ..."
Стандартные поля (вшито, не спрашивай): id UUIDField pk, created_at, updated_at, status CharEnumField(StrEnum).
Опиши сущности:
```
Prompt template E (см. ниже).
### Phase 5: INFRA (вопрос юзеру + subagent)
Вопрос:
```
- Docker compose: [1] yes / [2] no
- Prefect: [1] yes / [2] no (если worker или backend с background jobs)
- MCP external: [1] yes (URL) / [2] no
- Tunnel (demo): [1] yes / [2] no
```
Prompt template F (см. ниже).
### Phase 6: ROADMAP (вопрос юзеру + subagent)
Вопрос (с default proposal):
```
Дефолтный roadmap (можешь править):
1. scaffolding — repo structure, CI, .gitignore, LICENSE (через repo-init skill)
2. core: <module 1> — ...
3. core: <module 2> — ...
4. auth (если выбран auth в Phase 2)
5. db migrations (если есть DB)
6. docker compose (если выбран в Phase 5)
7. frontend scaffolding (если fullstack)
Подтверди или отредактируй:
```
Prompt template G (см. ниже).
### Phase 7: CONFIRM (subagent читает, вопрос юзеру)
Prompt template H (см. ниже).
Вопрос юзеру:
```
Подтверди spec? [1] confirm / [2] edit Phase N (укажи номер)
```
Если edit → вернуться на указанную фазу (3, 4, 5 или 6), повторить, снова Confirm.
### Phase 8: EXECUTE (subagent, без вопроса юзеру)
Prompt template I (см. ниже, create issues).
Финальный репорт юзеру (после Phase 8):
```
Spec complete. Issues: #N1, #N2, ...
Запусти /pipeline-driver для issue #<первый> чтобы начать реализацию.
```
## Prompt templates
### Template A (detect / Phase 0)
```
Контекст: запуск spec-driver в репо <cwd>.
1. `git rev-parse --show-toplevel` → repo root.
2. Если docs/spec/meta.md существует → прочитай frontmatter, верни phase/status.
3. Если нет → создай docs/spec/meta.md с frontmatter:
---
project: ''
type: ''
created: <today YYYY-MM-DD>
phase: 0
status: in_progress
---
(создай директорию docs/spec/ через `mkdir -p docs/spec` если не существует)
4. `memory_search("reference repo")` → верни список релевантных memory paths.
5. Верни: {spec_exists: bool, current_phase: int, references: [...]}.
```
### Template B (project_type / Phase 1)
```
Обнови docs/spec/meta.md frontmatter для Phase 1 (PROJECT_TYPE).
Ответы юзера: type=<type>, project=<name>, description=<desc>, owner=<owner>.
1. Прочитай docs/spec/meta.md.
2. edit frontmatter: type=<type>, project=<name>, created=<today>, phase=1.
3. Создай файл docs/spec/context.md с описанием проекта из ответа юзера.
4. Верни: "done: type=<type>, project=<name>".
```
### Template C (stack / Phase 2)
```
Создай файл docs/spec/stack.md для Phase 2 (STACK).
Ответы юзера: <answers>.
Тип проекта: <type> (из frontmatter meta.md).
Default stack для типа (хардкод, добавить всегда):
- Общий: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI
- backend: FastAPI + uvicorn, Tortoise ORM + Aerich, pydantic-settings, Loguru
- fullstack: + frontend/ (React 19 + Vite + Biome + TS strict + Vitest + Knip + happy-dom)
- mcp-server: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
- cli: Typer (default) / click / argparse, hatchling build
- bot: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
- worker: Prefect flows + tasks, prefect.yaml, docker-compose worker profile
1. Создай docs/spec/stack.md с полным списком (default + choices).
2. edit docs/spec/meta.md frontmatter: phase=2.
3. spec_status валидирует mandatory items через содержимое stack.md — если FAIL, верни что не хватает.
4. Верни: "done: stack.md created, <N>/<M> mandatory items".
```
### Template D (modules / Phase 3, с memory_search)
```
Контекст: Phase 3 (modules) для проекта типа <type>.
Ответ юзера: <answers>.
1. memory_search("reference repo <module>") — доберёт паттерны из reference repos.
2. Сформируй ## Модули (bullet list) + ## Структура (дерево) на основе ответа + референсов.
3. Создай docs/spec/modules.md с обеими секциями. edit docs/spec/meta.md frontmatter phase=3.
4. Верни summary (5-10 строк) для показа юзеру.
```
### Template E (db_schema / Phase 4)
```
Обнови docs/spec для Phase 4 (DB_SCHEMA).
Ответы юзера: <answers> (или "no_db" если выбрано).
1. Если no_db: edit docs/spec/meta.md frontmatter no_db=true (db-schema.md НЕ создаётся).
2. Иначе: создай docs/spec/db-schema.md со сущностями из ответа.
3. edit docs/spec/meta.md frontmatter phase=4.
4. Верни: "done: db-schema updated".
```
### Template F (infra / Phase 5)
```
Обнови docs/spec для Phase 5 (INFRA).
Ответы юзера: <answers>.
1. Создай docs/spec/infra.md.
2. edit docs/spec/meta.md frontmatter phase=5.
3. Верни: "done: infra.md updated".
```
### Template G (roadmap / Phase 6)
```
Обнови docs/spec для Phase 6 (ROADMAP).
Ответы юзера: <answers>.
1. Создай docs/spec/roadmap.md с N пунктами.
2. edit docs/spec/meta.md frontmatter phase=6.
3. Верни: "done: roadmap.md updated, N пунктов".
```
### Template H (confirm / Phase 7)
```
Прочитай все файлы docs/spec/*.md (meta.md, context.md, stack.md, modules.md, db-schema.md если есть, infra.md, roadmap.md).
1. edit docs/spec/meta.md frontmatter confirmed=true, phase=7 (если юзер подтвердил).
2. Верни полный текст spec (все файлы конкатенированные) для показа юзеру.
```
### Template I (execute / Phase 8, create issues)
```
Создай N GitHub issues по roadmap из docs/spec/roadmap.md.
1. Load `issue` skill via `skill({name: "issue"})`.
2. Прочитай docs/spec/stack.md, docs/spec/modules.md, docs/spec/db-schema.md (если есть), docs/spec/infra.md для контекста.
3. Для каждого пункта roadmap (по порядку):
- Сформируй самодостаточный issue body (issue-skill format):
## Контекст
## Что сделать (пошагово с путями к файлам)
## Проверка (команды)
## Связанные ресурсы (Part of spec, ref к docs/spec/roadmap.md)
- Issue #1 (scaffolding) body ДОЛЖЕН включать:
"Используй repo-init skill для: pyproject.toml, CI, .gitignore, LICENSE, dependabot, pre-commit. Структура — из ## Структура в docs/spec/modules.md."
- gh issue create --title "type(scope): description" --body "<body>" --label "enhancement,from-spec"
4. Собери реальные номера issues из вывода gh.
5. Update docs/spec/roadmap.md: добавь реальные #N номера. Update docs/spec/meta.md: executed=true, phase=8.
6. Верни: [{number, url, title}, ...] для всех issues.
```
## Rules
- `spec_status` — единственный source of truth для порядка шагов и действий.
- Скрипт read-only (только presence check в `docs/spec/*.md`, без мутаций).
- После каждой фазы → 1 строка прогресса юзеру.
- Если subagent error → 1 retry, потом STOP + report пользователю.
- Главный агент = оркестратор: `spec_status` tool + вопрос юзеру + task(general) делегирование. Не делает edit/memory_search/gh сам.
- Стоп на issues — дальше юзер сам /pipeline-driver.

View file

@ -0,0 +1,21 @@
import { spawnSync } from "child_process"
import path from "path"
import { tool } from "@opencode-ai/plugin"
export default tool({
description: "Pipeline status oracle. Returns current phase + NEXT action for a PR. Call BEFORE any pipeline action. Read-only. Blocks up to 5 min while CI runs (polling Actions API). Returns DONE on green, NOT_DONE on failure, AMBIGUOUS on timeout/API error.",
args: {
pr_number: tool.schema.number().describe("PR number to check"),
},
async execute(args, context) {
const script = path.join(import.meta.dir, "..", "scripts", "pipeline-status.py")
const r = spawnSync("python3", [script, String(args.pr_number)], {
encoding: "utf-8",
cwd: context.worktree,
})
if (r.status !== 0) {
return `⚠️ pipeline_status failed (exit ${r.status}): ${r.stderr}`
}
return r.stdout.trim()
},
})

View file

@ -0,0 +1,22 @@
import { spawnSync } from "child_process"
import path from "path"
import { tool } from "@opencode-ai/plugin"
export default tool({
description: "Spec status oracle. Returns current phase + NEXT action for spec-driver. Call BEFORE any spec action. Read-only. Returns DONE on phase complete, NOT_DONE on missing section, AMBIGUOUS on parse error.",
args: {
validate: tool.schema.boolean().optional().describe("If true, show all phases detail"),
},
async execute(args, context) {
const script = path.join(import.meta.dir, "..", "scripts", "spec-status.py")
const cmdArgs = args.validate ? ["--validate"] : []
const r = spawnSync("python3", [script, ...cmdArgs], {
encoding: "utf-8",
cwd: context.worktree,
})
if (r.status !== 0) {
return `⚠️ spec_status failed (exit ${r.status}): ${r.stderr}`
}
return r.stdout.trim()
},
})

View file

@ -0,0 +1,21 @@
# ADR-002: Migrate .opencode/ config (project-local auto-discovery)
## Статус
Accepted
## Контекст
Миграция из приватного репо (config/ + bind-mount + OPENCODE_CONFIG_DIR) в публичный (.opencode/ auto-discovery). Старая архитектура: config/ bind-mounted в /root/.config/opencode/. Новая: .opencode/ project-local, auto-discovered, zero env var.
## Решение
- config/* → .opencode/* (agents, commands, skills, tools, scripts, opencode.json, package.json, .gitignore)
- Исключено: AGENTS.md (#10), personal-knowledge (DROP), tunnel (excluded), ssh/ (private)
- Rename slaid098/opencode → slaid098/opencode-config
- Очищены упоминания внутренних проектов (digital_factory, mediakit, media-gen) → generic examples
## Альтернативы
- Сохранить config/ + bind-mount — отклонено (LSP-конфликт ADR-009, не zero-config)
- Дропнуть skills (использовать только глобальные) — отклонено (skills нужны в репо для публичного shareable config)

View file

@ -0,0 +1,34 @@
# PR: Migrate .opencode/ config
## Что сделано
- Перенесён global opencode config из `config/``.opencode/`:
- agents/ (3: docs-reviewer, memory-syncer, reviewer)
- commands/ (3: opencode-config, pipeline-driver, spec-driver)
- skills/ (14: все кроме tunnel и personal-knowledge)
- tools/ (2: pipeline-status.ts, spec-status.ts)
- scripts/ (7: без tunnel.sh)
- opencode.json, package.json, .gitignore
- Rename slaid098/opencode → slaid098/opencode-config (кроме opencode-memory)
- НЕ перенесено: AGENTS.md (#10), personal-knowledge (DROP), tunnel (excluded), ssh/ (private keys)
- Очищены упоминания внутренних проектов (digital_factory, mediakit, media-gen) → generic examples
## Почему
Миграция из приватного репо в публичный. .opencode/ auto-discovery (project-local, zero env var).
## Pending
- Skills cleanup (#11): minor fixes для 7 skills
- opencode-config skill rewrite (#12): canonical rule .opencode/
- issue + repo-init rewrite (#13): delegation model
- commands rename (#14): /run-pipeline, /spec
- pipeline-driver rewrite (#16): merge_pr tool, config/ paths
## Watch out
- skills count: 14 (16 в config/skills/ минус tunnel минус personal-knowledge)
- tools/ относительные пути `../scripts/` — проверены, работают
- opencode.json может содержать {env:VAR} ссылки — не трогать
- scaffold-handoff.sh теперь в .opencode/scripts/ — следующие PR могут использовать
- add-skill/SKILL.md: tunnel/SKILL.md заменён на spec-driver/SKILL.md в example tree

View file

@ -9,9 +9,47 @@ opencode-config/
├── .github/ ├── .github/
│ ├── workflows/ │ ├── workflows/
│ │ ├── ci.yml # Lint, test, typecheck, complexity (bootstrap + output-based skip) │ │ ├── ci.yml # Lint, test, typecheck, complexity (bootstrap + output-based skip)
│ │ ├── permissions-check.yml # .opencode/scripts/permissions.py validator (step-level skip) │ │ ├── permissions-check.yml # .opencode/scripts/check-permissions.py validator (step-level skip)
│ │ └── adr-check.yml # ADR cross-reference validator (step-level skip) │ │ └── adr-check.yml # ADR cross-reference validator (.opencode/scripts/check-adr-refs.py)
│ └── dependabot.yml # pip + github-actions ecosystem updates │ └── dependabot.yml # pip + github-actions ecosystem updates
├── .opencode/ # Project-local opencode config (auto-discovery, zero env var) — PR#23
│ ├── agents/
│ │ ├── docs-reviewer.md # Docs validation subagent (project map + handoff + ADR)
│ │ ├── memory-syncer.md # Distills gotchas from handoffs into opencode-memory
│ │ └── reviewer.md # Code review subagent (verdict APPROVE|REQUEST_CHANGES)
│ ├── commands/
│ │ ├── opencode-config.md # /opencode-config — edit opencode.json
│ │ ├── pipeline-driver.md # /pipeline-driver — 7-phase PR pipeline
│ │ └── spec-driver.md # /spec-driver — 9-phase spec generation
│ ├── skills/
│ │ ├── add-skill/SKILL.md # Create new opencode skill
│ │ ├── branch/SKILL.md # Branch naming conventions
│ │ ├── code-standards/SKILL.md # Universal code style rules
│ │ ├── commit/SKILL.md # Commit message conventions
│ │ ├── get-project-map/SKILL.md # Maintain docs/project-map/
│ │ ├── issue/SKILL.md # GitHub issue creation
│ │ ├── memory/SKILL.md # opencode-memory usage guide
│ │ ├── opencode-config/SKILL.md # Canonical rule: write to .opencode/
│ │ ├── pipeline-driver/SKILL.md # 7-phase pipeline orchestration
│ │ ├── python-development/SKILL.md # Python dev patterns
│ │ ├── release/SKILL.md # Tag + GitHub Release
│ │ ├── repo-init/SKILL.md # New repository bootstrap
│ │ ├── run-tests/SKILL.md # Test runner guide
│ │ └── spec-driver/SKILL.md # 9-phase spec generation
│ ├── tools/
│ │ ├── pipeline-status.ts # pipeline_status tool wrapper
│ │ └── spec-status.ts # spec_status tool wrapper
│ ├── scripts/
│ │ ├── check-adr-refs.py # ADR cross-reference validator (adr-check.yml)
│ │ ├── check-permissions.py # Permissions validator (permissions-check.yml)
│ │ ├── observability.py # OTel spans for tools
│ │ ├── pipeline-status.py # 7-phase oracle (gh PR + CI polling)
│ │ ├── scaffold-handoff.sh # Scaffold handoff + ADR stubs
│ │ ├── setup-memory.sh # opencode-memory bootstrap
│ │ └── spec-status.py # 9-phase spec oracle
│ ├── opencode.json # MCP servers, providers, permissions, agents, plugins
│ ├── package.json # npm deps for tools/*.ts
│ └── .gitignore # Ignores node_modules, etc.
├── docs/ ├── docs/
│ ├── handoff/ # PR handoffs (pr-<N>-<slug>.md) │ ├── handoff/ # PR handoffs (pr-<N>-<slug>.md)
│ ├── decisions/ # ADRs (NNN-pr-<N>-<slug>.md) │ ├── decisions/ # ADRs (NNN-pr-<N>-<slug>.md)
@ -28,7 +66,6 @@ opencode-config/
## Pending (future PRs) ## Pending (future PRs)
- `.opencode/` — global opencode config (agents, skills, tools, scripts) — after #7
- `src/` — Python RAG CLI (second-brain) — after #5 (PR#17) - `src/` — Python RAG CLI (second-brain) — after #5 (PR#17)
- `tests/` — pytest test suite — after #5 - `tests/` — pytest test suite — after #5