From a8f9aa0bc6f773d319a2622d7663f283417e157d Mon Sep 17 00:00:00 2001 From: Sergey <93754860+slaid098@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:57:39 +0300 Subject: [PATCH] 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 --- .opencode/.gitignore | 1 + .opencode/agents/docs-reviewer.md | 241 ++++++ .opencode/agents/memory-syncer.md | 98 +++ .opencode/agents/reviewer.md | 311 +++++++ .opencode/commands/opencode-config.md | 5 + .opencode/commands/pipeline-driver.md | 5 + .opencode/commands/spec-driver.md | 5 + .opencode/opencode.json | 351 ++++++++ .opencode/package.json | 8 + .opencode/scripts/check-adr-refs.py | 96 +++ .opencode/scripts/check-permissions.py | 186 +++++ .opencode/scripts/observability.py | 94 +++ .opencode/scripts/pipeline-status.py | 771 ++++++++++++++++++ .opencode/scripts/scaffold-handoff.sh | 75 ++ .opencode/scripts/setup-memory.sh | 46 ++ .opencode/scripts/spec-status.py | 434 ++++++++++ .opencode/skills/add-skill/SKILL.md | 82 ++ .opencode/skills/branch/SKILL.md | 30 + .opencode/skills/code-standards/SKILL.md | 26 + .opencode/skills/commit/SKILL.md | 47 ++ .opencode/skills/get-project-map/SKILL.md | 128 +++ .opencode/skills/issue/SKILL.md | 121 +++ .opencode/skills/memory/SKILL.md | 109 +++ .opencode/skills/opencode-config/SKILL.md | 116 +++ .opencode/skills/pipeline-driver/SKILL.md | 147 ++++ .opencode/skills/python-development/SKILL.md | 33 + .opencode/skills/release/SKILL.md | 82 ++ .opencode/skills/repo-init/SKILL.md | 720 ++++++++++++++++ .opencode/skills/run-tests/SKILL.md | 43 + .opencode/skills/spec-driver/SKILL.md | 324 ++++++++ .opencode/tools/pipeline-status.ts | 21 + .opencode/tools/spec-status.ts | 22 + .../002-pr-23-migrate-opencode-config.md | 21 + docs/handoff/pr-23-migrate-opencode-config.md | 34 + docs/project-map/README.md | 43 +- 35 files changed, 4873 insertions(+), 3 deletions(-) create mode 100644 .opencode/.gitignore create mode 100644 .opencode/agents/docs-reviewer.md create mode 100644 .opencode/agents/memory-syncer.md create mode 100644 .opencode/agents/reviewer.md create mode 100644 .opencode/commands/opencode-config.md create mode 100644 .opencode/commands/pipeline-driver.md create mode 100644 .opencode/commands/spec-driver.md create mode 100644 .opencode/opencode.json create mode 100644 .opencode/package.json create mode 100755 .opencode/scripts/check-adr-refs.py create mode 100755 .opencode/scripts/check-permissions.py create mode 100755 .opencode/scripts/observability.py create mode 100644 .opencode/scripts/pipeline-status.py create mode 100755 .opencode/scripts/scaffold-handoff.sh create mode 100755 .opencode/scripts/setup-memory.sh create mode 100644 .opencode/scripts/spec-status.py create mode 100644 .opencode/skills/add-skill/SKILL.md create mode 100644 .opencode/skills/branch/SKILL.md create mode 100644 .opencode/skills/code-standards/SKILL.md create mode 100644 .opencode/skills/commit/SKILL.md create mode 100644 .opencode/skills/get-project-map/SKILL.md create mode 100644 .opencode/skills/issue/SKILL.md create mode 100644 .opencode/skills/memory/SKILL.md create mode 100644 .opencode/skills/opencode-config/SKILL.md create mode 100644 .opencode/skills/pipeline-driver/SKILL.md create mode 100644 .opencode/skills/python-development/SKILL.md create mode 100644 .opencode/skills/release/SKILL.md create mode 100644 .opencode/skills/repo-init/SKILL.md create mode 100644 .opencode/skills/run-tests/SKILL.md create mode 100644 .opencode/skills/spec-driver/SKILL.md create mode 100644 .opencode/tools/pipeline-status.ts create mode 100644 .opencode/tools/spec-status.ts create mode 100644 docs/decisions/002-pr-23-migrate-opencode-config.md create mode 100644 docs/handoff/pr-23-migrate-opencode-config.md diff --git a/.opencode/.gitignore b/.opencode/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.opencode/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/.opencode/agents/docs-reviewer.md b/.opencode/agents/docs-reviewer.md new file mode 100644 index 0000000..7063941 --- /dev/null +++ b/.opencode/agents/docs-reviewer.md @@ -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 --json headRefName,title,body` to get branch name and PR context. +2. Run `gh pr checkout ` 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--.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/-.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). diff --git a/.opencode/agents/memory-syncer.md b/.opencode/agents/memory-syncer.md new file mode 100644 index 0000000..f77cf77 --- /dev/null +++ b/.opencode/agents/memory-syncer.md @@ -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, переключаться не нужно. diff --git a/.opencode/agents/reviewer.md b/.opencode/agents/reviewer.md new file mode 100644 index 0000000..0e9faca --- /dev/null +++ b/.opencode/agents/reviewer.md @@ -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). \ No newline at end of file diff --git a/.opencode/commands/opencode-config.md b/.opencode/commands/opencode-config.md new file mode 100644 index 0000000..6161677 --- /dev/null +++ b/.opencode/commands/opencode-config.md @@ -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. \ No newline at end of file diff --git a/.opencode/commands/pipeline-driver.md b/.opencode/commands/pipeline-driver.md new file mode 100644 index 0000000..533d494 --- /dev/null +++ b/.opencode/commands/pipeline-driver.md @@ -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. \ No newline at end of file diff --git a/.opencode/commands/spec-driver.md b/.opencode/commands/spec-driver.md new file mode 100644 index 0000000..af7d7cc --- /dev/null +++ b/.opencode/commands/spec-driver.md @@ -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. \ No newline at end of file diff --git a/.opencode/opencode.json b/.opencode/opencode.json new file mode 100644 index 0000000..c74e733 --- /dev/null +++ b/.opencode/opencode.json @@ -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 + } + } + } \ No newline at end of file diff --git a/.opencode/package.json b/.opencode/package.json new file mode 100644 index 0000000..49cae23 --- /dev/null +++ b/.opencode/package.json @@ -0,0 +1,8 @@ +{ + "scripts": { + "test": "bun test config/plugins/" + }, + "dependencies": { + "@opencode-ai/plugin": "1.18.3" + } +} diff --git a/.opencode/scripts/check-adr-refs.py b/.opencode/scripts/check-adr-refs.py new file mode 100755 index 0000000..2b1b244 --- /dev/null +++ b/.opencode/scripts/check-adr-refs.py @@ -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() diff --git a/.opencode/scripts/check-permissions.py b/.opencode/scripts/check-permissions.py new file mode 100755 index 0000000..09506e2 --- /dev/null +++ b/.opencode/scripts/check-permissions.py @@ -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() diff --git a/.opencode/scripts/observability.py b/.opencode/scripts/observability.py new file mode 100755 index 0000000..54fb0f8 --- /dev/null +++ b/.opencode/scripts/observability.py @@ -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() diff --git a/.opencode/scripts/pipeline-status.py b/.opencode/scripts/pipeline-status.py new file mode 100644 index 0000000..15bdaed --- /dev/null +++ b/.opencode/scripts/pipeline-status.py @@ -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() diff --git a/.opencode/scripts/scaffold-handoff.sh b/.opencode/scripts/scaffold-handoff.sh new file mode 100755 index 0000000..4668d29 --- /dev/null +++ b/.opencode/scripts/scaffold-handoff.sh @@ -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" \ No newline at end of file diff --git a/.opencode/scripts/setup-memory.sh b/.opencode/scripts/setup-memory.sh new file mode 100755 index 0000000..317dc30 --- /dev/null +++ b/.opencode/scripts/setup-memory.sh @@ -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." diff --git a/.opencode/scripts/spec-status.py b/.opencode/scripts/spec-status.py new file mode 100644 index 0000000..90c61af --- /dev/null +++ b/.opencode/scripts/spec-status.py @@ -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() diff --git a/.opencode/skills/add-skill/SKILL.md b/.opencode/skills/add-skill/SKILL.md new file mode 100644 index 0000000..5c20d87 --- /dev/null +++ b/.opencode/skills/add-skill/SKILL.md @@ -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`. diff --git a/.opencode/skills/branch/SKILL.md b/.opencode/skills/branch/SKILL.md new file mode 100644 index 0000000..f043e97 --- /dev/null +++ b/.opencode/skills/branch/SKILL.md @@ -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. diff --git a/.opencode/skills/code-standards/SKILL.md b/.opencode/skills/code-standards/SKILL.md new file mode 100644 index 0000000..e32c260 --- /dev/null +++ b/.opencode/skills/code-standards/SKILL.md @@ -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 +- Описывай **зачем**, а не **что** — код и так говорит что делает diff --git a/.opencode/skills/commit/SKILL.md b/.opencode/skills/commit/SKILL.md new file mode 100644 index 0000000..84f3a36 --- /dev/null +++ b/.opencode/skills/commit/SKILL.md @@ -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`. Новый коммит пишется в том же стиле. diff --git a/.opencode/skills/get-project-map/SKILL.md b/.opencode/skills/get-project-map/SKILL.md new file mode 100644 index 0000000..ad56d66 --- /dev/null +++ b/.opencode/skills/get-project-map/SKILL.md @@ -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 diff --git a/.opencode/skills/issue/SKILL.md b/.opencode/skills/issue/SKILL.md new file mode 100644 index 0000000..99f491e --- /dev/null +++ b/.opencode/skills/issue/SKILL.md @@ -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 процесса. \ No newline at end of file diff --git a/.opencode/skills/memory/SKILL.md b/.opencode/skills/memory/SKILL.md new file mode 100644 index 0000000..19fc746 --- /dev/null +++ b/.opencode/skills/memory/SKILL.md @@ -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). diff --git a/.opencode/skills/opencode-config/SKILL.md b/.opencode/skills/opencode-config/SKILL.md new file mode 100644 index 0000000..d19e031 --- /dev/null +++ b/.opencode/skills/opencode-config/SKILL.md @@ -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% случаев не нужен. \ No newline at end of file diff --git a/.opencode/skills/pipeline-driver/SKILL.md b/.opencode/skills/pipeline-driver/SKILL.md new file mode 100644 index 0000000..35612d1 --- /dev/null +++ b/.opencode/skills/pipeline-driver/SKILL.md @@ -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 автоматически). \ No newline at end of file diff --git a/.opencode/skills/python-development/SKILL.md b/.opencode/skills/python-development/SKILL.md new file mode 100644 index 0000000..2390fb8 --- /dev/null +++ b/.opencode/skills/python-development/SKILL.md @@ -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` +- Цикл исправления: запустил → упало → проанализировал → исправил → перезапустил упавший тест diff --git a/.opencode/skills/release/SKILL.md b/.opencode/skills/release/SKILL.md new file mode 100644 index 0000000..6f79b86 --- /dev/null +++ b/.opencode/skills/release/SKILL.md @@ -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` \ No newline at end of file diff --git a/.opencode/skills/repo-init/SKILL.md b/.opencode/skills/repo-init/SKILL.md new file mode 100644 index 0000000..bffb3b4 --- /dev/null +++ b/.opencode/skills/repo-init/SKILL.md @@ -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` закоммичен diff --git a/.opencode/skills/run-tests/SKILL.md b/.opencode/skills/run-tests/SKILL.md new file mode 100644 index 0000000..6ae9649 --- /dev/null +++ b/.opencode/skills/run-tests/SKILL.md @@ -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. Повторяй цикл, пока тест не пройдет успешно. diff --git a/.opencode/skills/spec-driver/SKILL.md b/.opencode/skills/spec-driver/SKILL.md new file mode 100644 index 0000000..909e265 --- /dev/null +++ b/.opencode/skills/spec-driver/SKILL.md @@ -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. \ No newline at end of file diff --git a/.opencode/tools/pipeline-status.ts b/.opencode/tools/pipeline-status.ts new file mode 100644 index 0000000..ef29956 --- /dev/null +++ b/.opencode/tools/pipeline-status.ts @@ -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() + }, +}) \ No newline at end of file diff --git a/.opencode/tools/spec-status.ts b/.opencode/tools/spec-status.ts new file mode 100644 index 0000000..5a7aa60 --- /dev/null +++ b/.opencode/tools/spec-status.ts @@ -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() + }, +}) \ No newline at end of file diff --git a/docs/decisions/002-pr-23-migrate-opencode-config.md b/docs/decisions/002-pr-23-migrate-opencode-config.md new file mode 100644 index 0000000..26ac1a8 --- /dev/null +++ b/docs/decisions/002-pr-23-migrate-opencode-config.md @@ -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) diff --git a/docs/handoff/pr-23-migrate-opencode-config.md b/docs/handoff/pr-23-migrate-opencode-config.md new file mode 100644 index 0000000..07cb41e --- /dev/null +++ b/docs/handoff/pr-23-migrate-opencode-config.md @@ -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 diff --git a/docs/project-map/README.md b/docs/project-map/README.md index 66cbaa1..975c6cd 100644 --- a/docs/project-map/README.md +++ b/docs/project-map/README.md @@ -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