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:
parent
1dbec56140
commit
a8f9aa0bc6
35 changed files with 4873 additions and 3 deletions
1
.opencode/.gitignore
vendored
Normal file
1
.opencode/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
||||
241
.opencode/agents/docs-reviewer.md
Normal file
241
.opencode/agents/docs-reviewer.md
Normal 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).
|
||||
98
.opencode/agents/memory-syncer.md
Normal file
98
.opencode/agents/memory-syncer.md
Normal 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, переключаться не нужно.
|
||||
311
.opencode/agents/reviewer.md
Normal file
311
.opencode/agents/reviewer.md
Normal 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).
|
||||
5
.opencode/commands/opencode-config.md
Normal file
5
.opencode/commands/opencode-config.md
Normal 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.
|
||||
5
.opencode/commands/pipeline-driver.md
Normal file
5
.opencode/commands/pipeline-driver.md
Normal 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.
|
||||
5
.opencode/commands/spec-driver.md
Normal file
5
.opencode/commands/spec-driver.md
Normal 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
351
.opencode/opencode.json
Normal 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
8
.opencode/package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"scripts": {
|
||||
"test": "bun test config/plugins/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.18.3"
|
||||
}
|
||||
}
|
||||
96
.opencode/scripts/check-adr-refs.py
Executable file
96
.opencode/scripts/check-adr-refs.py
Executable 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()
|
||||
186
.opencode/scripts/check-permissions.py
Executable file
186
.opencode/scripts/check-permissions.py
Executable 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()
|
||||
94
.opencode/scripts/observability.py
Executable file
94
.opencode/scripts/observability.py
Executable 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()
|
||||
771
.opencode/scripts/pipeline-status.py
Normal file
771
.opencode/scripts/pipeline-status.py
Normal 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()
|
||||
75
.opencode/scripts/scaffold-handoff.sh
Executable file
75
.opencode/scripts/scaffold-handoff.sh
Executable 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"
|
||||
46
.opencode/scripts/setup-memory.sh
Executable file
46
.opencode/scripts/setup-memory.sh
Executable 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."
|
||||
434
.opencode/scripts/spec-status.py
Normal file
434
.opencode/scripts/spec-status.py
Normal 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()
|
||||
82
.opencode/skills/add-skill/SKILL.md
Normal file
82
.opencode/skills/add-skill/SKILL.md
Normal 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`.
|
||||
30
.opencode/skills/branch/SKILL.md
Normal file
30
.opencode/skills/branch/SKILL.md
Normal 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.
|
||||
26
.opencode/skills/code-standards/SKILL.md
Normal file
26
.opencode/skills/code-standards/SKILL.md
Normal 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
|
||||
- Описывай **зачем**, а не **что** — код и так говорит что делает
|
||||
47
.opencode/skills/commit/SKILL.md
Normal file
47
.opencode/skills/commit/SKILL.md
Normal 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`. Новый коммит пишется в том же стиле.
|
||||
128
.opencode/skills/get-project-map/SKILL.md
Normal file
128
.opencode/skills/get-project-map/SKILL.md
Normal 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
|
||||
121
.opencode/skills/issue/SKILL.md
Normal file
121
.opencode/skills/issue/SKILL.md
Normal 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 процесса.
|
||||
109
.opencode/skills/memory/SKILL.md
Normal file
109
.opencode/skills/memory/SKILL.md
Normal 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, осторожно с Z»
|
||||
|
||||
## Когда НЕ сохранять
|
||||
|
||||
- данные, которые живой 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, осторожно с Z»
|
||||
- коренные причины багов (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).
|
||||
116
.opencode/skills/opencode-config/SKILL.md
Normal file
116
.opencode/skills/opencode-config/SKILL.md
Normal 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% случаев не нужен.
|
||||
147
.opencode/skills/pipeline-driver/SKILL.md
Normal file
147
.opencode/skills/pipeline-driver/SKILL.md
Normal 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 автоматически).
|
||||
33
.opencode/skills/python-development/SKILL.md
Normal file
33
.opencode/skills/python-development/SKILL.md
Normal 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`
|
||||
- Цикл исправления: запустил → упало → проанализировал → исправил → перезапустил упавший тест
|
||||
82
.opencode/skills/release/SKILL.md
Normal file
82
.opencode/skills/release/SKILL.md
Normal 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`
|
||||
720
.opencode/skills/repo-init/SKILL.md
Normal file
720
.opencode/skills/repo-init/SKILL.md
Normal 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` закоммичен
|
||||
43
.opencode/skills/run-tests/SKILL.md
Normal file
43
.opencode/skills/run-tests/SKILL.md
Normal 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. Повторяй цикл, пока тест не пройдет успешно.
|
||||
324
.opencode/skills/spec-driver/SKILL.md
Normal file
324
.opencode/skills/spec-driver/SKILL.md
Normal 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.
|
||||
21
.opencode/tools/pipeline-status.ts
Normal file
21
.opencode/tools/pipeline-status.ts
Normal 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()
|
||||
},
|
||||
})
|
||||
22
.opencode/tools/spec-status.ts
Normal file
22
.opencode/tools/spec-status.ts
Normal 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()
|
||||
},
|
||||
})
|
||||
21
docs/decisions/002-pr-23-migrate-opencode-config.md
Normal file
21
docs/decisions/002-pr-23-migrate-opencode-config.md
Normal 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)
|
||||
34
docs/handoff/pr-23-migrate-opencode-config.md
Normal file
34
docs/handoff/pr-23-migrate-opencode-config.md
Normal 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
|
||||
|
|
@ -9,9 +9,47 @@ opencode-config/
|
|||
├── .github/
|
||||
│ ├── workflows/
|
||||
│ │ ├── ci.yml # Lint, test, typecheck, complexity (bootstrap + output-based skip)
|
||||
│ │ ├── permissions-check.yml # .opencode/scripts/permissions.py validator (step-level skip)
|
||||
│ │ └── adr-check.yml # ADR cross-reference validator (step-level skip)
|
||||
│ │ ├── permissions-check.yml # .opencode/scripts/check-permissions.py validator (step-level skip)
|
||||
│ │ └── adr-check.yml # ADR cross-reference validator (.opencode/scripts/check-adr-refs.py)
|
||||
│ └── 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/
|
||||
│ ├── handoff/ # PR handoffs (pr-<N>-<slug>.md)
|
||||
│ ├── decisions/ # ADRs (NNN-pr-<N>-<slug>.md)
|
||||
|
|
@ -28,7 +66,6 @@ opencode-config/
|
|||
|
||||
## Pending (future PRs)
|
||||
|
||||
- `.opencode/` — global opencode config (agents, skills, tools, scripts) — after #7
|
||||
- `src/` — Python RAG CLI (second-brain) — after #5 (PR#17)
|
||||
- `tests/` — pytest test suite — after #5
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue