Compare commits

..

62 commits

Author SHA1 Message Date
opencode-agent
a08385c9dd ci: use preinstalled tools from custom runner image (drop setup-uv/python/node actions)
All checks were successful
CI / bootstrap (push) Successful in 22s
CI / complexity (push) Successful in 41s
CI / lint (push) Successful in 41s
CI / typecheck (push) Successful in 41s
CI / test (3.13) (push) Successful in 1m43s
2026-08-06 16:30:34 +00:00
opencode-agent
7e4dfcc38b ci: reduce python matrix to 3.13 and enable uv/npm caches
Some checks failed
CI / bootstrap (push) Successful in 22s
CI / typecheck (push) Failing after 1m49s
CI / complexity (push) Successful in 2m10s
CI / lint (push) Successful in 2m11s
CI / test (3.13) (push) Successful in 3m41s
2026-08-06 16:02:15 +00:00
opencode-agent
d16c2434a3 docs(env-example): add Forgejo API env vars
Some checks failed
CI / bootstrap (push) Successful in 17s
CI / lint (push) Failing after 14s
CI / typecheck (push) Successful in 40s
CI / complexity (push) Successful in 49s
CI / test (3.14) (push) Successful in 2m24s
CI / test (3.13) (push) Successful in 2m42s
CI / test (3.12) (push) Successful in 2m56s
2026-08-06 15:51:23 +00:00
opencode-agent
ab0c3dd68d test(ci): add timeout to _gh_available to prevent hang on Forgejo runner
Some checks failed
CI / bootstrap (push) Successful in 5s
CI / typecheck (push) Failing after 14s
CI / complexity (push) Successful in 43s
CI / test (3.12) (push) Successful in 2m30s
CI / test (3.13) (push) Successful in 2m32s
CI / test (3.14) (push) Successful in 2m26s
CI / lint (push) Successful in 29s
2026-08-06 15:48:02 +00:00
opencode-agent
9dabc57d9a feat(oracles): add Forgejo backend to status scripts
Some checks failed
CI / bootstrap (push) Successful in 9s
CI / lint (push) Successful in 21s
CI / typecheck (push) Successful in 25s
CI / complexity (push) Successful in 30s
CI / test (3.14) (push) Successful in 1m47s
CI / test (3.13) (push) Successful in 1m48s
CI / test (3.12) (push) Failing after 13m11s
2026-08-06 15:25:42 +00:00
opencode-agent
0fbf1d97bc feat(create-readme): support Forgejo contents API
All checks were successful
CI / bootstrap (push) Successful in 11s
CI / lint (push) Successful in 26s
CI / typecheck (push) Successful in 33s
CI / test (3.12) (push) Successful in 1m39s
CI / test (3.13) (push) Successful in 2m21s
CI / test (3.14) (push) Successful in 1m26s
CI / complexity (push) Successful in 20s
2026-08-06 15:11:23 +00:00
opencode-agent
93e7843438 feat(tools): add Forgejo backend with gh CLI fallback
All checks were successful
CI / bootstrap (push) Successful in 7s
CI / lint (push) Successful in 17s
CI / typecheck (push) Successful in 25s
CI / test (3.12) (push) Successful in 2m48s
CI / test (3.13) (push) Successful in 1m41s
CI / test (3.14) (push) Successful in 1m34s
CI / complexity (push) Successful in 21s
2026-08-06 15:02:20 +00:00
opencode-agent
77143ac6da test(project-status): skip exec_real when gh CLI unavailable
All checks were successful
CI / bootstrap (push) Successful in 7s
CI / lint (push) Successful in 20s
CI / typecheck (push) Successful in 28s
CI / test (3.12) (push) Successful in 1m41s
CI / test (3.13) (push) Successful in 1m35s
CI / test (3.14) (push) Successful in 1m28s
CI / complexity (push) Successful in 29s
2026-08-06 14:45:18 +00:00
Sergey
9459e47567
feat(spec): mobile-first silent enforcement in STACK_REQUIRED + CI e2e (#281)
Some checks failed
CI / bootstrap (push) Successful in 52s
CI / lint (push) Successful in 2m2s
CI / typecheck (push) Successful in 27s
CI / test (3.12) (push) Failing after 2m53s
CI / test (3.13) (push) Failing after 2m0s
CI / test (3.14) (push) Failing after 1m48s
CI / complexity (push) Successful in 23s
* feat(spec): mobile-first silent enforcement in STACK_REQUIRED + Template C

* feat(ci): frontend-e2e job with Playwright in fullstack cookiecutter

* docs(skills): mention mobile-first in audit, code-standards, project-status tool

* fix(ci): use npm install instead of npm ci + add e2e regression test

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-05 07:05:14 +03:00
Sergey
6bb115e706
feat(fullstack): mobile-first guarantee — cookiecutter + project-status (#280)
* feat(scripts): add MOBILE_FIRST_MARKERS + _check_mobile_first check

* feat(templates): fullstack mobile-first PWA manifest + mobile Playwright + axe

* test(scripts): cover mobile-first markers, check, cookiecutter structure

* fix(ci): reformat test_project_status.py string literals

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-05 06:46:20 +03:00
Sergey
43975cb593
refactor(scripts): project-status all-WARN non-blocking contract (#277)
* refactor(scripts): project-status all-WARN non-blocking contract

* refactor(skills): parse WARN instead of FAIL in audit and project-template

* test(scripts): update project-status asserts FAIL to WARN, exit code always 0

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-05 06:10:06 +03:00
Sergey
c9198a40a7
fix(scripts): project-status fullstack backend/ path + db/models + no_db removal (#276)
* refactor(contract): drop NO_DB_SUPPORTED set (issue #274)

* fix(scripts): route backend checks via backend_root + auto-detect db/models from deps

* refactor(templates): drop no_db marker write from cookiecutter hooks

* test(scripts): cover db/models auto-detect + fullstack all-groups + use_db=no detect

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-05 05:44:42 +03:00
Sergey
fa6fc9ecbd
fix(cover): remove stale create-readme validate references (#273)
* fix(cover): replace stale create-readme validate in step 7

* fix(cover): drop create-readme validate edge case mention

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-05 04:23:02 +03:00
Sergey
ca2a04ce47
refactor(templates): drop README from cookiecutter templates (#272)
* refactor(templates): remove README.md from cookiecutter templates

* test(templates): drop README expectations, expect WARN instead

* docs(skills): note README is generated post-init via repo-readme

* docs(skills): repo-readme creates README from scratch post-init

* fix(templates): drop readme field from pyproject after README removal

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-05 04:15:43 +03:00
Sergey
70b8203704
feat(readme): switch support link to /contacts (#270)
* feat(tool): switch create-readme support link to /contacts

* feat(scripts): warn on deprecated support link in readme check

* feat(templates): switch cookiecutter readme support links to contacts

* docs(skill): drop removed validate mode from repo-readme docs

* test(status): cover contacts link warn/fail/ok cases

* fix(ci): shorten lines to satisfy ruff

* fix(ci): apply ruff format

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-05 03:54:59 +03:00
Sergey
c0e8ec816a
refactor(scripts): unify project contract + fix no_db polarity + frontend stack detection (#267)
* refactor(scripts): add project_contract.py single source of truth

* fix(spec-status): word-boundary regex for stack matching (uv!=uvicorn)

* feat(project-status): frontend stack detection + no_db support

* fix(template): cookiecutter use_db=no writes no_db=true marker

* fix(ci): ruff SIM300/PLC0415/W292/E501 in project_contract tests

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-04 23:20:11 +03:00
Sergey
7c3ded601f
feat(template): bake shadcn-svelte + Tailwind v4 + TS into fullstack cookiecutter (#265)
* feat(template): fullstack cookiecutter — Tailwind v4 + TS + shadcn-svelte scaffold

* feat(template): fullstack — shadcn-svelte ui components + dashboard layout

* refactor(template): fullstack — remove counter/Header demo files

* feat(spec): add tailwind/shadcn/ts to STACK_REQUIRED fullstack

* test(template): update fullstack cookiecutter assertions for shadcn-svelte

* fix(ci): ruff format test_cookiecutter_templates

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-04 22:08:58 +03:00
Sergey
e4f9313641
fix(templates): validate cookiecutter project_name as python identifier (#263)
* fix(templates): add pre_gen_project hook validating project_name identifier

* fix(templates): use valid identifier defaults in cookiecutter.json

* test(templates): add regression tests for project_name identifier validation

* fix(tests): resolve fullstack package name via backend ctx

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-04 14:36:33 +03:00
Sergey
06899b3c01
fix(infra): fullstack api dirs include package name (#261)
* fix(infra): fullstack _api_dirs_for includes package name in path

* test(infra): fullstack thin-routes check coverage for issue #259

* fix(ci): reformat assert in test_project_status for ruff

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-04 13:55:52 +03:00
Sergey
8b05fa74f7
fix(skills): sync SDD headings to 8 in issue and bug-discovery (#260)
* fix(skills): add 8th SDD heading to issue and bug-discovery

* fix(skills): sync spec and audit SDD headings to 8

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-04 13:43:32 +03:00
Sergey
13942526a1
feat(skills): audit skill + /audit command for one-command project audit (#257)
* feat(skills): add audit skill for one-command project audit

* feat(commands): add /audit command as thin wrapper for audit skill

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-04 13:37:03 +03:00
Sergey
8e00969c3e
feat(skills): code-standards architecture + tests sections, scattered-models check (#256)
* feat(skills): code-standards architecture + tests sections

* feat(infra): scattered-models AST check in project-status

* test(infra): scattered-models check tests (10 cases)

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-04 13:31:15 +03:00
Sergey
aa1e5c08ae
feat(agents): reviewer cross-file impact analysis + paired update specs (#255)
* feat(agents): add Section 10 cross-file impact analysis to reviewer

* feat(agents): extend reviewer investigation budget for cross-file

* feat(agents): add cross-file spec template and examples to reviewer

* feat(agents): add cross-file edge cases verdict mapping to reviewer

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-04 13:18:08 +03:00
Sergey
feea1b58d9
feat(skills): feature-spec impact analysis + 8th SDD section (#254)
* feat(skills): feature-spec impact analysis + 8th SDD section

* feat(tools): validate 8th SDD section in create-issue

* test(tools): cover 8th SDD section in create-issue tests

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-04 13:05:37 +03:00
Sergey
baf6b30547
fix(infra): AST route imports + check_tests structure + check_readme full port (#248)
* feat(infra): AST route imports check in project-status

* feat(infra): check_tests structure + AST stub-detector

* feat(infra): check_readme full port from validateReadme

* refactor(infra): drop validate mode from create-readme tool

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 20:50:04 +03:00
Sergey
93911f2af6
refactor(infra): eliminate globals + split check_pyproject in project-status (#247)
* refactor(infra): introduce RepoCtx, eliminate globals in project-status

* refactor(infra): split check_pyproject into 13 sub-functions

* test(infra): add RepoCtx + 13 sub-function tests, migrate cookiecutter tests

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 20:34:04 +03:00
Sergey
10df459cee
fix(infra): enforce nested src/<package>/ layout in project-status + cookiecutter (#246)
* fix(infra): nested src/<package>/ layout in project-status oracle

* fix(templates): nested packages and drop src. prefix in cookiecutter

* test(infra): cover nested src/<package>/ layout contract #241

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 20:06:36 +03:00
Sergey
596aa9b18a
fix(pipeline-status): scan all rotated memory files in check_memory (#245)
* refactor(pipeline-status): split get_memory_file_path into _resolve_memory_base + get_memory_files

* test(pipeline-status): update check_memory tests for multi-file scan

* test(pipeline-status): add rotation/mtime/glob coverage

* fix(ci): move os/time imports to top-level in test_pipeline_status

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 19:10:18 +03:00
Sergey
1e9ee49a1d
feat(infra): check_pyproject and --repo flag in project-status (#240)
* feat(infra): add check_pyproject and --repo flag to project-status

* feat(infra): add repo flag to project-status ts wrapper

* test(infra): add check_pyproject and repo flag tests

* fix(infra): accept packages=["src"] as valid hatchling wheel config

* fix(ci): apply ruff format to project-status.py and tests

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 18:36:18 +03:00
Sergey
52343e2a78
feat(memory): rotate repo memory files by size without compaction (#239)
* feat(memory-syncer): rotate files by 50KB size, dedup across files

* docs(memory-skill): document file rotation, remove inline compaction

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 18:01:41 +03:00
Sergey
b3aac737eb
feat(spec): project template skill init and check flow (#237)
* feat(spec): add project-template skill init and check flow

* refactor(spec): remove repo-init migrate references to project-template

* test(spec): structural tests for project-template skill and command

* fix(test): wrap long lines and use non_templated var in project template tests

* fix(ci): reformat test_project_template_skill.py for ruff format

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 17:37:55 +03:00
Sergey
51a5300423
feat(spec): update stack required and jwt auth template (#236)
* feat(spec): update STACK_REQUIRED for svelte and quality tools

* fix(templates): align jwt auth with issue contracts

* docs(spec): update template c stack and migration note

* fix(ci): format tests/test_spec_status.py

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 17:21:43 +03:00
Sergey
35b6c6e542
feat(infra): cookiecutter templates backend fullstack cli (#235)
* feat(infra): backend cookiecutter template with fastapi tortoise

* feat(infra): cli cookiecutter template with typer entry point

* feat(infra): fullstack cookiecutter template with sveltekit frontend

* test(infra): cookiecutter template tests and render fixes

* style(infra): ruff cleanup for cookiecutter template tests

* style(infra): ruff format cookiecutter template tests

* fix(infra): handle use_db no use_auth no in cookiecutter

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 17:06:24 +03:00
Sergey
42c8e2d31e
feat(infra): project-status check oracle and ts wrapper (#233)
* feat(infra): project-status python oracle

* feat(infra): project-status ts wrapper

* feat(infra): project-status deny rules and config

* test(infra): project-status oracle and tool tests

* fix(ci): ruff format project-status.py and tests

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 15:31:42 +03:00
Sergey
cc2c896290
fix(spec): spec-status parser quotes crlf bom (#232)
* fix(spec): strip quotes crlf bom in spec-status parser

* test(spec): cover quotes crlf bom trailing newline cases

* fix(ci): format test_spec_status.py write_bytes call

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 15:08:50 +03:00
Sergey
7d1ef60f6f
fix(docker): install docker-cli and fix DOCKER_HOST hostname (#223)
* fix(docker): install docker-ce-cli and compose plugin in image

* fix(docker): point DOCKER_HOST to resolvable dind service alias

* test(docker): cover docker-ce-cli install and dind DOCKER_HOST fix

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-02 03:49:31 +03:00
Sergey
80f21cdf08
feat(config): add deepseek-v4-flash:0731 model for ollama-cloud (#221)
Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-02 02:15:51 +03:00
Sergey
b7b1920385
fix(permissions): harden bash rules against repo destruction (#218)
* feat(permissions): deny gh api DELETE and repo transfer

* feat(permissions): ask on force push and tag delete

* test(permissions): cover hardened bash deny/ask rules

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-01 06:54:33 +03:00
Sergey
1dc2b0fa41
fix(cleanup): remove stale handoff refs in test_execute_aborts docstring (#216)
Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-01 05:04:41 +03:00
Sergey
a1f18eaf24
fix(cleanup): remove stale docs references from test_pipeline_status_tool.py (#215)
Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-01 04:56:41 +03:00
Sergey
37ebcd0e08
chore(cleanup): remove stale references to deprecated docs infrastructure (#212)
Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-01 04:43:10 +03:00
Sergey
b2329210d5
chore(cleanup): delete docs-reviewer infrastructure (#210)
* chore(cleanup): remove docs-reviewer agent and post-docs-review tool

* chore(cleanup): remove scaffold-handoff, adr-refs check, CI and tests

* chore(cleanup): remove docs/project-map directory

* chore(cleanup): drop docs-reviewer entry and post_docs_review keys from opencode.json

* chore(cleanup): drop docs-reviewer refs from tests, skills and shared tools comment

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-01 04:15:41 +03:00
Sergey
1d35c5ed1c
refactor(pipeline): update templates and agent prompts for 6-phase (#209)
* refactor(run-pipeline): drop Template B and handoff refs for 6-phase

* refactor(agents): switch reviewer and memory-syncer to PR body

* refactor(docs): update pipeline to 6 phases and drop DOCS refs

* fix(docs): update RU pipeline row and memory-syncer description

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-01 04:02:03 +03:00
Sergey
9dd462b764
refactor(pipeline): remove DOCS phase from pipeline-status (#208)
* test(pipeline): remove adr and project-map test files

* refactor(pipeline): remove DOCS phase from pipeline-status

* test(pipeline): update tests for 6-phase pipeline

* docs(handoff): add handoff and ADR for remove-docs-phase

* docs(handoff): set PR number

* fix(pipeline): parse PR body via json.loads in check_implement

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-01 03:48:41 +03:00
Sergey
0c1e3e7d39
feat(create-pr): enforce Watch out and Pending headings in PR body (#207)
* feat(create-pr): enforce Watch out and Pending headings

* test(create-pr): cover Watch out and Pending headings

* docs(handoff): add handoff and ADR for PR #207

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-01 03:28:35 +03:00
Sergey
85d7d62fcd
fix(cover): restore sub-icon slot in cover.svg (#201)
* fix(cover): restore sub-icon slot in cover.svg

* docs(cover): add ADR-089 and handoff for PR #201

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-01 02:56:40 +03:00
Sergey
87d6c4464b
feat(cover): add /cover command and VERCEL_TOKEN placeholder (#199)
* feat(cover): add /cover command for branded cover generation

* chore(env): add VERCEL_TOKEN placeholder to .env.example

* docs(project-map): add cover command, ADR-088 and handoff for PR #199

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-01 02:07:34 +03:00
Sergey
66f0afa58b
refactor(draw-image): decouple tests from production cover.svg (#197)
* refactor(draw-image): add tests/fixtures/cover.svg and loadFixture helper

* feat(draw-image): add --template-dir flag to cli for test isolation

* refactor(draw-image): switch tests from templates/cover to fixtures

* chore(draw-image): simplify production cover.svg now decoupled from tests

* docs(handoff): add handoff + ADR for issue #196

* docs(handoff): set PR number

* docs(project-map): update after structural changes

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-01 01:22:15 +03:00
Sergey
cdd8ba20e3
chore(assets): update cover without subtitle (#195)
* chore(assets): update cover without subtitle

* fix(assets): restore optional slots in cover template for engine tests

* docs(handoff): add handoff + ADR for PR #195 cover update

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-01 01:03:14 +03:00
Sergey
f0701558d3
fix(agents): add safe read-only commands to reviewer bash allow-list (#193)
* fix(agents): add safe read-only commands to reviewer bash allow-list

* docs(adr): add ADR-085 reviewer allow-list expansion rationale

* docs(handoff): add handoff for reviewer allow-list expansion

* docs(handoff): set PR number

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-01 00:01:57 +03:00
Sergey
56bd356951
feat(config): add TELEGRAM_BOOSTY_CHAT_ID env var (#192)
* feat(config): add TELEGRAM_BOOSTY_CHAT_ID env var

* docs(handoff): add handoff and ADR for boosty chat id

* docs(handoff): set PR number

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-31 23:55:05 +03:00
Sergey
6bec709ec2
fix(agents): strengthen Orchestrator Model with override and delegation map (#189)
* fix(agents): strengthen Orchestrator Model with override and delegation map

* docs(handoff): add handoff and ADR for orchestrator model fix

* docs(handoff): set PR number

* docs(project-map): update AGENTS.md description after PR#189

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-31 23:36:04 +03:00
Sergey
3caa83e617
fix(draw-image): per-process output TMP in e2e.test.ts beforeAll race (#187)
* fix(draw-image): per-process output TMP in e2e.test.ts beforeAll

* docs(handoff): add handoff and ADR for e2e.test.ts beforeAll fix

* docs(handoff): set PR number 187

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-31 22:49:46 +03:00
Sergey
72caab4097
fix(draw-image): per-process output TMP in beforeAll for parallel vitest (#186)
* fix(draw-image): per-process output TMP in integration tests

* fix(draw-image): per-process output TMP in e2e tests

* fix(draw-image): per-process output TMP in cleanup test

* docs(handoff): add handoff and ADR for beforeAll output TMP fix

* docs(handoff): set PR number

* docs(handoff): set PR number 186

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-31 22:39:58 +03:00
Sergey
8c00346b76
fix(draw-image): per-process temp dir in renderToPng test helpers (#184)
* fix(draw-image): per-process temp dir in renderToPng test helpers

* docs(handoff): add handoff and ADR for renderToPng temp dir fix

* docs(project-map): note per-process temp dir fix in renderToPng helpers

* docs(handoff): set PR number

* docs(handoff): replace PR number placeholder in frontmatter

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-31 22:28:04 +03:00
Sergey
0bf97cf1c5
fix(scripts): compute next ADR as max+1 not count+1 in scaffold (#182)
* fix(scripts): compute next ADR as max+1 not count+1 in scaffold

* docs(handoff): add handoff and ADR-079 for ADR numbering fix

* docs(handoff): set PR number

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-31 22:13:30 +03:00
Sergey
e954284ec3
docs(config): add Config Edits rule to AGENTS.md, expand configure-opencode (#181)
* docs(agents): add Config Edits rule linking configure-opencode skill

* docs(configure-opencode): expand scope rule to AGENTS, skills, agents, commands

* docs(handoff): scaffold handoff and ADR-078 for config-edits rule

* docs(handoff): set PR number 181 in handoff and ADR-078

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-31 22:01:56 +03:00
Sergey
c87fcc7958
fix(scaffold-handoff): update usage comment to reflect .opencode/scripts/ path (#180)
* fix(scaffold-handoff): update usage comment to reflect .opencode/scripts/ path

* docs(handoff): add handoff and ADR-077 for scaffold-handoff usage comment fix

* docs(handoff): set PR number

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-31 21:53:53 +03:00
Sergey
f67a9298cc
fix(run-pipeline): replace origin/master with origin/HEAD in templates B and C (#179)
* fix(run-pipeline): replace origin/master with origin/HEAD in templates B and C

* docs(handoff): add handoff and ADR for run-pipeline origin/HEAD fix

* docs(handoff): set PR number

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-31 21:47:31 +03:00
Sergey
39de98dc95
fix(draw-image): race condition on shared .tmp-render.svg breaks parallel renders (#178)
* fix(draw-image): use pid-based tmp svg and cleanup in finally

* test(draw-image): decouple e2e.optional from leftover tmp svg

* test(draw-image): add cleanup unit test for tmp svg lifecycle

* docs(draw-image): add handoff and ADR for tmp svg race fix

* docs(handoff): set PR number

* docs(project-map): update after structural changes

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-31 21:38:54 +03:00
Sergey
4258621def
fix(repo-readme): create mode does not overwrite existing local README (#175)
* fix(create-readme): resolve file_path against context.worktree

* test(loader): support fs imports and extended TS stripping

* test(create-readme): add regression tests for local overwrite

* docs(repo-readme): note file_path resolves against worktree

* docs(handoff): set PR number

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-31 21:10:29 +03:00
Sergey
1bc8b8ed05
feat(telegram): add telegram-send plugin-tool for Bot API messaging (#174)
* feat(telegram): add CLI project with Bot API client

* feat(telegram): add telegram-send plugin-tool

* test(telegram): add unit tests for markdown, config, api

* chore(env): add TELEGRAM_CHAT_ID to .env.example

* docs(handoff): add handoff and ADR for telegram-send

* docs(handoff): set PR number 174

* docs: update project map for telegram-send tool

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-31 20:42:33 +03:00
341 changed files with 15378 additions and 3272 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Before

Width:  |  Height:  |  Size: 744 B

After

Width:  |  Height:  |  Size: 470 B

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 744 B

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
"""Tests package."""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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