diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d3a448f..7c3226d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "AI-native Vibe Coding — From idea to Deploy, Zero friction. Unified plugin with 11 agents, 5 commands, 7 skills, team-based parallel build, 20 design styles, screen-spec generator.", - "version": "0.1.16" + "version": "0.1.17" }, "plugins": [ { "name": "wigtn-plugins", "description": "Unified AI-native Vibe Coding plugin: PRD generation, digging analysis, screen-spec (IA / User Flow / Wireframe HTML / Dev Handoff), team-based parallel implementation, quality-gated auto-commit, 20 design styles, frontend (React/Next.js), backend (NestJS/Prisma), mobile (React Native/Expo), AI integration (STT/LLM), DevOps (Docker/CI-CD). 11 agents, 5 commands, 7 skills.", "source": "./plugins/wigtn-plugins", - "version": "0.1.16", + "version": "0.1.17", "author": { "name": "WIGTN Crew", "email": "contact@wigtn.com" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1ae704e..55b9958 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "wigtn-plugins", - "version": "0.1.16", + "version": "0.1.17", "description": "AI-native Vibe Coding — From idea to Deploy, Zero friction. Unified plugin with 11 agents, 5 commands, 7 skills, team-based parallel build, 20 design styles.", "author": { "name": "WIGTN Crew", diff --git a/.github/scripts/test_context_scripts.sh b/.github/scripts/test_context_scripts.sh new file mode 100755 index 0000000..153a9c8 --- /dev/null +++ b/.github/scripts/test_context_scripts.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# 번들 컨텍스트 스크립트 회귀 테스트 (repo-context / repo-state / pr-context). +# +# 이 세 스크립트는 /implement·/auto-commit·/review-pr 의 첫 단계라, 조용히 틀리면 +# 모델이 빈 트리나 없는 PR 을 그대로 믿고 진행한다. 실제로 리뷰에서 잡힌 결함이 +# 전부 "실패했는데 성공처럼 보이는" 종류였다 - tracked-only 수집, 잘린 개수를 +# 전체로 보고, 서브디렉터리 경로 어긋남, gh 실패의 빈 PR 성공 처리. +# +# 임시 저장소를 만들어 실제 실행하고 JSON 필드만 본다. 모델 호출 없음. +# +# 실행: bash .github/scripts/test_context_scripts.sh + +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +S="$REPO_ROOT/plugins/wigtn-plugins/scripts" +PASS=0; FAIL=0 +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT + +git_init() { git init -q "$1" && git -C "$1" config user.email t@t && git -C "$1" config user.name t; } + +# $1 라벨 $2 기대 $3 실제 +eq() { + if [ "$2" = "$3" ]; then echo " ok $1"; PASS=$((PASS+1)) + else echo " FAIL $1 - 기대 [$2] 실제 [$3]"; FAIL=$((FAIL+1)); fi +} +# $1 라벨 $2 JSON $3 python 표현식(d 로 접근) +field() { python3 -c " +import json,sys +d=json.loads(sys.stdin.read()) +print($3)" <<< "$2"; } + +echo "repo-context.sh" + +# 커밋하지 않은 파일도 소스 트리에 들어간다 (git ls-files 는 tracked 만 낸다) +D="$TMP/untracked"; mkdir -p "$D/src"; git_init "$D" +echo '{"scripts":{"test":"vitest"}}' > "$D/package.json"; echo x > "$D/src/app.ts" +J=$(cd "$D" && bash "$S/repo-context.sh") +eq "커밋 전 파일이 트리에 잡힌다" "2" "$(field _ "$J" "d['file_count']")" +eq " 경로가 root 기준이다" "src/app.ts" "$(field _ "$J" "[f for f in d['files'] if f.endswith('app.ts')][0]")" + +# tracked 가 이미 있는 레포에서도 untracked 가 합쳐져야 한다. +# (커밋이 없으면 find 폴백이 결함을 가리므로 이 케이스가 진짜 회귀 검사다) +D="$TMP/mixed"; mkdir -p "$D/src"; git_init "$D" +echo a > "$D/src/committed.ts"; git -C "$D" add -A; git -C "$D" commit -q -m init +echo b > "$D/src/new.ts" +J=$(cd "$D" && bash "$S/repo-context.sh") +eq "tracked+untracked 가 합쳐진다" "2" "$(field _ "$J" "d['file_count']")" +eq " untracked 가 포함된다" "True" "$(field _ "$J" "'src/new.ts' in d['files']")" + +# git 저장소가 아니면 find 로 떨어진다 +D="$TMP/nogit"; mkdir -p "$D/src"; echo x > "$D/src/a.ts"; echo y > "$D/README.md" +J=$(cd "$D" && bash "$S/repo-context.sh") +eq "git 아닌 디렉터리도 수집한다" "2" "$(field _ "$J" "d['file_count']")" + +# .gitignore 는 존중한다 +D="$TMP/ignore"; mkdir -p "$D/node_modules/p"; git_init "$D" +echo "node_modules/" > "$D/.gitignore"; echo x > "$D/app.ts"; echo n > "$D/node_modules/p/f.js" +J=$(cd "$D" && bash "$S/repo-context.sh") +eq "node_modules 는 빠진다" "0" "$(field _ "$J" "len([f for f in d['files'] if 'node_modules' in f])")" + +# 자를 때 file_count 는 자르기 전 전체 수여야 한다 +D="$TMP/trunc"; mkdir -p "$D"; git_init "$D" +for i in $(seq 1 250); do echo x > "$D/f$i.txt"; done +J=$(cd "$D" && bash "$S/repo-context.sh") +eq "file_count 는 자르기 전 개수" "250" "$(field _ "$J" "d['file_count']")" +eq " 목록은 상한까지만" "200" "$(field _ "$J" "len(d['files'])")" +eq " 잘림을 표기한다" "True" "$(field _ "$J" "d['files_truncated']")" + +# 검증 명령 감지 - npm 외 생태계도 잡아야 한다 (빈 목록이면 모델이 검증을 건너뛴다) +detect() { # $1 디렉터리명 $2.. 생성할 파일 + local d="$TMP/eco_$1"; shift; mkdir -p "$d"; git_init "$d" + for f in "$@"; do printf '%s' "$3" > "$d/$f"; done + (cd "$d" && bash "$S/repo-context.sh") +} +D="$TMP/py"; mkdir -p "$D"; git_init "$D" +printf '[tool.pytest.ini_options]\nx=1\n[tool.ruff]\ny=2\n' > "$D/pyproject.toml" +J=$(cd "$D" && bash "$S/repo-context.sh") +eq "pyproject 에서 pytest 감지" "pytest" "$(field _ "$J" "d['verify_commands'].get('test')")" + +D="$TMP/go"; mkdir -p "$D"; git_init "$D"; printf 'module x\ngo 1.22\n' > "$D/go.mod" +eq "go.mod 에서 go test 감지" "go test ./..." "$(field _ "$(cd "$D" && bash "$S/repo-context.sh")" "d['verify_commands'].get('test')")" + +D="$TMP/rust"; mkdir -p "$D"; git_init "$D"; printf '[package]\nname="x"\n' > "$D/Cargo.toml" +eq "Cargo.toml 에서 cargo test 감지" "cargo test" "$(field _ "$(cd "$D" && bash "$S/repo-context.sh")" "d['verify_commands'].get('test')")" + +D="$TMP/mk"; mkdir -p "$D"; git_init "$D"; printf 'test:\n\techo t\n' > "$D/Makefile" +eq "Makefile 타깃 감지" "make test" "$(field _ "$(cd "$D" && bash "$S/repo-context.sh")" "d['verify_commands'].get('test')")" + +# bun·pnpm 의 test 서브커맨드는 package.json 스크립트를 돌리지 않는다 +for lk in bun.lock bun.lockb; do + D="$TMP/bun_$lk"; mkdir -p "$D"; git_init "$D" + echo '{"scripts":{"test":"vitest"}}' > "$D/package.json"; touch "$D/$lk" + eq "$lk -> bun run test" "bun run test" "$(field _ "$(cd "$D" && bash "$S/repo-context.sh")" "d['verify_commands'].get('test')")" +done +D="$TMP/pnpm"; mkdir -p "$D"; git_init "$D" +echo '{"scripts":{"test":"vitest"}}' > "$D/package.json"; touch "$D/pnpm-lock.yaml" +eq "pnpm -> pnpm run test" "pnpm run test" "$(field _ "$(cd "$D" && bash "$S/repo-context.sh")" "d['verify_commands'].get('test')")" + +D="$TMP/plain"; mkdir -p "$D"; git_init "$D"; echo hi > "$D/README.md" +eq "감지 실패는 verify_detected=false" "False" "$(field _ "$(cd "$D" && bash "$S/repo-context.sh")" "d['verify_detected']")" + +echo "repo-state.sh" + +# staged·unstaged·untracked 가 모두 changed_files 에 들어간다 +D="$TMP/state"; mkdir -p "$D/sub" "$D/sub2"; git_init "$D" +git -C "$D" commit -q --allow-empty -m init +echo r > "$D/root_untracked.txt"; echo s > "$D/sub/sub_untracked.txt" +echo x > "$D/sub2/x.txt"; git -C "$D" add sub2/x.txt +J=$(cd "$D" && bash "$S/repo-state.sh") +eq "세 상태가 모두 잡힌다" "3" "$(field _ "$J" "d['file_count']")" + +# 서브디렉터리에서 실행해도 결과가 같아야 한다 +# (git ls-files --others 는 cwd 하위만 cwd 기준 경로로 낸다) +J2=$(cd "$D/sub" && bash "$S/repo-state.sh") +eq "서브디렉터리에서도 동일" "3" "$(field _ "$J2" "d['file_count']")" +eq " root 파일이 안 빠진다" "True" "$(field _ "$J2" "'root_untracked.txt' in d['changed_files']")" +eq " 경로가 root 기준이다" "True" "$(field _ "$J2" "'sub/sub_untracked.txt' in d['changed_files']")" + +echo "pr-context.sh" + +# 셸 인젝션 - 인자가 명령으로 해석되면 안 된다 +MARK="$TMP/pwned" +bash "$S/pr-context.sh" "44; touch $MARK; #" >/dev/null 2>&1 +eq "인젝션 차단" "no" "$([ -e "$MARK" ] && echo yes || echo no)" + +for bad in "" "abc" "44abc" '$(id)'; do + out=$(bash "$S/pr-context.sh" "$bad" 2>/dev/null) + eq "잘못된 입력 거부 [${bad:-빈값}]" "True" "$(field _ "$out" "'error' in d")" +done + +# gh 조회 실패를 빈 PR 로 성공 처리하지 않는다 (없는 코드에 대한 리뷰 방지). +# 가짜 gh 를 PATH 앞에 두어 인증 상태와 무관하게 각 실패 지점을 재현한다. +FAKE="$TMP/bin"; mkdir -p "$FAKE" +make_gh() { # $1 view 결과(ok|fail) $2 diff 결과(ok|fail) + cat > "$FAKE/gh" <&2; exit 1; } + echo "diff --git a/a.ts b/a.ts"; exit 0 +fi +if [ "\$1" = "pr" ] && [ "\$2" = "view" ]; then + [ "$1" = "ok" ] || { echo "error: not found" >&2; exit 1; } + echo '{"title":"x","state":"OPEN","files":[{"path":"a.ts"}],"additions":1,"deletions":0,"changedFiles":1,"author":{"login":"u"},"baseRefName":"main","headRefName":"f","body":"","reviewDecision":null,"reviews":[],"comments":[]}' + exit 0 +fi +exit 1 +GHEOF + chmod +x "$FAKE/gh" +} + +make_gh fail fail +out=$(PATH="$FAKE:$PATH" bash "$S/pr-context.sh" 44 2>/dev/null); rc=$? +eq "메타 조회 실패는 error" "True" "$(field _ "$out" "'error' in d")" +eq " exit code 가 0 이 아니다" "1" "$rc" + +# 메타는 성공하고 diff 만 실패하는 경우 - 가장 위험한 경로 +# (코드 없이 리뷰가 만들어진다) +make_gh ok fail +out=$(PATH="$FAKE:$PATH" bash "$S/pr-context.sh" 44 2>/dev/null); rc=$? +eq "diff 조회 실패도 error" "True" "$(field _ "$out" "'error' in d")" +eq " exit code 가 0 이 아니다" "1" "$rc" + +make_gh ok ok +out=$(PATH="$FAKE:$PATH" bash "$S/pr-context.sh" 44 2>/dev/null); rc=$? +eq "둘 다 성공하면 error 없음" "False" "$(field _ "$out" "'error' in d")" +eq " exit code 0" "0" "$rc" +eq " diff 가 담긴다" "True" "$(field _ "$out" "len(d['diff'])>0")" + +echo +echo "통과 $PASS · 실패 $FAIL" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 317627a..97642fb 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -37,6 +37,9 @@ jobs: - name: Dangerous-command hook tests (real JSON -> shell -> grep chain) run: bash .github/scripts/test_danger_hook.sh + - name: Context script tests (real temp repos, no model calls) + run: bash .github/scripts/test_context_scripts.sh + - name: Prompt surface measurement (reported, non-blocking) run: | bash .github/scripts/measure.sh HEAD >> "$GITHUB_STEP_SUMMARY" || true diff --git a/CLAUDE.md b/CLAUDE.md index ee60f61..0316788 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ A unified Claude Code plugin enabling AI-powered Vibe Coding: idea to production with minimal friction. -**Version**: 0.1.16 +**Version**: 0.1.17 **License**: Apache-2.0 **Repository**: https://github.com/wigtn/wigtn-plugins @@ -19,6 +19,7 @@ wigtn-plugins/ │ ├── agents/ # 11 agent definitions │ ├── commands/ # 5 commands (/prd, /screen-spec, /implement, /auto-commit, /review-pr) │ ├── skills/ # 7 skills (code-review-levels, design-system-reference, handdrawn-diagram, screen-spec, team-memory-protocol, wigtn-ppt) +│ ├── scripts/ # Bundled shell scripts commands call directly │ └── hooks/ # Hooks configuration ├── CLAUDE.md # This file ├── README.md # English docs @@ -33,6 +34,7 @@ plugins/wigtn-plugins/ ├── agents/ # Agent definitions (.md) ├── commands/ # User-invocable commands (.md) ├── skills/ # Skills with SKILL.md + reference files +├── scripts/ # Bundled scripts invoked via ${CLAUDE_PLUGIN_ROOT} └── hooks/hooks.json # Hooks configuration ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f2d594..b319a92 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,7 @@ plugins/wigtn-plugins/ ├── agents/ *.md # one file per agent ├── commands/ *.md # one file per slash command ├── skills/ /SKILL.md # one folder per skill +├── scripts/ *.sh # bundled scripts commands call directly └── hooks/hooks.json ``` @@ -42,6 +43,13 @@ plugins/wigtn-plugins/ `allowed-tools`. See existing skills for the pattern. - **Mermaid in generated docs**: quote every label (`["..."]`), use only valid shapes, no `mindmap` (use `flowchart LR`). +- **Bundled scripts**: a command that needs several read-only lookups (`git`, + `gh`, file probes) calls one script under `scripts/` via + `bash "${CLAUDE_PLUGIN_ROOT}/scripts/.sh"` instead of listing the commands + for the model to run one by one. Return a single JSON object, cap unbounded + fields, and never let a missing tool abort the script. Keep the decision in the + command (the model), not in the script. Add a line telling the model not to run + the individual commands anyway — without it the model re-runs them to confirm. ## Versioning & releases diff --git a/README.cn.md b/README.cn.md index a2bb04d..3220082 100644 --- a/README.cn.md +++ b/README.cn.md @@ -6,7 +6,7 @@ **一个插件。11 个智能体。从创意到通过验证的提交。** -![Version](https://img.shields.io/badge/v0.1.16-Unified_Plugin-FF6B6B?style=for-the-badge) +![Version](https://img.shields.io/badge/v0.1.17-Unified_Plugin-FF6B6B?style=for-the-badge) ![Agents](https://img.shields.io/badge/11-Agents-5A67D8?style=for-the-badge) ![Commands](https://img.shields.io/badge/5-Commands-38B2AC?style=for-the-badge) ![Skills](https://img.shields.io/badge/6-Skills-00D4AA?style=for-the-badge) diff --git a/README.ko.md b/README.ko.md index c1c5bab..d8b8184 100644 --- a/README.ko.md +++ b/README.ko.md @@ -6,7 +6,7 @@ **하나의 플러그인. 11개 에이전트. 아이디어에서 검증된 커밋까지.** -![Version](https://img.shields.io/badge/v0.1.16-Unified_Plugin-FF6B6B?style=for-the-badge) +![Version](https://img.shields.io/badge/v0.1.17-Unified_Plugin-FF6B6B?style=for-the-badge) ![Agents](https://img.shields.io/badge/11-Agents-5A67D8?style=for-the-badge) ![Commands](https://img.shields.io/badge/5-Commands-38B2AC?style=for-the-badge) ![Skills](https://img.shields.io/badge/6-Skills-00D4AA?style=for-the-badge) diff --git a/README.md b/README.md index 4ddb138..de17526 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **One plugin. 11 agents. From idea to a verified commit.** -![Version](https://img.shields.io/badge/v0.1.16-Unified_Plugin-FF6B6B?style=for-the-badge) +![Version](https://img.shields.io/badge/v0.1.17-Unified_Plugin-FF6B6B?style=for-the-badge) ![Agents](https://img.shields.io/badge/11-Agents-5A67D8?style=for-the-badge) ![Commands](https://img.shields.io/badge/5-Commands-38B2AC?style=for-the-badge) ![Skills](https://img.shields.io/badge/6-Skills-00D4AA?style=for-the-badge) diff --git a/plugins/wigtn-plugins/.claude-plugin/plugin.json b/plugins/wigtn-plugins/.claude-plugin/plugin.json index 8c7776b..afbd766 100644 --- a/plugins/wigtn-plugins/.claude-plugin/plugin.json +++ b/plugins/wigtn-plugins/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "wigtn-plugins", - "version": "0.1.16", + "version": "0.1.17", "description": "AI-native Vibe Coding — From idea to Deploy, Zero friction. 11 agents, 5 commands, 7 skills, team-based parallel build, 20 design styles, screen-spec generator (IA / User Flow / Wireframe HTML), WIGTN-brand PPT generator.", "agents": [ "./agents/ai-agent.md", diff --git a/plugins/wigtn-plugins/commands/auto-commit.md b/plugins/wigtn-plugins/commands/auto-commit.md index 6cfc8af..92e4a8d 100644 --- a/plugins/wigtn-plugins/commands/auto-commit.md +++ b/plugins/wigtn-plugins/commands/auto-commit.md @@ -157,26 +157,29 @@ fi ### Step 1: 상태 확인 및 브랜치 판단 -```bash -git fetch origin # 최신 변경사항 가져오기 -current_branch=$(git branch --show-current) -git status -git diff --stat -git diff --stat HEAD # 스테이징 안된 파일 포함 -git remote -v +상태 조회는 스크립트가 한 번에 처리한다. **개별 `git`/`gh` 명령을 따로 실행하지 말 것.** -# ⚠️ Stale 검사 — 현재 브랜치에 머지/닫힌 PR이 있는가? (Stale Branch Handling 참조) -gh pr list --head "$current_branch" --state all --json number,state,title --limit 1 - -# 다른 브랜치 재사용 판단용 open PR 목록 -gh pr list --state open --json number,title,headRefName - -# PRD/PLAN 파일 확인 (feature name 추출) -# 정본 경로는 `/prd`가 쓰는 docs/todo_plan/ 이다. 루트 fallback은 구버전 산출물용. -ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo .) -ls "$ROOT"/docs/todo_plan/PLAN_*.md "$ROOT"/PLAN_*.md "$ROOT"/PRD.md 2>/dev/null +```bash +bash "${CLAUDE_PLUGIN_ROOT}/scripts/repo-state.sh" ``` +반환 JSON 필드: + +| 필드 | 쓰임 | +|---|---| +| `branch` · `on_main` | 브랜치 판단 | +| `staged_stat` · `unstaged_stat` | 변경 규모 | +| `file_count` | 전체 변경 파일 수 (staged + unstaged + untracked) | +| `changed_files` · `files_truncated` | 변경 파일 목록. 50개를 넘으면 잘리고 `files_truncated`가 true | +| `staged_files` · `unstaged_files` · `untracked_files` | 스테이징 상태별 분류 | +| `remotes` | remote 여러 개인지 | +| `head_pr` | 현재 브랜치의 PR (Stale 검사) | +| `stale_branch` | **머지/닫힌 PR이면 `true`** — 새 브랜치 분기 필요 | +| `open_prs` | 다른 브랜치 재사용 판단용 | +| `plan_files` | PRD/PLAN 경로 (feature name 추출) | + +`git fetch origin`은 스크립트가 이미 실행한다. 파일 내용이 더 필요하면 그때 Read 한다. + **브랜치 판단:** 1. `--direct` → Direct 모드, 브랜치 판단 스킵 2. 피처 브랜치: Stale 검사 먼저 (머지/닫힌 PR이면 새 브랜치 분기) → Open PR이면 재사용 → PR 없으면 그대로 사용 + 새 PR diff --git a/plugins/wigtn-plugins/commands/implement.md b/plugins/wigtn-plugins/commands/implement.md index f810361..006e93b 100644 --- a/plugins/wigtn-plugins/commands/implement.md +++ b/plugins/wigtn-plugins/commands/implement.md @@ -135,7 +135,19 @@ BLOCKED이면 Critical 이슈(번호·위치·영향)를 나열하고 ① PRD ### Step 3~4: 프로젝트 상태 분석 -기존 구현 여부, 관련 파일 위치, 사용 중인 패턴·컨벤션을 파악해 **이미 된 부분은 다시 만들지 않는다**. 새 코드는 발견한 컨벤션을 따른다. +레포 상태 조회는 스크립트가 한 번에 처리한다. **`ls`/`find`/`cat package.json`/`git log`/`node -v` 같은 조회 명령을 따로 실행하지 말 것.** + +```bash +bash "${CLAUDE_PLUGIN_ROOT}/scripts/repo-context.sh" +``` + +반환 JSON에 파일 목록·디렉터리 구조·`package.json` 스크립트·**검증 명령(`verify_commands`, 감지 여부는 `verify_detected`)**·PRD/PLAN 경로·런타임 버전·git 상태가 들어 있다. 파일 목록은 tracked·untracked 를 모두 담되 200개에서 자르고, 잘리면 `files_truncated`가 true다(`file_count`는 자르기 전 전체 수). + +**`verify_detected`가 true면 검증은 `verify_commands`에 적힌 명령만 쓴다.** `npm test`가 실패한다고 다른 테스트 러너 호출 방식(node --test 변형 등)을 시도하지 말 것 — 명령이 틀린 게 아니라 코드가 틀린 것이다. + +**`verify_detected`가 false면 검증을 건너뛰지 말고 직접 찾는다.** 스크립트가 감지하는 것은 npm/pnpm/yarn/bun 스크립트, `pyproject.toml`(pytest·ruff·mypy), `go.mod`, `Cargo.toml`, Makefile 타깃까지다. 그 밖의 빌드 체계는 레포에서 검증 방법을 찾아 실행하고, 정말 없으면 없다는 사실을 보고한다. + +이 JSON으로 기존 구현 여부, 관련 파일 위치, 사용 중인 패턴·컨벤션을 파악해 **이미 된 부분은 다시 만들지 않는다**. 파일 내용이 더 필요하면 그때 Read 한다. 새 코드는 발견한 컨벤션을 따른다. **화면정의서가 있으면 읽는다.** `docs/prd/screens/{feature}/` 가 존재하면 `03-SCREEN-SPEC.md`(화면별 상태·컴포넌트)와 `05-DEV-HANDOFF.md`(FR ↔ 화면 ↔ 컴포넌트 매핑)를 diff --git a/plugins/wigtn-plugins/commands/review-pr.md b/plugins/wigtn-plugins/commands/review-pr.md index 302f09e..cd5d00b 100644 --- a/plugins/wigtn-plugins/commands/review-pr.md +++ b/plugins/wigtn-plugins/commands/review-pr.md @@ -35,16 +35,15 @@ GitHub Pull Request를 터미널에서 리뷰하고 피드백을 남깁니다. ### Step 1: PR 정보 수집 +PR 정보 수집은 스크립트가 한 번에 처리한다. **개별 `gh` 명령을 따로 실행하지 말 것.** + ```bash -# PR 메타데이터 가져오기 -gh pr view $PR_NUMBER --json title,body,author,baseRefName,headRefName,files,additions,deletions,changedFiles,reviewDecision,reviews,state +bash "${CLAUDE_PLUGIN_ROOT}/scripts/pr-context.sh" $PR_NUMBER +``` -# PR diff 가져오기 -gh pr diff $PR_NUMBER +반환 JSON에 메타데이터(`state`·`title`·`body`·`author`·`files`·`stats`), 전체 `diff`, 기존 코멘트·리뷰가 모두 들어 있다. `diff_truncated`가 true일 때만 추가로 조회한다. -# PR에 달린 기존 리뷰/코멘트 확인 -gh pr view $PR_NUMBER --json comments,reviews -``` +**JSON에 `error` 필드가 있으면 리뷰를 진행하지 말고 중단한 뒤 사용자에게 알린다.** PR 번호·인증·레포 중 하나가 잘못된 것이라, 빈 diff로 리뷰를 만들면 없는 코드를 리뷰하게 된다. **PR 상태 확인:** - `state: MERGED` → "이미 머지된 PR입니다. 리뷰를 계속할까요?" (AskUserQuestion) diff --git a/plugins/wigtn-plugins/scripts/pr-context.sh b/plugins/wigtn-plugins/scripts/pr-context.sh new file mode 100755 index 0000000..6da80fe --- /dev/null +++ b/plugins/wigtn-plugins/scripts/pr-context.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# PR 리뷰에 필요한 정보를 한 번에 모아 JSON으로 반환한다. +# 모델이 gh pr view / gh pr diff / gh pr view --json comments 를 따로 부를 필요가 없다. +PR="$1" +if ! command -v python3 >/dev/null 2>&1; then + printf '{"error":"python3 가 필요하다 — 설치 후 다시 실행"}\n' + exit 1 +fi +exec python3 - "$PR" <<'PY' +import subprocess, json, sys, re + +# PR 참조는 숫자 또는 GitHub PR URL 만 받는다. 셸에 넘기지 않지만, +# 잘못된 입력을 조용히 흘려보내지 않도록 여기서 잘라낸다. +raw = sys.argv[1] if len(sys.argv) > 1 else '' +m = re.fullmatch( + r'\s*(?:https?://[^\s/]+/(?P[^\s/]+)/(?P[^\s/]+)/pull/)?#?(?P\d{1,10})\s*', + raw or '') +if not m: + print(json.dumps({'error': 'PR 번호 또는 GitHub PR URL 이 필요하다', 'given': raw}, + ensure_ascii=False)) + sys.exit(1) +pr = m.group('num') +# URL 로 받았으면 그 레포를 그대로 쓴다. 번호만 뽑으면 현재 레포의 동일 번호 PR 을 +# 조용히 리뷰하게 된다. +repo_args = (['--repo', f"{m.group('owner')}/{m.group('repo')}"] + if m.group('owner') and m.group('repo') else []) + +def run(args, d=''): + # shell=False. 인자 배열이라 PR 번호가 명령으로 해석되지 않는다. + try: + r = subprocess.run(args, capture_output=True, text=True, timeout=40) + return r.stdout.strip() if r.returncode == 0 else d + except Exception: + return d + +def run_ok(args): + # 실패를 빈 문자열로 뭉개지 않는다. 호출부가 fail-closed 로 판단할 수 있어야 한다. + try: + r = subprocess.run(args, capture_output=True, text=True, timeout=40) + return r.returncode == 0, (r.stdout or '').strip(), (r.stderr or '').strip() + except Exception as e: + return False, '', str(e) + +def j(args): + o = run(args) + try: return json.loads(o) if o else {} + except Exception: return {} + +meta = j(['gh', 'pr', 'view', pr] + repo_args + ['--json', + 'title,body,author,baseRefName,headRefName,files,' + 'additions,deletions,changedFiles,reviewDecision,reviews,state']) +conv = j(['gh', 'pr', 'view', pr] + repo_args + ['--json', 'comments,reviews']) +# 조회가 실패하면 빈 PR 로 성공 처리하지 않는다. 리뷰 커맨드의 가장 위험한 실패 +# 모드가 "없는 코드에 대한 리뷰"라, 입력 검증과 같은 fail-closed 로 맞춘다. +if not meta: + print(json.dumps({'pr': pr, + 'error': 'gh pr view 실패 — PR 번호·인증·레포를 확인'}, + ensure_ascii=False)) + sys.exit(1) +# diff 조회 실패도 빈 diff 로 흘려보내지 않는다. 메타데이터만 있고 코드가 없으면 +# 모델이 "변경 없음"으로 읽고 리뷰를 만들어 버린다. +diff_ok, diff, diff_err = run_ok(['gh', 'pr', 'diff', pr] + repo_args) +if not diff_ok: + print(json.dumps({'pr': pr, + 'error': 'gh pr diff 실패 — 인증·네트워크·권한을 확인', + 'detail': diff_err[:300]}, ensure_ascii=False)) + sys.exit(1) +LIMIT = 60000 +print(json.dumps({ + 'pr': pr, + 'state': meta.get('state'), + 'title': meta.get('title'), + 'body': meta.get('body'), + 'author': (meta.get('author') or {}).get('login'), + 'base': meta.get('baseRefName'), 'head': meta.get('headRefName'), + 'files': meta.get('files', []), + 'stats': {'additions': meta.get('additions'), 'deletions': meta.get('deletions'), + 'changed_files': meta.get('changedFiles')}, + 'existing_comments': conv.get('comments', []), + 'existing_reviews': conv.get('reviews', []), + 'diff_truncated': len(diff) > LIMIT, + 'diff': diff[:LIMIT], +}, ensure_ascii=False, indent=1)) +PY diff --git a/plugins/wigtn-plugins/scripts/repo-context.sh b/plugins/wigtn-plugins/scripts/repo-context.sh new file mode 100755 index 0000000..e8470d5 --- /dev/null +++ b/plugins/wigtn-plugins/scripts/repo-context.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# 구현 시작 전 레포 상태를 한 번에 수집해 JSON으로 반환한다. +# 모델이 ls/find/cat/npm test 를 개별로 돌려가며 알아낼 필요가 없다. +if ! command -v python3 >/dev/null 2>&1; then + printf '{"error":"python3 가 필요하다 — 설치 후 다시 실행"}\n' + exit 1 +fi +exec python3 - <<'PY' +import subprocess, json, os, glob, re +def run(c, d=''): + try: + r = subprocess.run(c, shell=True, capture_output=True, text=True, timeout=25) + return r.stdout.strip() if r.returncode == 0 else d + except Exception: + return d + +root = run('git rev-parse --show-toplevel', '.') +os.chdir(root) + +# 소스 트리 — tracked + untracked(.gitignore 존중). +# git ls-files 만 쓰면 아직 커밋 안 한 파일이 통째로 빠진다(새 프로젝트/작업 중 레포). +tracked = run('git ls-files') +untracked = run('git ls-files --others --exclude-standard') +found = [f for f in (tracked + '\n' + untracked).split('\n') if f] +if not found: # git 저장소가 아니면 + found = [f for f in run( + "find . -type f -not -path './.git/*' -not -path './node_modules/*'" + ).split('\n') if f] +all_files = sorted(set(found)) +FILE_LIMIT = 200 +files = all_files[:FILE_LIMIT] + +pkg = {} +if os.path.exists('package.json'): + try: pkg = json.load(open('package.json')) + except Exception: pkg = {} +scripts = pkg.get('scripts', {}) + +# 검증 명령을 실제 프로젝트 파일에서 감지한다 — 모델이 시행착오로 찾지 않도록. +# 감지 못 하면 verify_commands 를 비우고 verify_detected=false 로 알린다. +# (커맨드 계약: 비어 있으면 모델이 직접 찾는다. 임의로 검증을 건너뛰지 않는다.) +verify = {} + +# JS/TS — 락파일로 패키지 매니저를 맞춘다 +if scripts: + pm = 'npm' + for lock, name in (('pnpm-lock.yaml','pnpm'), ('yarn.lock','yarn'), + ('bun.lockb','bun'), ('bun.lock','bun')): + if os.path.exists(lock): pm = name; break + for key in ('test', 'typecheck', 'lint', 'build'): + if key in scripts: + # pnpm·bun 의 'test' 서브커맨드는 package.json 스크립트를 돌리지 않는다 + # (bun test 는 Bun 내장 러너). 둘은 항상 run 을 거친다. + direct = key == 'test' and pm in ('npm', 'yarn') + verify[key] = f'{pm} test' if direct else f'{pm} run {key}' + +# Python +if os.path.exists('pyproject.toml'): + try: t = open('pyproject.toml', encoding='utf-8').read() + except Exception: t = '' + if 'pytest' in t: verify.setdefault('test', 'pytest') + if 'ruff' in t: verify.setdefault('lint', 'ruff check .') + if 'mypy' in t: verify.setdefault('typecheck', 'mypy .') +elif os.path.exists('tox.ini') or os.path.exists('pytest.ini'): + verify.setdefault('test', 'pytest') + +# Go +if os.path.exists('go.mod'): + verify.setdefault('test', 'go test ./...') + verify.setdefault('typecheck', 'go vet ./...') + +# Rust +if os.path.exists('Cargo.toml'): + verify.setdefault('test', 'cargo test') + verify.setdefault('lint', 'cargo clippy') + +# Makefile — 실제로 정의된 타깃만 +if os.path.exists('Makefile'): + try: mk = open('Makefile', encoding='utf-8').read() + except Exception: mk = '' + for tgt in ('test', 'lint', 'check', 'typecheck'): + if re.search(rf'^{tgt}:', mk, re.M): verify.setdefault(tgt, f'make {tgt}') + +state = { + 'root': root, + 'branch': run('git branch --show-current'), + 'file_count': len(all_files), + 'files': files, + 'files_truncated': len(all_files) > FILE_LIMIT, + 'dirs': sorted({os.path.dirname(f) for f in all_files if os.path.dirname(f)})[:40], + 'package_json': {'type': pkg.get('type'), 'scripts': scripts, 'deps': sorted((pkg.get('dependencies') or {}).keys())[:30]}, + 'verify_commands': verify, + 'verify_detected': bool(verify), + 'runtime': {'node': run('node -v'), 'python': run('python3 -V')}, + 'prd_files': sorted(glob.glob('docs/prd/PRD_*.md') + glob.glob('docs/todo_plan/PRD.md') + glob.glob('PRD.md')), + 'plan_files': sorted(glob.glob('docs/todo_plan/PLAN_*.md') + glob.glob('PLAN_*.md')), + 'screen_specs': sorted(glob.glob('docs/prd/screens/*/*.md'))[:20], + 'git_status': run('git status --short'), + 'recent_commits': run('git log --oneline -5'), +} +print(json.dumps(state, ensure_ascii=False, indent=1)) +PY diff --git a/plugins/wigtn-plugins/scripts/repo-state.sh b/plugins/wigtn-plugins/scripts/repo-state.sh new file mode 100755 index 0000000..157a33a --- /dev/null +++ b/plugins/wigtn-plugins/scripts/repo-state.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# 커밋 전 상태를 한 번에 수집해 JSON으로 반환한다. 모델이 개별 명령을 돌릴 필요가 없다. +if ! command -v python3 >/dev/null 2>&1; then + printf '{"error":"python3 가 필요하다 — 설치 후 다시 실행"}\n' + exit 1 +fi +exec python3 - <<'PY' +import subprocess, json, os, glob +def run(c, d=''): + try: + r=subprocess.run(c, shell=True, capture_output=True, text=True, timeout=25) + return r.stdout.strip() if r.returncode==0 else d + except Exception: return d +def runa(args, d=''): + # 브랜치명 등 외부 값이 섞이는 명령은 shell=False 인자 배열로 실행한다. + try: + r=subprocess.run(args, capture_output=True, text=True, timeout=25) + return r.stdout.strip() if r.returncode==0 else d + except Exception: return d +def jruna(args): + o=runa(args) + try: return json.loads(o) if o else [] + except Exception: return [] +def jrun(c): + o=run(c) + try: return json.loads(o) if o else [] + except Exception: return [] +root=run('git rev-parse --show-toplevel','.') +# git ls-files --others 는 cwd 하위만 cwd 기준 경로로 낸다. 서브디렉터리에서 실행하면 +# 다른 디렉터리의 새 파일이 빠지고 경로 기준도 섞이므로 repo 루트로 이동해서 수집한다. +try: os.chdir(root) +except Exception: pass +br=run('git branch --show-current') +run('git fetch origin --quiet') +staged=[x for x in run('git diff --cached --name-only').split('\n') if x] +unstaged=[x for x in run('git diff --name-only').split('\n') if x] +untracked=[x for x in run('git ls-files --others --exclude-standard').split('\n') if x] +all_files=sorted(set(staged+unstaged+untracked)) +LIMIT=50 +files=all_files[:LIMIT] +head_pr=jruna(['gh','pr','list','--head',br,'--state','all', + '--json','number,state,title','--limit','1']) if br else [] +state={ + 'branch':br, + 'on_main': br in ('main','master'), + 'staged_stat': (run('git diff --cached --stat').split('\n') or [''])[-1], + 'unstaged_stat': (run('git diff --stat').split('\n') or [''])[-1], + 'changed_files': files, + 'file_count': len(all_files), + 'files_truncated': len(all_files) > LIMIT, + 'staged_files': staged[:50], + 'unstaged_files': unstaged[:50], + 'untracked_files': untracked[:50], + 'remotes': [x for x in run('git remote').split('\n') if x], + 'head_pr': head_pr, + 'open_prs': jrun('gh pr list --state open --json number,title,headRefName --limit 20'), + 'plan_files': sorted(glob.glob(f'{root}/docs/todo_plan/PLAN_*.md')+glob.glob(f'{root}/PLAN_*.md')+glob.glob(f'{root}/PRD.md')), + 'stale_branch': bool(head_pr) and head_pr[0].get('state') in ('MERGED','CLOSED'), +} +print(json.dumps(state, ensure_ascii=False, indent=1)) +PY