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>
This commit is contained in:
Sergey 2026-08-01 04:15:41 +03:00 committed by GitHub
parent 1d35c5ed1c
commit b2329210d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 15 additions and 1504 deletions

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

@ -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

@ -287,8 +287,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 +296,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 +305,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,91 +0,0 @@
#!/usr/bin/env bash
# Create handoff + ADR templates for a PR.
# Usage: bash .opencode/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 ADR number = max(existing) + 1, NOT count + 1.
# count+1 collides when files are added out of order or in parallel
# (e.g. two PRs both computed wc -l = 72 → both wrote ADR-073, see issue #176).
# max+1 is race-resistant: each run reads the current highest number.
# Guard loop handles the residual race: if max+1 file already exists
# (created between the max-read and our write), increment to a free slot.
# 10# forces base-10 (leading zeros like 078 are invalid octal in bash).
# `|| true` neutralizes grep's exit 1 under set -euo pipefail when the
# directory is empty (no ADRs yet → max=0 → next=001).
MAX_N="$(ls "$ADR_DIR" 2>/dev/null | grep -E '^[0-9]{3}-' | cut -c1-3 | sort -n | tail -n1 || true)"
NEXT_N=$((10#${MAX_N:-0} + 1))
while :; do
NN="$(printf "%03d" "$NEXT_N")"
ADR="$ADR_DIR/${NN}-pr-${PR}-${SLUG}.md"
if ! ls "$ADR_DIR"/${NN}-pr-*.md >/dev/null 2>&1; then
break
fi
NEXT_N=$((NEXT_N + 1))
done
cat > "$ADR" <<EOF
# ADR-${NN}: <title>
## Статус
Accepted (${TODAY})
## Контекст
<заполни, или «—» если архитектурных решений не было>
## Решение
<заполни, или «—»>
## Альтернативы
<заполни, или «—»>
EOF
echo "created: $ADR"
fi
echo ""
echo "handoff: $HANDOFF"
echo "adr: $ADR"

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)
@ -18,54 +18,6 @@ description: Use when you need to view or update the current project folder/file
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 файлы (docs/handoff/)
Контекст передаётся между сессиями через handoff-файлы — один файл на PR. Контекст передаётся между сессиями через handoff-файлы — один файл на PR.

View file

@ -6,8 +6,8 @@ import { spawnSync } from "child_process"
* If `repo` is provided, returns `["--repo", repo]` (explicit target). * If `repo` is provided, returns `["--repo", repo]` (explicit target).
* If omitted, returns `[]` so `gh` auto-detects the repo from its `cwd` * If omitted, returns `[]` so `gh` auto-detects the repo from its `cwd`
* (set to `context.worktree` by `runGh`). Auto-detect is the default and * (set to `context.worktree` by `runGh`). Auto-detect is the default and
* backward-compatible behaviour for all 5 GitHub tools (create-issue, * backward-compatible behaviour for all 4 GitHub tools (create-issue,
* create-pr, post-review, post-docs-review, merge-pr) see ADR-027. * create-pr, post-review, merge-pr) see ADR-027.
*/ */
export function parseRepo(repo?: string): string[] { export function parseRepo(repo?: string): string[] {
if (repo) return ["--repo", repo] if (repo) return ["--repo", repo]

View file

@ -1,23 +0,0 @@
import { tool } from "@opencode-ai/plugin"
import { runGh } from "./_shared"
const VERDICTS = ["APPROVE", "FIXED", "NO_CHANGES"] as const
type Verdict = typeof VERDICTS[number]
export default tool({
description: "Post docs review verdict as PR comment with deterministic heading. Docs-reviewer agent uses this instead of raw `gh pr comment` to guarantee `## Docs Review Summary` heading that pipeline-status.py parses.",
args: {
pr_number: tool.schema.number().describe("PR number to comment on"),
verdict: tool.schema.enum(VERDICTS).describe("Docs review verdict: APPROVE, FIXED, or NO_CHANGES"),
body: tool.schema.string().describe("Docs review body text (without heading — heading is auto-generated)"),
repo: tool.schema.string().optional().describe("Optional repo (owner/name). If omitted, gh auto-detects from context.worktree."),
},
async execute(args, context) {
const comment = `## Docs Review Summary\n\n${args.body}\n\n### Verdict: ${args.verdict}`
const r = runGh(["pr", "comment", String(args.pr_number), "--body", comment], args.repo, { cwd: context.worktree })
if (r.status !== 0) {
return `⚠️ post-docs-review failed for PR #${args.pr_number} (exit ${r.status}): ${r.stderr || r.stdout}`
}
return `Docs review posted on PR #${args.pr_number}: verdict=${args.verdict}`
},
})

View file

@ -1,215 +0,0 @@
# Project Map
opencode-config — Docker-based AI coding assistant with persistent memory (opencode configuration). Runs in Docker via `docker-compose.yml` (dind + opencode services).
Root `AGENTS.md` — orchestrator directive (chat = plan only, all via subagents) + global rules (pipeline, code style, language RU) + `## Tool Usage Policy` (таблица tools: commit/create-pr/create-issue/merge-pr/post-review/post-docs-review/pipeline-status/spec-status/memory-doctor/memory-save/memory-search/memory-list/memory-access/tunnel; raw bash заблокирован deny, при сбое tool — STOP, НЕ fallback; `memory-setup` REMOVED PR#103) + `## Bug Discovery Protocol` (агенты создают issues через `create-issue` tool для найденных багов вне scope) + `## Linear Execution` (строго линейный pipeline, параллельные research OK, параллельное исполнение ЗАПРЕЩЕНО) — PR#113. Auto-loaded for project + bind-mounted globally in container — PR#31, PR#63.
## Structure
```
opencode-config/
├── .github/
│ ├── workflows/
│ │ ├── ci.yml # Lint, test, typecheck, complexity (bootstrap + output-based skip); apt-get install ripgrep + npm ci (.opencode) для keyword search — PR#124
│ │ ├── permissions-check.yml # .opencode/scripts/check-permissions.py validator (step-level skip)
│ │ └── adr-check.yml # ADR cross-reference validator (.opencode/scripts/check-adr-refs.py)
│ └── dependabot.yml # pip + github-actions ecosystem updates
├── .opencode/ # Project-local opencode config (auto-discovery, zero env var) — PR#23
│ ├── agents/
│ │ ├── docs-reviewer.md # Docs validation subagent (project map + handoff + ADR, uses `commit`+`post-docs-review`+`create_issue` tools; `gh issue list*` allow) — PR#40, PR#46, PR#69, PR#113
│ │ ├── memory-syncer.md # Distills gotchas from handoffs into opencode-memory (`create_issue` tool; `gh issue list*` allow) — PR#69, PR#113
│ │ └── reviewer.md # Code review subagent (verdict via `post-review` tool: APPROVE|REQUEST_CHANGES|NEEDS_DISCUSSION) — PR#46, PR#69
│ ├── commands/
│ │ ├── configure-opencode.md # /configure-opencode — edit opencode.json
│ │ ├── cover.md # /cover — branded 1024×1024 cover generation (hardcoded Lime standard, agent: build, draw-image tool; no subtitle, extension via brand-logos) — PR#199
│ │ ├── feature-spec.md # /feature-spec — SDD-style Q&A for feature planning (loads feature-spec skill) — PR#172
│ │ ├── repo-readme.md # /repo-readme — standardized README generation (frontmatter agent: build, loads repo-readme skill) — PR#158
│ │ ├── run-pipeline.md # /run-pipeline — 7-phase PR pipeline
│ │ └── spec.md # /spec — 9-phase spec generation
│ ├── skills/
│ │ ├── add-skill/SKILL.md # Create new opencode skill
│ │ ├── bug-discovery/SKILL.md # Bug Discovery Protocol — create issue for out-of-scope bugs (create-issue tool, 7 SDD sections, duplicate check) — PR#120, PR#169
│ │ ├── branch/SKILL.md # Branch naming conventions
│ │ ├── code-standards/SKILL.md # Universal code style rules
│ │ ├── configure-opencode/SKILL.md # Canonical rule: write to .opencode/
│ │ ├── feature-spec/SKILL.md # SDD-style Q&A for feature planning (7 SDD sections, decision criteria, handoff to issue skill) — PR#172
│ │ ├── get-project-map/SKILL.md # Maintain docs/project-map/
│ │ ├── issue/SKILL.md # GitHub issue creation (7 SDD sections template) — PR#169
│ │ ├── memory/SKILL.md # opencode-memory usage guide
│ │ ├── run-pipeline/SKILL.md # 7-phase pipeline orchestration
│ │ ├── python-development/SKILL.md # Python dev patterns
│ │ ├── release/SKILL.md # Tag + GitHub Release
│ │ ├── repo-init/SKILL.md # New repository bootstrap
│ │ ├── repo-readme/SKILL.md # Standardized README generation (create-readme tool: create vs validate, bilingual Why/What + Features table, GitHub metadata, quick_start_steps clickable steps + conditional bash block; tagline_en/tagline_ru required + delimiter-теги, breaking change note; workflow: create → draw-image (template "cover", slots icon/sub-icon/badge, brand-logos vs Lucide) → validate → fix-цикл) — PR#112, PR#116, PR#118, PR#130, PR#146, PR#151, PR#158 (cover pipeline в workflow)
│ │ ├── tunnel/SKILL.md # Cloudflare tunnel toggle (tool `tunnel()`: 1-й вызов start, 2-й stop) — PR#63 (восстановлен, удалён в PR#42)
│ │ ├── run-tests/SKILL.md # Test runner guide
│ │ └── spec/SKILL.md # 9-phase spec generation (issue body template: 7 SDD sections) — PR#169
│ ├── tools/
│ │ ├── _shared.ts # Shared module for GitHub tools: parseRepo(repo?), runGh(args, repo?, opts?), formatResult(r, toolName) — PR#65
│ │ ├── commit.ts # commit tool wrapper (1 arg message, validates format+staged) — PR#38
│ │ ├── create-issue.ts # create-issue tool wrapper (3 args, validates 7 SDD headings+format+labels; optional repo?: string) — PR#38, PR#65, PR#169
│ │ ├── create-pr.ts # create-pr tool wrapper (3 args, validates format+Closes #N; optional repo?: string) — PR#38, PR#65
│ │ ├── create-readme.ts # create-readme tool (TS plugin, modes: create/validate; standardized bilingual README with features table, include_clone/development_en/ru/quick_start_steps_en/ru optional params, clickable access_url [url](url), conditional bash block via hasBashBlock, RU heading 'Русский' + anchor checks, 6 delimiter pairs for slaid098.dev (summary-en/ru, features-en/ru, tagline-en/ru); tagline_en+tagline_ru required (BREAKING: tagline removed PR#151), content-валидация repo_name (lowercase kebab-case) + tagline_en (no Cyrillic) + tagline_ru (require Cyrillic), validateReadme H1 prefix check `# 🚀 `; generateReadme рендерит `![Cover](assets/cover.png)` после H1 перед tagline-разделителями; validateReadme substring-чек `assets/cover.png` (без file existence — для remote gh api режима); local fs path resolved via path.resolve(context.worktree, file_path) — overwrite existing README; remote gh api) — PR#112, PR#116, PR#118, PR#130, PR#146, PR#149 (quick_start optional + guard), PR#151 (bilingual tagline + repo_name validation, breaking), PR#158 (cover image reference в template + validation), PR#175 (local file_path resolved against context.worktree — overwrite existing README)
│ │ ├── draw-image.ts # draw-image tool wrapper (opencode plugin, 5 args: template/title/subtitle?/slots?/out?; spawnSync node cli.ts render → sharp PNG) — PR#133
│ │ ├── merge-pr.ts # merge-pr tool wrapper (orchestrator-safe gh pr merge; optional repo?: string) — PR#30, PR#65
│ │ ├── memory-access.ts # memory-access tool (bump frontmatter last_accessed/access_count, regex replace, atomic write tmp+rename) — PR#101
│ │ ├── memory-doctor.ts # memory-doctor tool (read-only diagnostics: rg {lines, rgWorks} G9, allowSystemFallback:true G5, arch-mismatch detection G2; npm-missing hint G3; markdown ✅/❌ report) — PR#101, PR#124
│ │ ├── memory-list.ts # memory-list tool (pure TS, categories count .md or files in category with frontmatter) — PR#101
│ │ ├── memory-save.ts # memory-save tool (auto-setup mkdir+git init/clone+hook+7 categories, git commit, async reindex via spawn detached+unref) — PR#101
│ │ ├── memory-search.ts # memory-search tool (ripgrep keyword + Python semantic via spawnSync + scoring port + cross-category fallback; stderr warning при rgBin===null) — PR#101, PR#124
│ │ ├── pipeline-status.ts # pipeline-status tool wrapper
│ │ ├── post-docs-review.ts # post-docs-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Docs Review Summary heading; optional repo?: string) — PR#46, PR#65
│ │ ├── post-review.ts # post-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Code Review Summary heading; optional repo?: string) — PR#46, PR#65
│ │ ├── spec-status.ts # spec-status tool wrapper
│ │ ├── telegram-send.ts # telegram-send plugin-tool wrapper (spawnSync CLI .opencode/telegram/cli.ts; action: text|document|photo; MarkdownV2 default; error → ⚠️ telegram-send failed) — PR#174
│ │ └── tunnel.ts # Cloudflare tunnel toggle tool (start/stop без args) — PR#34
│ ├── draw-image/ # SVG template renderer for on-brand covers (sharp + lucide-static) — PR#133
│ │ ├── package.json # deps: sharp, lucide-static; devDeps: vitest, typescript; postinstall sync-lucide
│ │ ├── tsconfig.json # ES2022 Bundler, strict, noEmit
│ │ ├── vitest.config.ts # node env, tests/**/*.test.ts
│ │ ├── render.mjs # SVG string → PNG via sharp (fontFiles: Geist TTF bundle)
│ │ ├── cli.ts # CLI entry: render <template> --title --slots --out [--template-dir dir]; temp SVG в os.tmpdir()/draw-image-${pid}.svg + finally rmSync (race-safe для параллельных рендеров) — PR#178, PR#197 (--template-dir)
│ │ ├── brand.json # default palette slaid098 (base/surface/fg/muted/accent/line)
│ │ ├── templates/cover.svg # 1024×1024 cover template (icon-слот 520×520 + sub-icon-слот 200×200 [восстановлен PR#201, был удалён PR#197] + {{title}} y=830; badge/subtitle удалены — PR#197, тесты на fixture)
│ │ ├── fonts/ # Geist Sans TTF (Regular + Bold) bundled for sharp fontFiles
│ │ ├── icons/lucide/ # 2007 Lucide SVG icons (synced from npm lucide-static via postinstall)
│ │ ├── brand-logos/ # brand SVG logos (opencode.svg — адаптированный логотип OpenCode 512×512 fill #ccff00, резолвится через draw-image slot icon=opencode) — PR#158
│ │ ├── scripts/sync-lucide.mjs # postinstall: copy icons from node_modules/lucide-static → icons/lucide/
│ │ ├── src/
│ │ │ ├── config.ts # loadBrand + validateBrand (zod-style hex validation)
│ │ │ ├── slot-parser.ts # parseSlots: HTML comments → SlotSpec objects
│ │ │ ├── fit.ts # computeFit (contain/cover/stretch) + parseViewBox
│ │ │ ├── recolor.ts # recolorSvg (stroke/fill → target) + stripSvgWrapper + extractRootAttrs
│ │ │ ├── resolve.ts # resolveSlotContent (lucide/brand-logo/file lookup) + renderSlotSvg
│ │ │ └── render.ts # buildSvg (assemble final SVG) + computeHash (sha256 idempotency) + loadTemplate(drawImageDir, name, templateDir?) — опц. templateDir (дефолт "templates", относит./абсолют.) — PR#197
│ │ └── tests/ # vitest: 58 tests (unit + integration + e2e) — PR#178 (+3 cleanup), PR#197 (9 тестов на fixture, отвязка от продакшн cover.svg)
│ │ ├── fixtures/cover.svg # каноничный тестовый шаблон (все типы слотов: icon/sub-icon/badge/subtitle) — стабильный контракт для тестов, не зависит от продакшн templates/cover.svg — PR#197
│ │ ├── helpers/fixtures.ts # loadFixture(name): читает из tests/fixtures/, экспортирует FIXTURES_DIR_ABS — PR#197
│ │ ├── config.test.ts
│ │ ├── slot-parser.test.ts
│ │ ├── fit.test.ts
│ │ ├── recolor.test.ts
│ │ ├── cli.test.ts
│ │ ├── render.integration.test.ts # integration: PNG 1024x1024 + recursive out dir; renderToPng → per-process mkdtempSync tmp dir + finally cleanup (PR#177); на fixture (PR#197)
│ │ ├── idempotency.test.ts # на fixture (PR#197)
│ │ ├── slot.test.ts # на fixture (PR#197)
│ │ ├── e2e.test.ts # e2e: --template-dir tests/fixtures (PR#197)
│ │ ├── e2e.no-icon.test.ts # e2e: --template-dir tests/fixtures (PR#197)
│ │ ├── e2e.bad-input.test.ts
│ │ ├── render.optional.test.ts # unit: empty badge → no rect, empty subtitle → no text (PR#144); на fixture (PR#197)
│ │ ├── render.optional.integration.test.ts # integration: PNG valid with/without subtitle+badge (PR#144); renderToPng → per-process mkdtempSync tmp dir + finally cleanup (PR#177); на fixture (PR#197)
│ │ ├── e2e.optional.test.ts # e2e: title-only CLI → exit 0, clean SVG (PR#144); хелпер переписан — buildSvg напрямую вместо чтения leftover .tmp-render.svg (PR#178); --template-dir tests/fixtures (PR#197)
│ │ └── cleanup.test.ts # unit: temp SVG cleanup — no .tmp-render.svg leftover, PID-based path under os.tmpdir, no stray .svg (PR#178); на fixture (PR#197)
│ ├── telegram/ # Telegram Bot API CLI-проект (по паттерну draw-image: plugin-tool + CLI, без runtime-deps) — PR#174
│ │ ├── package.json # "type": "module", без runtime-deps; devDeps: @types/node, typescript, vitest; script test: vitest run
│ │ ├── tsconfig.json # ES2022 + DOM (для типов fetch/Blob/FormData/File), moduleResolution: Bundler, allowImportingTsExtensions: true
│ │ ├── vitest.config.ts # environment: node, include: tests/**/*.test.ts (e2e.manual.ts исключён — нет .test. в имени)
│ │ ├── cli.ts # argv-парсер, диспетчер по action (text/document/photo), parse_mode=MarkdownV2 по умолчанию; успех → JSON в stdout, провал → stderr + exit(1)
│ │ ├── src/
│ │ │ ├── config.ts # loadConfig(overrides?): читает TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID из env, валидирует, override chatId приоритетнее env
│ │ │ ├── markdown.ts # escapeMarkdownV2(text): экранирует спецсимволы MarkdownV2 (_*[]()~`>#+-=|{}.!), кириллицу не трогает, backslash НЕ в наборе
│ │ │ └── api.ts # sendMessage/sendDocument/sendPhoto через встроенный fetch Node 22+; telegramFetch парсит result → {ok, message_id, chat_id}; ok:false → throw с description
│ │ └── tests/ # vitest: 39 unit-тестов (markdown 25, config 5, api 9), все проходят
│ │ ├── markdown.test.ts # 25 кейсов: пустая строка, без спецсимволов, кириллица, it.each по каждому спецсимволу, микс, backtick, `\` не в наборе
│ │ ├── config.test.ts # 5 кейсов: валидный env, отсутствие token/chatId, override приоритет, override без env
│ │ ├── api.test.ts # 9 кейсов: URL/method/body/headers sendMessage, parse_mode omit/plain/HTML, return value, Telegram API error, network error, sendDocument/sendPhoto FormData
│ │ └── e2e.manual.ts # ручной скрипт (НЕ в npm test — нет .test. в имени + vitest include)
│ ├── scripts/
│ │ ├── check-adr-refs.py # ADR cross-reference validator (adr-check.yml)
│ │ ├── check-permissions.py # Permissions validator (permissions-check.yml)
│ │ ├── observability.py # OTel spans for tools
│ │ ├── pipeline-status.py # 7-phase oracle (gh PR + CI polling, NEXT_ACTIONS with subagent_type+template) — PR#42, PR#67 (check_project_map guard)
│ │ ├── scaffold-handoff.sh # Scaffold handoff + ADR stubs
│ │ ├── spec-status.py # 9-phase spec oracle
│ │ └── tunnel.sh # Cloudflare tunnel toggle bash (named mode via CLOUDFLARE_TUNNEL_TOKEN) — PR#34
│ ├── opencode.json # MCP servers, providers, permissions, agents (role-based tools; `create_issue` enabled for reviewer/docs-reviewer/memory-syncer) — PR#40, PR#69, PR#113 (plugin block REMOVED PR#103, deprecated `new_api` provider REMOVED PR#106)
│ ├── package.json # npm deps for tools/*.ts (deps: @vscode/ripgrep; devDeps: @types/node, typescript) — PR#101
│ └── .gitignore # Ignores node_modules, etc.
├── docs/
│ ├── handoff/ # PR handoffs (pr-<N>-<slug>.md)
│ ├── decisions/ # ADRs (NNN-pr-<N>-<slug>.md)
│ └── project-map/ # This file — structure snapshot
├── assets/ # Generated cover images for README (cover.png + cover.meta.json idempotency hash; default draw-image out path, referenced by create-readme `![Cover](assets/cover.png)`) — PR#158
├── src/ # Python RAG CLI (memory) — PR#17
│ └── memory/
│ ├── __init__.py
│ ├── __main__.py # Entry point for `python -m memory`
│ ├── cli.py # CLI commands (prog="memory")
│ ├── embedder.py # Embedding via OPENAI_BASE_URL (env-only, OpenAI-compatible); rate-limit-aware: Retry-After header parsing, retry=5, backoff max=30 — PR#83
│ ├── index.py # Indexing with chunking (MEMORY_CHUNK_SIZE/OVERLAP env); SHA256 incremental index (.rag/meta.json), atomic writes (.tmp+os.replace), versioning (model:chunk_size:overlap), fcntl.flock(LOCK_EX) — PR#83
│ └── search.py # Search with dedup by source in top-K
├── tests/ # pytest + TS/MJS test suite — PR#17
│ ├── _ts_loader.mjs # TS test loader (load/exec_stub/exec_stub_json/exec_real modes; relative import inlining via inlineShared(); fs import support + multi-line type/var/non-null assertion stripping for create-readme.ts) — PR#38, PR#65, PR#175
│ ├── test_agent_frontmatter.py # Agent frontmatter validators (no top-level doom_loop, permission.doom_loop present, steps:150) — PR#49, PR#69
│ ├── test_check_adr_refs.py # adr-check.yml validator
│ ├── test_check_permissions.py # permissions-check.yml validator
│ ├── test_cli.py # src/memory/cli.py
│ ├── test_docker_compose.py # docker-compose.yml port exposure (0.0.0.0:4096, no 127.0.0.1) + opencode limits/init/healthcheck assertions (8 raw-text: 8G/4cpu/2048pids, init:true, healthcheck block+timing, restart-not-duplicated, dind-unchanged) — PR#51, PR#132
│ ├── test_dockerfile.py # Dockerfile npm install (opencode-ai + repomix; @mathew-cf/opencode-memory REMOVED PR#103, assertions inverted) — PR#57, PR#103
│ ├── test_commit_tool.py # .opencode/tools/commit.ts (via _ts_loader.mjs exec_stub_json) — PR#38
│ ├── test_commit_tool.ts # TS wrapper test (mjs loader) — PR#38
│ ├── test_create_issue_tool.py # .opencode/tools/create-issue.ts (via _ts_loader.mjs exec_stub_json; 7 SDD headings, 4 new missing-heading tests, +repo) — PR#38, PR#65, PR#169
│ ├── test_create_issue_tool.ts # TS wrapper test (mjs loader; +repo cases, 7 SDD headings) — PR#38, PR#65, PR#169
│ ├── test_create_pr_tool.py # .opencode/tools/create-pr.ts (via _ts_loader.mjs exec_stub_json; +repo explicit/omitted/invalid) — PR#38, PR#65
│ ├── test_create_pr_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#38, PR#65
│ ├── test_create_readme_tool.py # .opencode/tools/create-readme.ts (via _ts_loader.mjs exec_stub_json; 14 tests: local create overwrite/new/subdir, remote 2-call, local validate, validation errors; regression #148 file_path resolved via context.worktree) — PR#175
│ ├── test_draw_image_tool.py # .opencode/tools/draw-image.ts (via _ts_loader.mjs exec_stub_json; 5 tests: load/valid/failure/cwd/all-args) — PR#133
│ ├── test_draw_image_tool.ts # TS wrapper test (mjs loader; 3 tests: valid/failure/cwd) — PR#133
│ ├── test_embedder.py # src/memory/embedder.py (mocks OPENAI_BASE_URL)
│ ├── test_embedder_live.py # Live embed tests (skip without RUN_LIVE=1)
│ ├── test_index.py # src/memory/index.py (chunking + env override + .rag skip)
│ ├── test_chunking.py # _chunk_text edge cases (empty, unicode, size<overlap)
│ ├── test_memory_doctor.ts # TS unit-тесты для memory-doctor (allGreen false при битом бинарнике G9, allGreen true regression guard, arch-mismatch detection G2, npm-missing hint G3) — PR#124
│ ├── test_memory_keyword_search.ts # TS unit-тесты для resolveRgBinary (npm-package, system-fallback, null-nothing, null-no-fallback) + memory-search stderr warning при rgBin===null — PR#124
│ ├── test_memory_tools_e2e.py # E2E hybrid memory (4 scenarios: zero-config keyword-only [БЕЗ RUN_LIVE], full hybrid, OpenRouter fallback, deletion [individual skipif(not RUN_LIVE)]; subprocess-based: ripgrep + python3 -m src.memory) — PR#102, PR#124
│ ├── test_merge_pr_tool.py # .opencode/tools/merge-pr.ts (via _ts_loader.mjs; 8 tests: base + repo explicit/omitted/invalid) — PR#65
│ ├── test_merge_pr_tool.ts # TS wrapper test (mjs loader; 5 tests: base + repo) — PR#65
│ ├── test_observability.py # .opencode/scripts/observability.py
│ ├── test_permissions.py # Global deny rules + agent.<name>.tools role-based access (8 tests) — PR#40, PR#69
│ ├── test_pipeline_status.py # .opencode/scripts/pipeline-status.py (REVIEW verdict branching)
│ ├── test_pipeline_status_adr.py
│ ├── test_pipeline_status_ci.py
│ ├── test_pipeline_status_next_actions.py # NEXT_ACTIONS subagent_type+template per phase (25 tests) — PR#42
│ ├── test_pipeline_status_project_map.py # check_project_map guard (README.md mandatory, ADR-028) — PR#67
│ ├── test_pipeline_status_tool.py
│ ├── test_pipeline_status_tool.ts # TS wrapper test (mjs loader)
│ ├── test_post_docs_review_tool.py # .opencode/tools/post-docs-review.ts (via _ts_loader.mjs exec_stub_json; +repo cases) — PR#46, PR#65
│ ├── test_post_docs_review_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#46, PR#65
│ ├── test_post_review_tool.py # .opencode/tools/post-review.ts (via _ts_loader.mjs exec_stub_json; +repo cases) — PR#46, PR#65
│ ├── test_post_review_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#46, PR#65
│ ├── test_search.py # src/memory/search.py
│ ├── test_spec_status.py # .opencode/scripts/spec-status.py
│ ├── test_spec_status_tool.py
│ ├── test_tunnel_tool.py # .opencode/scripts/tunnel.sh (6 pytest: start/stop/stale PID/toggle, isolated tmp copy + fake cloudflared) — PR#65
│ └── test_tunnel_tool.ts # .opencode/tools/tunnel.ts (4 TS: spawnSync wiring, success/error/cwd) — PR#65
├── pyproject.toml # Python project (uv, ruff, pytest config)
├── uv.lock # Locked deps for Python project
├── .pre-commit-config.yaml # ruff + UV hooks
├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts, port 4096 on 0.0.0.0; opencode: init: true (tini reaper), healthcheck (curl :4096, 200|401 healthy), limits 8G/4cpu/2048pids; command passed through entrypoint shim via "$@" — PR#24, PR#51, PR#132, PR#144
├── Dockerfile # node:22-trixie-slim (Debian 13 trixie, glibc 2.41) + uv + gh + chromium + docker.io + opencode-ai + repomix + cloudflared + ripgrep (apt fallback для keyword search); ENTRYPOINT=/usr/local/bin/docker-entrypoint.sh (self-healing: npm ci if sharp missing) (@mathew-cf/opencode-memory REMOVED PR#103) — PR#24, PR#34, PR#57, PR#71, PR#103, PR#124, PR#144
├── docker-entrypoint.sh # Self-healing entrypoint shim: checks node_modules/sharp, runs npm ci if missing (continue-on-error), exec opencode "$@" — PR#144
├── .dockerignore # Excludes app_data/, .git, **/node_modules from docker build context — PR#144
│ # Memory deps install layers (PR#107): COPY .opencode/package.json → npm install --omit=dev (runtime: @vscode/ripgrep for memory-search.ts); COPY pyproject.toml uv.lock → uv sync --no-dev --frozen (runtime: httpx, numpy, tenacity for python -m src.memory)
├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN), PR#36 (OPENCODE_MEMORY_REMOTE/DIR), PR#106 (AI_PROVIDER_* removed, OPENCODE_MEMORY_REMOTE now optional), PR#118 (OPENCODE_SERVER_USERNAME), PR#174 (TELEGRAM_CHAT_ID), PR#199 (VERCEL_TOKEN placeholder, not wired)
├── app_data/
│ ├── opencode-memory/ # Persistent memory (separate git repo, gitignored) — PR#36
│ ├── workspaces/ # Agent working directory (.gitkeep)
│ └── ssh/ # SSH keys, not in git (.gitkeep)
├── .editorconfig
├── .gitignore
├── .python-version
├── AGENTS.md # Orchestrator directive (CRITICAL — overrides built-in prompt: FORBIDDEN delegation map explore/general/reviewer/docs-reviewer/memory-syncer + ALLOWED read-only oracle tools; global rules, Bug Discovery Protocol, Linear Execution; bind-mounted globally) — PR#31, PR#113, PR#189
├── LICENSE
└── README.md
```
## Pending (future PRs)
- `.opencode/scripts/pipeline-status.py` fix `610452f` — PR#7 (config/scripts/ migration)
- `.opencode/tools/` TS wrappers — PR#7 (config/tools/ migration)
## Update Protocol
Updated by docs-reviewer subagent on each PR. Reflects tracked files only (`git ls-files`).
Guarded by `pipeline-status.py:check_project_map` — README.md existence is mandatory.

View file

@ -1,14 +1,14 @@
"""Tests for agent frontmatter validation — top-level vs permission-nested fields. """Tests for agent frontmatter validation — top-level vs permission-nested fields.
Covers issue #48 acceptance criteria: Covers issue #48 acceptance criteria:
- ``reviewer.md``, ``docs-reviewer.md``, ``memory-syncer.md`` frontmatter does - ``reviewer.md`` and ``memory-syncer.md`` frontmatter does NOT contain a
NOT contain a top-level ``doom_loop`` field (outside the ``permission:`` block). top-level ``doom_loop`` field (outside the ``permission:`` block).
A top-level ``doom_loop`` is invalid it is not in the opencode ``AgentV2.Info`` A top-level ``doom_loop`` is invalid it is not in the opencode ``AgentV2.Info``
schema and gets forwarded to the provider as a model param, causing schema and gets forwarded to the provider as a model param, causing
``AI_APICallError: Extra inputs are not permitted`` on strict providers. ``AI_APICallError: Extra inputs are not permitted`` on strict providers.
- ``doom_loop`` INSIDE the ``permission:`` block IS present in all 3 files - ``doom_loop`` INSIDE the ``permission:`` block IS present in all 2 files
(valid it is the real permission guard). (valid it is the real permission guard).
- ``steps: 150`` is present and unchanged in all 3 files. - ``steps: 150`` is present and unchanged in all 2 files.
PyYAML is not a project dependency, so frontmatter is parsed manually by PyYAML is not a project dependency, so frontmatter is parsed manually by
splitting on ``---`` delimiters and tracking indentation (top-level fields have splitting on ``---`` delimiters and tracking indentation (top-level fields have
@ -23,7 +23,6 @@ AGENTS_DIR = REPO_ROOT / ".opencode" / "agents"
AGENT_FILES = [ AGENT_FILES = [
AGENTS_DIR / "reviewer.md", AGENTS_DIR / "reviewer.md",
AGENTS_DIR / "docs-reviewer.md",
AGENTS_DIR / "memory-syncer.md", AGENTS_DIR / "memory-syncer.md",
] ]
@ -81,7 +80,7 @@ def _parse_yaml_block(text: str) -> dict:
def test_agent_files_exist(): def test_agent_files_exist():
"""All 3 agent files exist in .opencode/agents/.""" """All 2 agent files exist in .opencode/agents/."""
for f in AGENT_FILES: for f in AGENT_FILES:
assert f.exists(), f"agent file missing: {f}" assert f.exists(), f"agent file missing: {f}"
@ -90,7 +89,7 @@ def test_agent_files_exist():
def test_no_top_level_doom_loop(): def test_no_top_level_doom_loop():
"""Frontmatter of all 3 agents has NO top-level ``doom_loop`` key. """Frontmatter of all 2 agents has NO top-level ``doom_loop`` key.
A top-level ``doom_loop`` is invalid outside ``permission:`` it is not A top-level ``doom_loop`` is invalid outside ``permission:`` it is not
a recognised agent field and gets forwarded to the provider. a recognised agent field and gets forwarded to the provider.
@ -126,7 +125,7 @@ def test_no_top_level_doom_loop_raw():
def test_permission_doom_loop_present(): def test_permission_doom_loop_present():
"""``doom_loop: deny`` INSIDE ``permission:`` is present in all 3 files. """``doom_loop: deny`` INSIDE ``permission:`` is present in all 2 files.
This is the valid permission guard it must NOT be removed. This is the valid permission guard it must NOT be removed.
""" """
@ -147,7 +146,7 @@ def test_permission_doom_loop_present():
def test_steps_150_present(): def test_steps_150_present():
"""``steps: 150`` is present in all 3 agent files (must not be changed).""" """``steps: 150`` is present in all 2 agent files (must not be changed)."""
for f in AGENT_FILES: for f in AGENT_FILES:
fm = _parse_frontmatter(f) fm = _parse_frontmatter(f)
assert "steps" in fm, f"{f.name}: 'steps' field missing" assert "steps" in fm, f"{f.name}: 'steps' field missing"
@ -158,7 +157,7 @@ def test_steps_150_present():
def test_frontmatter_parseable(): def test_frontmatter_parseable():
"""All 3 agent files have parseable frontmatter (split by ---).""" """All 2 agent files have parseable frontmatter (split by ---)."""
for f in AGENT_FILES: for f in AGENT_FILES:
content = f.read_text() content = f.read_text()
parts = content.split("---", 2) parts = content.split("---", 2)

View file

@ -1,157 +0,0 @@
"""Tests for .opencode/scripts/check-adr-refs.py — dangling ADR reference guard.
Covers the deterministic ADR-ref guard: clean repo passes, a dangling
``ADR-999`` in a temp handoff is detected, self-reference inside an
``NNN-*.md`` file is excluded, a valid cross-ref (``ADR-002``) passes,
a file with no ADR refs passes, and ``node_modules/`` is excluded.
Strategy mirrors ``tests/test_check_permissions.py``:
- ``test_clean_repo_passes`` runs the script as a subprocess on the
real (clean) repo black-box, returncode 0, OK message in stdout.
- The remaining tests load the script in-process via importlib and
monkeypatch ``REPO_ROOT`` / ``ADR_DIR`` to point at a tmp_path tree,
then call ``main()`` and assert on captured stdout / SystemExit code.
"""
import importlib.util
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_PATH = REPO_ROOT / ".opencode" / "scripts" / "check-adr-refs.py"
def _load_script():
spec = importlib.util.spec_from_file_location("check_adr_refs", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
sys.modules["check_adr_refs"] = module
spec.loader.exec_module(module)
return module
car = _load_script()
# ── clean repo (subprocess black-box) ───────────────────────────────────────
def test_clean_repo_passes():
"""On the current (clean) repo the script exits 0 with the OK message."""
result = subprocess.run(
["python3", str(SCRIPT_PATH)],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert "OK: No dangling ADR references." in result.stdout
# ── helpers for in-process tests ────────────────────────────────────────────
def _setup_tmp_repo(tmp_path: Path, monkeypatch) -> tuple[Path, Path]:
"""Point the script's REPO_ROOT and ADR_DIR at a tmp_path tree.
Creates the ``docs/decisions/`` dir and returns (repo_root, adr_dir).
"""
repo = tmp_path / "repo"
adr_dir = repo / "docs" / "decisions"
adr_dir.mkdir(parents=True)
handoff_dir = repo / "docs" / "handoff"
handoff_dir.mkdir(parents=True)
monkeypatch.setattr(car, "REPO_ROOT", repo)
monkeypatch.setattr(car, "ADR_DIR", adr_dir)
return repo, adr_dir
# ── dangling reference detected ─────────────────────────────────────────────
def test_dangling_ref_detected(tmp_path, monkeypatch, capsys):
"""A handoff referencing ADR-999 (no 999-*.md) → exit 1 + ADR-999 in output."""
repo, _adr_dir = _setup_tmp_repo(tmp_path, monkeypatch)
(repo / "docs" / "handoff" / "pr-1-test.md").write_text("See ADR-999 for context.\n")
with pytest.raises(SystemExit) as exc_info:
car.main()
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert "ADR-999" in captured.out
assert "FAIL" in captured.out
# ── self-reference excluded ─────────────────────────────────────────────────
def test_self_reference_excluded(tmp_path, monkeypatch, capsys):
"""An ADR file ``019-pr-94-test.md`` mentioning ADR-019 → passes (self-ref)."""
_repo, adr_dir = _setup_tmp_repo(tmp_path, monkeypatch)
(adr_dir / "019-pr-94-test.md").write_text("# ADR-019: self-reference is OK.\n")
with pytest.raises(SystemExit) as exc_info:
car.main()
assert exc_info.value.code == 0
captured = capsys.readouterr()
assert "OK: No dangling ADR references." in captured.out
# ── valid cross-reference passes ────────────────────────────────────────────
def test_valid_cross_ref_passes(tmp_path, monkeypatch, capsys):
"""A handoff referencing ADR-002 when 002-*.md exists → passes."""
repo, adr_dir = _setup_tmp_repo(tmp_path, monkeypatch)
(adr_dir / "002-pr-56-pipeline-mandatory-adr.md").write_text("# ADR-002\n")
(repo / "docs" / "handoff" / "pr-3-test.md").write_text("Per ADR-002, ADR is mandatory.\n")
with pytest.raises(SystemExit) as exc_info:
car.main()
assert exc_info.value.code == 0
captured = capsys.readouterr()
assert "OK: No dangling ADR references." in captured.out
# ── no ADR refs in a file passes ────────────────────────────────────────────
def test_no_adr_refs_passes(tmp_path, monkeypatch, capsys):
"""A handoff with no ADR-NNN references → passes."""
repo, _adr_dir = _setup_tmp_repo(tmp_path, monkeypatch)
(repo / "docs" / "handoff" / "pr-4-test.md").write_text("No ADR references here.\n")
with pytest.raises(SystemExit) as exc_info:
car.main()
assert exc_info.value.code == 0
captured = capsys.readouterr()
assert "OK: No dangling ADR references." in captured.out
# ── node_modules excluded ───────────────────────────────────────────────────
def test_exclude_node_modules(tmp_path, monkeypatch, capsys):
"""A ``.md`` file under ``node_modules/`` with ADR-999 is NOT scanned."""
repo, _adr_dir = _setup_tmp_repo(tmp_path, monkeypatch)
nm = repo / "node_modules" / "some-pkg"
nm.mkdir(parents=True)
(nm / "README.md").write_text("Dangling ADR-999 should be ignored.\n")
with pytest.raises(SystemExit) as exc_info:
car.main()
assert exc_info.value.code == 0
captured = capsys.readouterr()
assert "OK: No dangling ADR references." in captured.out
assert "ADR-999" not in captured.out
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View file

@ -145,18 +145,6 @@ def test_reviewer_tools_readonly():
assert tools["merge_pr"] is False assert tools["merge_pr"] is False
# ── agent.docs-reviewer.tools ───────────────────────────────────────────────
def test_docs_reviewer_tools():
"""docs-reviewer: commit=true (project map/handoff), create_issue=true, rest false."""
tools = _load_config()["agent"]["docs-reviewer"]["tools"]
assert tools["commit"] is True
assert tools["create_pr"] is False
assert tools["create_issue"] is True
assert tools["merge_pr"] is False
# ── agent.memory-syncer.tools ─────────────────────────────────────────────── # ── agent.memory-syncer.tools ───────────────────────────────────────────────

View file

@ -1,304 +0,0 @@
"""Tests for .opencode/tools/post-docs-review.ts — the post-docs-review custom tool.
Mirrors tests/test_commit_tool.py / test_create_pr_tool.py:
exercises the tool's ``execute()`` function via ``tests/_ts_loader.mjs``
using the ``exec_stub_json`` mode (multi-arg tools).
The loader is parameterized via the ``TS_FILE`` env var. These tests set
``TS_FILE=.opencode/tools/post-docs-review.ts``.
Modes used:
- ``load`` sanity-check that the tool loads and declares pr_number, verdict,
body args.
- ``exec_stub_json`` call execute with a stubbed spawnSync to verify:
(a) success path: valid verdict (APPROVE/FIXED/NO_CHANGES)
"Docs review posted on PR #N: verdict=<V>",
(b) comment heading: body passed to gh contains "## Docs Review Summary",
(c) comment verdict: body passed to gh contains "### Verdict: <V>",
(d) spawnSync args: gh pr comment N --body <comment> (no --repo; gh auto-detects
from context.worktree ADR-019 superseding note, PR#60).
post-docs-review.ts makes 1 spawnSync call (gh pr comment) on all paths.
Note on enum validation: see test_post_review_tool.py same delegation to
zod schema, execute() itself is permissive.
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs"
TS_FILE = REPO_ROOT / ".opencode" / "tools" / "post-docs-review.ts"
TS_FILE_REL = ".opencode/tools/post-docs-review.ts"
COMMENT_URL = "https://github.com/slaid098/opencode-config/issues/45#issuecomment-1"
COMMENT_OK_RESPONSE = {"status": 0, "stdout": COMMENT_URL + "\n", "stderr": ""}
EXPECTED_APPROVE = "Docs review posted on PR #45: verdict=APPROVE"
EXPECTED_FIXED = "Docs review posted on PR #45: verdict=FIXED"
EXPECTED_NO_CHANGES = "Docs review posted on PR #45: verdict=NO_CHANGES"
EXPECTED_APROVE_TYPO = "Docs review posted on PR #45: verdict=APROVE"
def _run_loader(*args: str) -> dict:
"""Invoke the loader with TS_FILE env set to post-docs-review.ts and parse JSON stdout."""
env = {**os.environ, "TS_FILE": TS_FILE_REL}
proc = subprocess.run(
["node", str(LOADER), *args],
capture_output=True,
text=True,
check=False,
cwd=str(REPO_ROOT),
timeout=60,
env=env,
)
if proc.returncode != 0:
raise RuntimeError(
f"_ts_loader.mjs {' '.join(args)} failed (exit {proc.returncode}):\n"
f"stdout: {proc.stdout}\nstderr: {proc.stderr}"
)
return json.loads(proc.stdout)
def _run_exec(args: dict, responses: list[dict]) -> dict:
"""Helper: exec_stub_json mode with JSON args + sequential stub responses."""
return _run_loader("exec_stub_json", json.dumps(args), json.dumps(responses))
def test_loader_can_load_tool():
"""Sanity: post-docs-review.ts loads and declares pr_number, verdict, body args."""
if not TS_FILE.exists():
pytest.skip("post-docs-review.ts not present")
out = _run_loader("load")
assert "description" in out
args = out["args"]
assert "pr_number" in args, f"missing pr_number arg: {args}"
assert "verdict" in args, f"missing verdict arg: {args}"
assert "body" in args, f"missing body arg: {args}"
def test_valid_approve():
"""execute() with APPROVE verdict returns 'Docs review posted on PR #N: verdict=APPROVE'."""
out = _run_exec(
{"pr_number": 45, "verdict": "APPROVE", "body": "- Handoff: valid"},
[COMMENT_OK_RESPONSE],
)
result = out["result"]
assert result == EXPECTED_APPROVE, f"expected success, got: {result!r}"
def test_valid_fixed():
"""execute() with FIXED verdict returns success."""
out = _run_exec(
{"pr_number": 45, "verdict": "FIXED", "body": "- Handoff: fixed: added section"},
[COMMENT_OK_RESPONSE],
)
result = out["result"]
assert result == EXPECTED_FIXED, f"expected success, got: {result!r}"
def test_valid_no_changes():
"""execute() with NO_CHANGES verdict returns success."""
out = _run_exec(
{"pr_number": 45, "verdict": "NO_CHANGES", "body": "- No structural changes"},
[COMMENT_OK_RESPONSE],
)
result = out["result"]
assert result == EXPECTED_NO_CHANGES, f"expected success, got: {result!r}"
def test_invalid_verdict_not_validated_by_execute():
"""execute() does NOT validate verdict inline — zod enum does at opencode layer.
The _ts_loader.mjs shim does not validate (chainable builder without checks).
Passing a typo "APROVE" (missing a P) builds a comment with the bad verdict.
The real guard is tool.schema.enum(VERDICTS) in the opencode runtime.
This test documents that execute() itself is permissive validation is
delegated to the zod schema, not duplicated inside execute().
"""
out = _run_exec(
{"pr_number": 45, "verdict": "APROVE", "body": "typo verdict"},
[COMMENT_OK_RESPONSE],
)
result = out["result"]
assert result == EXPECTED_APROVE_TYPO, f"expected permissive, got: {result!r}"
def test_comment_has_heading():
"""Comment body passed to gh contains '## Docs Review Summary' heading."""
out = _run_exec(
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."},
[COMMENT_OK_RESPONSE],
)
calls = out["calls"]
assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}"
args = calls[0]["args"]
body_idx = args.index("--body") + 1
comment = args[body_idx]
assert comment.startswith("## Docs Review Summary\n"), (
f"expected heading at start, got: {comment!r}"
)
def test_comment_has_verdict():
"""Comment body passed to gh contains '### Verdict: <verdict>'."""
out = _run_exec(
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."},
[COMMENT_OK_RESPONSE],
)
calls = out["calls"]
args = calls[0]["args"]
body_idx = args.index("--body") + 1
comment = args[body_idx]
assert "### Verdict: APPROVE" in comment, f"expected verdict line, got: {comment!r}"
def test_comment_has_fixed_verdict():
"""Comment body with FIXED contains '### Verdict: FIXED'."""
out = _run_exec(
{"pr_number": 45, "verdict": "FIXED", "body": "- Handoff: fixed"},
[COMMENT_OK_RESPONSE],
)
calls = out["calls"]
args = calls[0]["args"]
body_idx = args.index("--body") + 1
comment = args[body_idx]
assert "### Verdict: FIXED" in comment, f"expected verdict line, got: {comment!r}"
def test_comment_has_no_changes_verdict():
"""Comment body with NO_CHANGES contains '### Verdict: NO_CHANGES'."""
out = _run_exec(
{"pr_number": 45, "verdict": "NO_CHANGES", "body": "- No changes"},
[COMMENT_OK_RESPONSE],
)
calls = out["calls"]
args = calls[0]["args"]
body_idx = args.index("--body") + 1
comment = args[body_idx]
assert "### Verdict: NO_CHANGES" in comment, f"expected verdict line, got: {comment!r}"
def test_spawnsync_args():
"""spawnSync called with gh pr comment <N> --body <comment> (no --repo).
--repo was hardcoded to slaid098/opencode-config (ADR-019) and caused silent
misroute to the wrong repo when working outside opencode-config. Removed in
PR#60 — gh now auto-detects the repo from context.worktree (cwd), matching
create-pr.ts/merge-pr.ts. Symmetric with the success-path assertions.
"""
out = _run_exec(
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."},
[COMMENT_OK_RESPONSE],
)
calls = out["calls"]
assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}"
call = calls[0]
assert call["cmd"] == "gh", f"expected cmd 'gh', got: {call['cmd']!r}"
args = call["args"]
assert args[0] == "pr", f"expected first arg 'pr', got: {args[0]!r}"
assert args[1] == "comment", f"expected second arg 'comment', got: {args[1]!r}"
assert args[2] == "45", f"expected PR number '45', got: {args[2]!r}"
assert "--body" in args, "missing --body flag"
# --repo MUST NOT be present — gh auto-detects from cwd (context.worktree).
# Hardcoded --repo caused silent misroute (PR#60 root cause).
assert "--repo" not in args, (
f"--repo must not be hardcoded; gh auto-detects from cwd. args: {args!r}"
)
# args must end with --body <comment> (no trailing --repo slaid098/...).
assert args[-2] == "--body", f"expected args to end with --body <comment>, got: {args[-2:]!r}"
assert args[-1].startswith("## Docs Review Summary\n"), (
f"expected last arg to be the comment body, got: {args[-1]!r}"
)
def test_execute_uses_cwd_from_context():
"""execute passes cwd=context.worktree to spawnSync (ADR-023 pattern)."""
out = _run_exec(
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."},
[COMMENT_OK_RESPONSE],
)
calls = out["calls"]
assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}"
opts = calls[0]["opts"]
assert opts is not None, "spawnSync called without opts — expected cwd kwarg"
assert "cwd" in opts, f"opts missing 'cwd' key — got: {opts}"
assert opts["cwd"] == str(REPO_ROOT), (
f"cwd must equal context.worktree ({REPO_ROOT}), got: {opts['cwd']!r}"
)
def test_gh_failure_returns_error():
"""execute() with gh exit non-zero returns error message with exit code."""
fail_response = {"status": 1, "stdout": "", "stderr": "gh: not authenticated"}
out = _run_exec(
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."},
[fail_response],
)
result = out["result"]
assert "post-docs-review failed" in result, f"expected failure message, got: {result!r}"
assert "exit 1" in result, f"expected exit 1 mention, got: {result!r}"
def test_repo_explicit_passed_to_gh():
"""execute() with repo='foo/bar' → gh receives '--repo foo/bar' before the subcommand.
The shared runGh helper prepends ['--repo', <repo>] to the gh argv so an
explicit repo targets the right owner/name regardless of context.worktree.
"""
out = _run_exec(
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body.", "repo": "foo/bar"},
[COMMENT_OK_RESPONSE],
)
result = out["result"]
assert result == EXPECTED_APPROVE, f"expected success, got: {result!r}"
calls = out["calls"]
assert len(calls) == 1, f"expected 1 spawnSync call, got: {len(calls)}"
args = calls[0]["args"]
assert args[0] == "--repo", f"expected --repo first, got: {args[0]!r}"
assert args[1] == "foo/bar", f"expected repo value, got: {args[1]!r}"
assert args[2] == "pr", f"expected 'pr' after --repo <name>, got: {args[2]!r}"
def test_repo_omitted_no_repo_flag():
"""execute() without repo → gh argv has NO --repo (auto-detect from cwd).
Backward-compatibility: when repo is omitted, runGh returns [] from
parseRepo, so gh auto-detects the repo from context.worktree (cwd)
matching the pre-refactor behaviour (ADR-025 / PR#61). This complements
test_spawnsync_args which asserts the same on the success path.
"""
out = _run_exec(
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."},
[COMMENT_OK_RESPONSE],
)
calls = out["calls"]
assert len(calls) == 1, f"expected 1 spawnSync call, got: {len(calls)}"
args = calls[0]["args"]
assert "--repo" not in args, (
f"--repo must NOT be added when repo arg omitted; gh auto-detects. args: {args!r}"
)
assert args[0] == "pr", f"expected 'pr' first, got: {args[0]!r}"
def test_repo_invalid_gh_error():
"""execute() with invalid repo + gh failure → error mentions post-docs-review + exit code.
gh rejects an invalid owner/name with non-zero exit; the tool formats the
error with its tool-specific message (post-docs-review failed for PR #N).
"""
fail_response = {"status": 1, "stdout": "", "stderr": 'expected the "owner/repo" format'}
out = _run_exec(
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body.", "repo": "not-a-valid-repo"},
[fail_response],
)
result = out["result"]
assert "post-docs-review failed" in result, f"expected tool failure, got: {result!r}"
assert "exit 1" in result, f"expected exit 1 mention, got: {result!r}"
calls = out["calls"]
args = calls[0]["args"]
assert "--repo" in args, f"expected --repo in args even on failure, got: {args!r}"

View file

@ -1,240 +0,0 @@
/**
* Tests for .opencode/tools/post-docs-review.ts the post-docs-review custom tool.
*
* Mirror of tests/test_post_review_tool.ts / test_commit_tool.ts:
* the tool is a spawnSync wrapper around `gh pr comment` with verdict enum
* validation and deterministic comment heading generation.
*
* Runtime note: opencode ships a standalone binary with Bun bundled inside;
* there is no separate `bun` CLI on the host (CI runner uses node + pytest).
* The CI runs the equivalent Python tests in tests/test_post_docs_review_tool.py
* via the JS loader tests/_ts_loader.mjs (exec_stub_json mode for multi-arg
* tools). This file documents the intended TS-side test cases and is
* runnable under `bun test` once a bun runtime is available on the host.
*
* Test cases (mirror tests/test_post_docs_review_tool.py):
* - test_valid_approve valid APPROVE verdict "Docs review posted"
* - test_valid_fixed valid FIXED verdict "Docs review posted"
* - test_valid_no_changes valid NO_CHANGES verdict "Docs review posted"
* - test_invalid_verdict invalid verdict "APROVE" (typo) error
* - test_comment_has_heading comment body contains "## Docs Review Summary"
* - test_comment_has_verdict comment body contains "### Verdict: <verdict>"
* - test_spawnsync_args spawnSync called with correct gh args
*/
import { describe, test, expect, mock } from "bun:test" with { type: "'bun-test'" }
import { spawnSync } from "child_process"
import path from "path"
const TOOL_SRC = path.resolve(import.meta.dir, "..", ".opencode", "tools", "post-docs-review.ts")
function ctx() {
return {
sessionID: "t", messageID: "t", agent: "t",
directory: ".", worktree: ".",
abort: new AbortController().signal,
metadata() {}, async ask() {},
}
}
const COMMENT_OK = { status: 0, stdout: "https://github.com/slaid098/opencode-config/issues/45#issuecomment-1\n", stderr: "" }
describe("post-docs-review tool", () => {
test("test_valid_approve — APPROVE verdict succeeds", async () => {
let capturedArgs
mock.module("child_process", () => ({
spawnSync: (_cmd, args) => {
capturedArgs = args
return COMMENT_OK
},
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({
pr_number: 45,
verdict: "APPROVE",
body: "- Project map: no structural changes\n- Handoff: valid",
}, ctx())
expect(result).toBe("Docs review posted on PR #45: verdict=APPROVE")
const bodyIdx = capturedArgs.indexOf("--body") + 1
expect(capturedArgs[bodyIdx]).toContain("## Docs Review Summary")
expect(capturedArgs[bodyIdx]).toContain("### Verdict: APPROVE")
})
test("test_valid_fixed — FIXED verdict succeeds", async () => {
let capturedArgs
mock.module("child_process", () => ({
spawnSync: (_cmd, args) => {
capturedArgs = args
return COMMENT_OK
},
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({
pr_number: 45,
verdict: "FIXED",
body: "- Handoff: fixed: added missing section",
}, ctx())
expect(result).toBe("Docs review posted on PR #45: verdict=FIXED")
const bodyIdx = capturedArgs.indexOf("--body") + 1
expect(capturedArgs[bodyIdx]).toContain("### Verdict: FIXED")
})
test("test_valid_no_changes — NO_CHANGES verdict succeeds", async () => {
let capturedArgs
mock.module("child_process", () => ({
spawnSync: (_cmd, args) => {
capturedArgs = args
return COMMENT_OK
},
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({
pr_number: 45,
verdict: "NO_CHANGES",
body: "- Project map: no structural changes\n- Handoff: valid",
}, ctx())
expect(result).toBe("Docs review posted on PR #45: verdict=NO_CHANGES")
const bodyIdx = capturedArgs.indexOf("--body") + 1
expect(capturedArgs[bodyIdx]).toContain("### Verdict: NO_CHANGES")
})
test("test_invalid_verdict — typo 'APROVE' → error", async () => {
mock.module("child_process", () => ({
spawnSync: () => COMMENT_OK,
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
// Invalid verdict passed directly — at runtime zod would reject this,
// but the loader shim does no validation. The tool builds the comment
// regardless. The enum validation happens at the opencode layer (zod),
// not inside execute(). This test documents that the tool itself does
// not validate verdicts (delegated to zod schema).
const result = await mod.default.execute({
pr_number: 45,
verdict: "APROVE",
body: "typo verdict",
}, ctx())
expect(result).toBe("Docs review posted on PR #45: verdict=APROVE")
})
test("test_comment_has_heading — comment body contains '## Docs Review Summary'", async () => {
let capturedArgs
mock.module("child_process", () => ({
spawnSync: (_cmd, args) => {
capturedArgs = args
return COMMENT_OK
},
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
await mod.default.execute({
pr_number: 45,
verdict: "APPROVE",
body: "Docs body",
}, ctx())
const bodyIdx = capturedArgs.indexOf("--body") + 1
const comment = capturedArgs[bodyIdx]
expect(comment.startsWith("## Docs Review Summary\n")).toBe(true)
})
test("test_comment_has_verdict — comment body contains '### Verdict: APPROVE'", async () => {
let capturedArgs
mock.module("child_process", () => ({
spawnSync: (_cmd, args) => {
capturedArgs = args
return COMMENT_OK
},
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
await mod.default.execute({
pr_number: 45,
verdict: "APPROVE",
body: "Docs body",
}, ctx())
const bodyIdx = capturedArgs.indexOf("--body") + 1
const comment = capturedArgs[bodyIdx]
expect(comment).toContain("### Verdict: APPROVE")
})
test("test_spawnsync_args — spawnSync called with correct gh args (no --repo)", async () => {
let capturedCmd
let capturedArgs
mock.module("child_process", () => ({
spawnSync: (cmd, args) => {
capturedCmd = cmd
capturedArgs = args
return COMMENT_OK
},
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
await mod.default.execute({
pr_number: 45,
verdict: "APPROVE",
body: "Docs body",
}, ctx())
expect(capturedCmd).toBe("gh")
expect(capturedArgs[0]).toBe("pr")
expect(capturedArgs[1]).toBe("comment")
expect(capturedArgs[2]).toBe("45")
expect(capturedArgs).toContain("--body")
// --repo MUST NOT be present — gh auto-detects from cwd (PR#60).
expect(capturedArgs).not.toContain("--repo")
// args end with --body <comment> (no trailing --repo slaid098/...).
const lastIdx = capturedArgs.length - 1
expect(capturedArgs[lastIdx - 1]).toBe("--body")
expect(capturedArgs[lastIdx].startsWith("## Docs Review Summary\n")).toBe(true)
})
test("test_repo_explicit — repo='foo/bar' prepends --repo to gh argv", async () => {
let capturedArgs
mock.module("child_process", () => ({
spawnSync: (_cmd, args) => {
capturedArgs = args
return COMMENT_OK
},
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({
pr_number: 45,
verdict: "APPROVE",
body: "Docs body",
repo: "foo/bar",
}, ctx())
expect(result).toBe("Docs review posted on PR #45: verdict=APPROVE")
expect(capturedArgs[0]).toBe("--repo")
expect(capturedArgs[1]).toBe("foo/bar")
expect(capturedArgs[2]).toBe("pr")
})
test("test_repo_omitted — no --repo flag (auto-detect from cwd)", async () => {
let capturedArgs
mock.module("child_process", () => ({
spawnSync: (_cmd, args) => {
capturedArgs = args
return COMMENT_OK
},
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
await mod.default.execute({
pr_number: 45,
verdict: "APPROVE",
body: "Docs body",
}, ctx())
// Backward-compat: --repo MUST NOT be present when repo arg omitted.
expect(capturedArgs).not.toContain("--repo")
expect(capturedArgs[0]).toBe("pr")
})
test("test_repo_invalid — invalid repo + gh failure → tool error", async () => {
mock.module("child_process", () => ({
spawnSync: () => ({ status: 1, stdout: "", stderr: 'expected the "owner/repo" format' }),
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({
pr_number: 45,
verdict: "APPROVE",
body: "Docs body",
repo: "not-a-valid-repo",
}, ctx())
expect(result).toContain("post-docs-review failed")
expect(result).toContain("exit 1")
})
})