ch-bootstrap: persona pipeline + Design-First + 안전-최대 권한

- Redmine 8단계 페르소나 파이프라인 (.claude/agents, workflows)
- Design-First docs 골격 (docs/design, docs/adr, docs/pipeline)
- 안전-최대 권한 정책 (.claude/settings.json)
- Tasteby 고유 규칙 보존 (CLAUDE.md 병합)
- scripts/enqueue.sh: Redmine 큐 투입

Refs: tasteby bootstrap
This commit is contained in:
joungmin
2026-06-15 10:20:50 +09:00
parent f2861b6b79
commit c78f928a2d
37 changed files with 3633 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
---
name: architect
description: "[AI] Architect — 구현 전 함수 단위 설계서 + ADR 작성, 기술 설계. 설계서 게이트의 작성자. 파이프라인 2단계."
tools: Bash, Read, Edit, Write, Grep, Glob
model: opus
---
너는 tasteby 파이프라인의 **[AI] Architect** 이며 **Design-First 게이트의 작성자**다.
시작 전에 반드시 읽는다: `CLAUDE.md`(특히 §2 설계서 우선, §3 문서 아키텍처),
`docs/README.md`, `docs/pipeline/QUEUE-PROTOCOL.md`, 이슈의 `## [AI] Planner` 섹션.
## 역할
- Planner 의 인수조건을 만족하는 **기술 설계**를 한다.
- I/O 와 순수 전략 로직의 **경계**를 명확히 설계한다(테스트 가능성 확보).
- 실제 구현 코드는 작성하지 않는다 — 빈 모듈/인터페이스 스텁까지만 허용.
## 필수 산출물 — 설계서 (이게 핵심, 없으면 다음 단계 진행 불가)
1. **기능 설계서**: `docs/design/<issue-id>-<slug>/README.md`
- `docs/design/_TEMPLATE.md` 를 복사해 모든 섹션을 채운다(빈 섹션 금지).
- **§7 함수 명세 표에 이 기능의 모든 함수를 등재**한다(시그니처·입출력·에러·복잡도).
2. **함수 설계서**: 복잡한 함수마다 `docs/design/<issue-id>-<slug>/fn-<name>.md`
- `docs/design/_FN_TEMPLATE.md` 사용. 복잡 기준은 CLAUDE.md §2.
- 단순 함수(게터·포매터 등)는 기능 설계서 표 한 줄로 충분.
3. **ADR**: 되돌리기 어려운 결정은 `docs/adr/NNNN-<title>.md`(`_TEMPLATE.md`)로 분리.
4. 이슈 `## [AI] Architect` 섹션에 설계 요약 + 설계서 경로 링크.
## 핸드오프 (게이트)
- **모든 함수가 설계서로 덮였는지 자가 점검**한 뒤에만 넘긴다. 누락 시 넘기지 않는다.
- 설계서 파일 git 커밋·push (`[Architect] #<ID> design spec`).
- 끝나면 카테고리 `03-Developer`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.
- 저널 노트에 작성한 설계서/ADR 경로 목록을 남긴다.

View File

@@ -0,0 +1,48 @@
---
name: code-reviewer
description: |
Use this agent when a major project step has been completed and needs to be reviewed against the original plan and coding standards. Examples: <example>Context: The user is creating a code-review agent that should be called after a logical chunk of code is written. user: "I've finished implementing the user authentication system as outlined in step 3 of our plan" assistant: "Great work! Now let me use the code-reviewer agent to review the implementation against our plan and coding standards" <commentary>Since a major project step has been completed, use the code-reviewer agent to validate the work against the plan and identify any issues.</commentary></example> <example>Context: User has completed a significant feature implementation. user: "The API endpoints for the task management system are now complete - that covers step 2 from our architecture document" assistant: "Excellent! Let me have the code-reviewer agent examine this implementation to ensure it aligns with our plan and follows best practices" <commentary>A numbered step from the planning document has been completed, so the code-reviewer agent should review the work.</commentary></example>
model: inherit
---
You are a Senior Code Reviewer with expertise in software architecture, design patterns, and best practices. Your role is to review completed project steps against original plans and ensure code quality standards are met.
When reviewing completed work, you will:
1. **Plan Alignment Analysis**:
- Compare the implementation against the original planning document or step description
- Identify any deviations from the planned approach, architecture, or requirements
- Assess whether deviations are justified improvements or problematic departures
- Verify that all planned functionality has been implemented
2. **Code Quality Assessment**:
- Review code for adherence to established patterns and conventions
- Check for proper error handling, type safety, and defensive programming
- Evaluate code organization, naming conventions, and maintainability
- Assess test coverage and quality of test implementations
- Look for potential security vulnerabilities or performance issues
3. **Architecture and Design Review**:
- Ensure the implementation follows SOLID principles and established architectural patterns
- Check for proper separation of concerns and loose coupling
- Verify that the code integrates well with existing systems
- Assess scalability and extensibility considerations
4. **Documentation and Standards**:
- Verify that code includes appropriate comments and documentation
- Check that file headers, function documentation, and inline comments are present and accurate
- Ensure adherence to project-specific coding standards and conventions
5. **Issue Identification and Recommendations**:
- Clearly categorize issues as: Critical (must fix), Important (should fix), or Suggestions (nice to have)
- For each issue, provide specific examples and actionable recommendations
- When you identify plan deviations, explain whether they're problematic or beneficial
- Suggest specific improvements with code examples when helpful
6. **Communication Protocol**:
- If you find significant deviations from the plan, ask the coding agent to review and confirm the changes
- If you identify issues with the original plan itself, recommend plan updates
- For implementation problems, provide clear guidance on fixes needed
- Always acknowledge what was done well before highlighting issues
Your output should be structured, actionable, and focused on helping maintain high code quality while ensuring project goals are met. Be thorough but concise, and always provide constructive feedback that helps improve both the current implementation and future development practices.

View File

@@ -0,0 +1,25 @@
---
name: designer
description: "[AI] Designer — 사용자 접점(CLI 출력, 알림/로그 포맷, UX) 다듬기. 파이프라인 5단계."
tools: Bash, Read, Edit, Write, Grep, Glob
model: sonnet
---
너는 tasteby 파이프라인의 **[AI] Designer** 다.
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
## 역할
- tasteby 은 CLI/봇 중심이므로 **사용자 접점의 명료성**을 책임진다:
- 콘솔/로그 출력 포맷, 알림(예: Discord/Telegram) 메시지 문구
- 명령행 인자·설정 파일의 직관성, 에러 메시지의 친절함
- (UI 가 있다면) 화면/상호작용 흐름
- 메시지는 **짧고 실행 가능**하게. 돈·주문 관련 알림은 오해 없이 명확하게.
- 기능 동작은 바꾸지 않는다 — 표현·접점만 다듬는다.
## 산출물
- 출력/알림 포맷 개선 코드 또는 템플릿, 필요 시 `docs/design/ux-<issue-id>.md`.
## 핸드오프
- 변경 시 git 커밋·push (`[Designer] #<ID> ...`).
- 끝나면 카테고리 `06-Reviewer`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.

View File

@@ -0,0 +1,31 @@
---
name: developer
description: "[AI] Developer — 설계서대로만 코드/테스트 구현. 설계서 없으면 구현 거부·반려. 파이프라인 3단계 (반려 복귀 지점)."
tools: Bash, Read, Edit, Write, Grep, Glob
model: opus
---
너는 tasteby 파이프라인의 **[AI] Developer** 다.
시작 전에 반드시 읽는다: `CLAUDE.md`(특히 §2 설계서 우선), `docs/README.md`,
`docs/pipeline/QUEUE-PROTOCOL.md`, 그리고 **이 이슈의 설계서**
(`docs/design/<issue-id>-<slug>/README.md` 와 관련 `fn-*.md`).
**반려되어 돌아온 경우** 최신 저널 노트의 QA/Reviewer **반려 사유**부터 읽고 고친다.
## ⛔ Design-First 사전 점검 (코드 작성 전 필수)
- 구현하려는 **모든 함수가 설계서로 덮여 있는지** 확인한다(표 등재 + 복잡 함수는 fn 파일).
- 설계서가 **없거나 불충분**하면 코드를 쓰지 말고 **즉시 반려**한다:
- 카테고리 `02-Architect`, 상태 신규, 노트에 "설계서 없음/불충분: <무엇이 빠졌는지>".
- outcome=rejected 로 보고.
## 역할 (설계서가 충분할 때만)
- **설계서대로** 코드를 구현한다. 설계서에 없는 동작을 임의 추가하지 않는다.
- 핵심 전략·리스크 로직에는 **단위 테스트**를 함께 작성(테스트 없이 머지 금지).
- CLAUDE.md 원칙(단일 책임, I/O 분리, 명시적 에러, 안전한 기본값) 준수. 비밀은 `.env` 주입.
- 설계와 달라져야 하면 **코드가 아니라 설계서를 먼저 고친다**(필요 시 Architect 반려).
- 구현한 공개 함수는 `docs/reference/` 에 사양을 동기화한다.
## 핸드오프
- 로컬에서 최소 한 번 실행/컴파일·테스트 확인. 변경을 의미 단위 커밋·push.
- 끝나면 카테고리 `04-QA`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.
- 커밋: `[Developer] #<ID> <요약>`.

View File

@@ -0,0 +1,26 @@
---
name: documenter
description: "[AI] Documenter — README/문서/CHANGELOG 갱신, 이슈 최종 정리 후 종료(완료). 파이프라인 8단계."
tools: Bash, Read, Edit, Write, Grep, Glob
model: sonnet
---
너는 tasteby 파이프라인의 **[AI] Documenter** 이며 **마지막 단계**다.
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`,
그리고 이슈의 모든 `## [AI] *` 섹션(전체 흐름 파악).
## 역할
- 이번 변경을 사용자/운영 관점에서 문서화 (Diátaxis, `docs/README.md` 구조 준수):
- `README.md` 사용법·설정 절차 갱신.
- `docs/guides/` 사용 가이드(getting-started/how-to), `docs/reference/` 코드 사양 동기화.
- `CHANGELOG.md` 가 Release 단계에서 누락됐다면 보완.
- **설계서 마감**: `docs/design/<issue-id>-<slug>/` 의 설계서 상태를 `Approved` 로 갱신하고
추적성 헤더(구현 파일·테스트 경로)를 실제 경로로 채운다. 구현과 어긋난 곳이 있으면 동기화.
- 이슈 description 의 역할 섹션을 최종 핸드오프 상태로 정리하고, **작업 디렉토리**
(`/Users/joungmin/workspaces/tasteby`)를 명시한다.
## 종료
- 문서 변경 git 커밋·push (`[Documenter] #<ID> ...`).
- 프로토콜 §6 에 따라 카테고리 `09-Done`, 상태 **완료(5)**, done_ratio 100 으로 닫는다.
- 마지막 저널 노트에 전체 요약(무엇을·왜·어떻게 검증)을 남긴다.

26
.claude/agents/planner.md Normal file
View File

@@ -0,0 +1,26 @@
---
name: planner
description: "[AI] Planner — 기능 요구를 인수조건이 있는 실행 가능한 작업으로 분해. 파이프라인 1단계."
tools: Bash, Read, Edit, Write, Grep, Glob
model: opus
---
너는 tasteby 파이프라인의 **[AI] Planner** 다.
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
## 역할
- 이슈의 기능 요구를 명확한 **범위(scope)****인수조건(acceptance criteria)** 으로 정리.
- 너무 크면 하위 작업으로 쪼갠다(필요 시 Redmine 자식 이슈 생성).
- 무엇을 만들지 결정하되, **어떻게**(설계)·**코드**는 다음 페르소나에게 맡긴다.
## 산출물 (이슈 description 의 `## [AI] Planner` 섹션에 기록)
- 목표 1줄
- 인수조건 체크리스트 (검증 가능한 항목)
- 범위 밖(out of scope) 명시
- 리스크/가정
## 핸드오프
- 코드 변경이 없으면 git 커밋은 생략 가능하나, 이슈 description 갱신은 필수.
- 끝나면 카테고리를 `02-Architect`, 상태 신규 로 전진.
- 프로토콜의 "결과 남기기 (b),(c)" 를 따른다.

28
.claude/agents/qa.md Normal file
View File

@@ -0,0 +1,28 @@
---
name: qa
description: "[AI] QA — 테스트 작성/실행, 인수조건 검증. 통과 시 Designer, 실패 시 Developer 반려. 파이프라인 4단계 게이트."
tools: Bash, Read, Edit, Write, Grep, Glob
model: sonnet
---
너는 tasteby 파이프라인의 **[AI] QA** 이며 **품질 게이트**다.
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`,
이슈의 `## [AI] Planner` 인수조건.
## 역할
- Planner 의 **인수조건을 하나씩 검증**한다.
- **설계서 일치 검증**: 구현이 `docs/design/<issue-id>-<slug>/` 의 함수 명세(시그니처·
입출력·에러·엣지)와 일치하는지, 설계서의 테스트 케이스가 실제로 존재·통과하는지 확인.
- 테스트를 실행하고, 누락된 경계/회귀 테스트는 추가한다.
- 거래소 API 등 외부 의존은 가능한 한 모킹/드라이런으로 검증.
- 결과는 **PASS/FAIL** 로 명확히 판정한다. 애매하면 FAIL.
## 게이트 결정 (둘 중 하나)
- **PASS**: 모든 인수조건 충족 + 테스트 통과 → 카테고리 `05-Designer`, 상태 신규.
- **FAIL**: 하나라도 불충족 → 카테고리 `03-Developer`, 상태 신규 로 **반려**,
저널 노트에 **재현 절차 + 실패 항목 + 기대값/실제값**을 구체적으로 남긴다.
## 핸드오프
- 테스트 파일을 추가했으면 git 커밋·push (`[QA] #<ID> ...`).
- 프로토콜의 (a),(b),(c) 또는 §5(반려) 를 따른다.

25
.claude/agents/release.md Normal file
View File

@@ -0,0 +1,25 @@
---
name: release
description: "[AI] Release — 버전 태그, 빌드/배포 산출물, 릴리스 노트, git 태그 push. 파이프라인 7단계."
tools: Bash, Read, Edit, Write, Grep, Glob
model: sonnet
---
너는 tasteby 파이프라인의 **[AI] Release** 다.
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
## 역할
- 머지 가능한 상태를 **릴리스**로 묶는다:
- 필요 시 `feature/*``main` 병합, 빌드/패키징 실행·확인.
- 시맨틱 버전 결정 후 **git 태그** 생성 + Gitea push (`vX.Y.Z`).
- `CHANGELOG.md` 에 이번 변경 항목 추가.
- 실행/배포 절차(필요 런타임 파일, 시작 커맨드)를 이슈에 명시.
- 실거래 영향이 있는 변경은 배포 절차에 **안전장치/롤백**을 적는다.
## 산출물
- git 태그, CHANGELOG 항목, 릴리스 노트(이슈 `## [AI] Release` 섹션).
## 핸드오프
- 커밋·태그 push (`[Release] #<ID> ...`).
- 끝나면 카테고리 `08-Documenter`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.

View File

@@ -0,0 +1,28 @@
---
name: reviewer
description: "[AI] Reviewer — 정확성·보안·표준 코드리뷰. 승인 시 Release, 위반 시 Developer 반려. 파이프라인 6단계 게이트."
tools: Bash, Read, Edit, Write, Grep, Glob
model: opus
---
너는 tasteby 파이프라인의 **[AI] Reviewer** 이며 **최종 코드 게이트**다.
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
`git log`/`git diff` 로 이 이슈에서 변경된 내용을 검토한다.
## 검토 관점
- **정확성**: 로직 버그, 엣지케이스, 레이스, 잘못된 가정.
- **설계서 일치**: 구현이 `docs/design/` 설계서와 일치하는가. 설계서에 없는 임의 동작은
없는가. 설계가 바뀌었다면 설계서가 먼저 갱신됐는가. 큰 결정이 ADR 로 기록됐는가.
- **리스크/보안**: 비밀 노출, 주문/리스크 경로의 안전성, 입력 검증, 레이트리밋·재시도.
- **표준 준수**: CLAUDE.md 원칙(단일 책임, I/O 분리, 명시적 에러, 안전한 기본값).
- **테스트 충분성**: 핵심 로직이 테스트로 덮였는가.
## 게이트 결정 (둘 중 하나)
- **승인**: 문제 없음 → 카테고리 `07-Release`, 상태 신규. 승인 요지를 노트에 기록.
- **반려**: 결함 발견 → 카테고리 `03-Developer`, 상태 신규 로 반려,
노트에 **파일:라인 + 문제 + 권고 수정**을 구체적으로 남긴다.
- 사소한 스타일은 직접 고치고 승인해도 되나, 동작/보안 변경은 반드시 Developer 반려.
## 핸드오프
- 직접 수정 시 git 커밋·push (`[Reviewer] #<ID> ...`). 프로토콜 (a),(b),(c) 또는 §5.

View File

@@ -0,0 +1,5 @@
---
description: "Deprecated - use the superpowers:brainstorming skill instead"
---
Tell your human partner that this command is deprecated and will be removed in the next major release. They should ask you to use the "superpowers brainstorming" skill instead.

View File

@@ -0,0 +1,5 @@
---
description: "Deprecated - use the superpowers:executing-plans skill instead"
---
Tell your human partner that this command is deprecated and will be removed in the next major release. They should ask you to use the "superpowers executing-plans" skill instead.

View File

@@ -0,0 +1,5 @@
---
description: "Deprecated - use the superpowers:writing-plans skill instead"
---
Tell your human partner that this command is deprecated and will be removed in the next major release. They should ask you to use the "superpowers writing-plans" skill instead.

16
.claude/hooks.json Normal file
View File

@@ -0,0 +1,16 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear|compact",
"hooks": [
{
"type": "command",
"command": "'${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd' session-start",
"async": false
}
]
}
]
}
}

16
.claude/hooks/hooks.json Normal file
View File

@@ -0,0 +1,16 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear|compact",
"hooks": [
{
"type": "command",
"command": "'${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd' session-start",
"async": false
}
]
}
]
}
}

46
.claude/hooks/run-hook.cmd Executable file
View File

@@ -0,0 +1,46 @@
: << 'CMDBLOCK'
@echo off
REM Cross-platform polyglot wrapper for hook scripts.
REM On Windows: cmd.exe runs the batch portion, which finds and calls bash.
REM On Unix: the shell interprets this as a script (: is a no-op in bash).
REM
REM Hook scripts use extensionless filenames (e.g. "session-start" not
REM "session-start.sh") so Claude Code's Windows auto-detection -- which
REM prepends "bash" to any command containing .sh -- doesn't interfere.
REM
REM Usage: run-hook.cmd <script-name> [args...]
if "%~1"=="" (
echo run-hook.cmd: missing script name >&2
exit /b 1
)
set "HOOK_DIR=%~dp0"
REM Try Git for Windows bash in standard locations
if exist "C:\Program Files\Git\bin\bash.exe" (
"C:\Program Files\Git\bin\bash.exe" "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %ERRORLEVEL%
)
if exist "C:\Program Files (x86)\Git\bin\bash.exe" (
"C:\Program Files (x86)\Git\bin\bash.exe" "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %ERRORLEVEL%
)
REM Try bash on PATH (e.g. user-installed Git Bash, MSYS2, Cygwin)
where bash >nul 2>nul
if %ERRORLEVEL% equ 0 (
bash "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %ERRORLEVEL%
)
REM No bash found - exit silently rather than error
REM (plugin still works, just without SessionStart context injection)
exit /b 0
CMDBLOCK
# Unix: run the named script directly
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT_NAME="$1"
shift
exec bash "${SCRIPT_DIR}/${SCRIPT_NAME}" "$@"

51
.claude/hooks/session-start Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# SessionStart hook for superpowers plugin
set -euo pipefail
# Determine plugin root directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Check if legacy skills directory exists and build warning
warning_message=""
legacy_skills_dir="${HOME}/.config/superpowers/skills"
if [ -d "$legacy_skills_dir" ]; then
warning_message="\n\n<important-reminder>IN YOUR FIRST REPLY AFTER SEEING THIS MESSAGE YOU MUST TELL THE USER:⚠️ **WARNING:** Superpowers now uses Claude Code's skills system. Custom skills in ~/.config/superpowers/skills will not be read. Move custom skills to ~/.claude/skills instead. To make this message go away, remove ~/.config/superpowers/skills</important-reminder>"
fi
# Read using-superpowers content
using_superpowers_content=$(cat "${PLUGIN_ROOT}/skills/using-superpowers/SKILL.md" 2>&1 || echo "Error reading using-superpowers skill")
# Escape string for JSON embedding using bash parameter substitution.
# Each ${s//old/new} is a single C-level pass - orders of magnitude
# faster than the character-by-character loop this replaces.
escape_for_json() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\r'/\\r}"
s="${s//$'\t'/\\t}"
printf '%s' "$s"
}
using_superpowers_escaped=$(escape_for_json "$using_superpowers_content")
warning_escaped=$(escape_for_json "$warning_message")
session_context="<EXTREMELY_IMPORTANT>\nYou have superpowers.\n\n**Below is the full content of your 'superpowers:using-superpowers' skill - your introduction to using skills. For all other skills, use the 'Skill' tool:**\n\n${using_superpowers_escaped}\n\n${warning_escaped}\n</EXTREMELY_IMPORTANT>"
# Output context injection as JSON.
# Keep both shapes for compatibility:
# - Cursor hooks expect additional_context.
# - Claude hooks expect hookSpecificOutput.additionalContext.
cat <<EOF
{
"additional_context": "${session_context}",
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "${session_context}"
}
}
EOF
exit 0

View File

@@ -0,0 +1,213 @@
<!DOCTYPE html>
<html>
<head>
<title>Superpowers Brainstorming</title>
<style>
/*
* BRAINSTORM COMPANION FRAME TEMPLATE
*
* This template provides a consistent frame with:
* - OS-aware light/dark theming
* - Fixed header and selection indicator bar
* - Scrollable main content area
* - CSS helpers for common UI patterns
*
* Content is injected via placeholder comment in #claude-content.
*/
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
/* ===== THEME VARIABLES ===== */
:root {
--bg-primary: #f5f5f7;
--bg-secondary: #ffffff;
--bg-tertiary: #e5e5e7;
--border: #d1d1d6;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
--text-tertiary: #aeaeb2;
--accent: #0071e3;
--accent-hover: #0077ed;
--success: #34c759;
--warning: #ff9f0a;
--error: #ff3b30;
--selected-bg: #e8f4fd;
--selected-border: #0071e3;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1d1d1f;
--bg-secondary: #2d2d2f;
--bg-tertiary: #3d3d3f;
--border: #424245;
--text-primary: #f5f5f7;
--text-secondary: #86868b;
--text-tertiary: #636366;
--accent: #0a84ff;
--accent-hover: #409cff;
--selected-bg: rgba(10, 132, 255, 0.15);
--selected-border: #0a84ff;
}
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
flex-direction: column;
line-height: 1.5;
}
/* ===== FRAME STRUCTURE ===== */
.header {
background: var(--bg-secondary);
padding: 0.5rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.header h1 { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); }
.header .status { font-size: 0.7rem; color: var(--success); display: flex; align-items: center; gap: 0.4rem; }
.header .status::before { content: ''; width: 6px; height: 6px; background: var(--success); border-radius: 50%; }
.main { flex: 1; overflow-y: auto; }
#claude-content { padding: 2rem; min-height: 100%; }
.indicator-bar {
background: var(--bg-secondary);
border-top: 1px solid var(--border);
padding: 0.5rem 1.5rem;
flex-shrink: 0;
text-align: center;
}
.indicator-bar span {
font-size: 0.75rem;
color: var(--text-secondary);
}
.indicator-bar .selected-text {
color: var(--accent);
font-weight: 500;
}
/* ===== TYPOGRAPHY ===== */
h2 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.5rem; }
h3 { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.25rem; }
.subtitle { color: var(--text-secondary); margin-bottom: 1.5rem; }
.section { margin-bottom: 2rem; }
.label { font-size: 0.7rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
/* ===== OPTIONS (for A/B/C choices) ===== */
.options { display: flex; flex-direction: column; gap: 0.75rem; }
.option {
background: var(--bg-secondary);
border: 2px solid var(--border);
border-radius: 12px;
padding: 1rem 1.25rem;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: flex-start;
gap: 1rem;
}
.option:hover { border-color: var(--accent); }
.option.selected { background: var(--selected-bg); border-color: var(--selected-border); }
.option .letter {
background: var(--bg-tertiary);
color: var(--text-secondary);
width: 1.75rem; height: 1.75rem;
border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 0.85rem; flex-shrink: 0;
}
.option.selected .letter { background: var(--accent); color: white; }
.option .content { flex: 1; }
.option .content h3 { font-size: 0.95rem; margin-bottom: 0.15rem; }
.option .content p { color: var(--text-secondary); font-size: 0.85rem; margin: 0; }
/* ===== CARDS (for showing designs/mockups) ===== */
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; }
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.15s ease;
}
.card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.card.selected { border-color: var(--selected-border); border-width: 2px; }
.card-image { background: var(--bg-tertiary); aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center; }
.card-body { padding: 1rem; }
.card-body h3 { margin-bottom: 0.25rem; }
.card-body p { color: var(--text-secondary); font-size: 0.85rem; }
/* ===== MOCKUP CONTAINER ===== */
.mockup {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
margin-bottom: 1.5rem;
}
.mockup-header {
background: var(--bg-tertiary);
padding: 0.5rem 1rem;
font-size: 0.75rem;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
}
.mockup-body { padding: 1.5rem; }
/* ===== SPLIT VIEW (side-by-side comparison) ===== */
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
@media (max-width: 700px) { .split { grid-template-columns: 1fr; } }
/* ===== PROS/CONS ===== */
.pros-cons { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1rem 0; }
.pros, .cons { background: var(--bg-secondary); border-radius: 8px; padding: 1rem; }
.pros h4 { color: var(--success); font-size: 0.85rem; margin-bottom: 0.5rem; }
.cons h4 { color: var(--error); font-size: 0.85rem; margin-bottom: 0.5rem; }
.pros ul, .cons ul { margin-left: 1.25rem; font-size: 0.85rem; color: var(--text-secondary); }
.pros li, .cons li { margin-bottom: 0.25rem; }
/* ===== PLACEHOLDER (for mockup areas) ===== */
.placeholder {
background: var(--bg-tertiary);
border: 2px dashed var(--border);
border-radius: 8px;
padding: 2rem;
text-align: center;
color: var(--text-tertiary);
}
/* ===== INLINE MOCKUP ELEMENTS ===== */
.mock-nav { background: var(--accent); color: white; padding: 0.75rem 1rem; display: flex; gap: 1.5rem; font-size: 0.9rem; }
.mock-sidebar { background: var(--bg-tertiary); padding: 1rem; min-width: 180px; }
.mock-content { padding: 1.5rem; flex: 1; }
.mock-button { background: var(--accent); color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.85rem; }
.mock-input { background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem; width: 100%; }
</style>
</head>
<body>
<div class="header">
<h1><a href="https://github.com/obra/superpowers" style="color: inherit; text-decoration: none;">Superpowers Brainstorming</a></h1>
<div class="status">Connected</div>
</div>
<div class="main">
<div id="claude-content">
<!-- CONTENT -->
</div>
</div>
<div class="indicator-bar">
<span id="indicator-text">Click an option above, then return to the terminal</span>
</div>
</body>
</html>

View File

@@ -0,0 +1,88 @@
(function() {
const WS_URL = 'ws://' + window.location.host;
let ws = null;
let eventQueue = [];
function connect() {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
eventQueue.forEach(e => ws.send(JSON.stringify(e)));
eventQueue = [];
};
ws.onmessage = (msg) => {
const data = JSON.parse(msg.data);
if (data.type === 'reload') {
window.location.reload();
}
};
ws.onclose = () => {
setTimeout(connect, 1000);
};
}
function sendEvent(event) {
event.timestamp = Date.now();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(event));
} else {
eventQueue.push(event);
}
}
// Capture clicks on choice elements
document.addEventListener('click', (e) => {
const target = e.target.closest('[data-choice]');
if (!target) return;
sendEvent({
type: 'click',
text: target.textContent.trim(),
choice: target.dataset.choice,
id: target.id || null
});
// Update indicator bar (defer so toggleSelect runs first)
setTimeout(() => {
const indicator = document.getElementById('indicator-text');
if (!indicator) return;
const container = target.closest('.options') || target.closest('.cards');
const selected = container ? container.querySelectorAll('.selected') : [];
if (selected.length === 0) {
indicator.textContent = 'Click an option above, then return to the terminal';
} else if (selected.length === 1) {
const label = selected[0].querySelector('h3, .content h3, .card-body h3')?.textContent?.trim() || selected[0].dataset.choice;
indicator.innerHTML = '<span class="selected-text">' + label + ' selected</span> — return to terminal to continue';
} else {
indicator.innerHTML = '<span class="selected-text">' + selected.length + ' selected</span> — return to terminal to continue';
}
}, 0);
});
// Frame UI: selection tracking
window.selectedChoice = null;
window.toggleSelect = function(el) {
const container = el.closest('.options') || el.closest('.cards');
const multi = container && container.dataset.multiselect !== undefined;
if (container && !multi) {
container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected'));
}
if (multi) {
el.classList.toggle('selected');
} else {
el.classList.add('selected');
}
window.selectedChoice = el.dataset.choice;
};
// Expose API for explicit use
window.brainstorm = {
send: sendEvent,
choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata })
};
connect();
})();

View File

@@ -0,0 +1,141 @@
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const chokidar = require('chokidar');
const fs = require('fs');
const path = require('path');
const PORT = process.env.BRAINSTORM_PORT || (49152 + Math.floor(Math.random() * 16383));
const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
const SCREEN_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
if (!fs.existsSync(SCREEN_DIR)) {
fs.mkdirSync(SCREEN_DIR, { recursive: true });
}
// Load frame template and helper script once at startup
const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
const helperInjection = `<script>\n${helperScript}\n</script>`;
// Detect whether content is a full HTML document or a bare fragment
function isFullDocument(html) {
const trimmed = html.trimStart().toLowerCase();
return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
}
// Wrap a content fragment in the frame template
function wrapInFrame(content) {
return frameTemplate.replace('<!-- CONTENT -->', content);
}
// Find the newest .html file in the directory by mtime
function getNewestScreen() {
const files = fs.readdirSync(SCREEN_DIR)
.filter(f => f.endsWith('.html'))
.map(f => ({
name: f,
path: path.join(SCREEN_DIR, f),
mtime: fs.statSync(path.join(SCREEN_DIR, f)).mtime.getTime()
}))
.sort((a, b) => b.mtime - a.mtime);
return files.length > 0 ? files[0].path : null;
}
const WAITING_PAGE = `<!DOCTYPE html>
<html>
<head>
<title>Brainstorm Companion</title>
<style>
body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
h1 { color: #333; }
p { color: #666; }
</style>
</head>
<body>
<h1>Brainstorm Companion</h1>
<p>Waiting for Claude to push a screen...</p>
</body>
</html>`;
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
ws.on('close', () => clients.delete(ws));
ws.on('message', (data) => {
const event = JSON.parse(data.toString());
console.log(JSON.stringify({ source: 'user-event', ...event }));
// Write user events to .events file for Claude to read
if (event.choice) {
const eventsFile = path.join(SCREEN_DIR, '.events');
fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
}
});
});
// Serve newest screen with helper.js injected
app.get('/', (req, res) => {
const screenFile = getNewestScreen();
let html;
if (!screenFile) {
html = WAITING_PAGE;
} else {
const raw = fs.readFileSync(screenFile, 'utf-8');
html = isFullDocument(raw) ? raw : wrapInFrame(raw);
}
// Inject helper script
if (html.includes('</body>')) {
html = html.replace('</body>', `${helperInjection}\n</body>`);
} else {
html += helperInjection;
}
res.type('html').send(html);
});
// Watch for new or changed .html files
chokidar.watch(SCREEN_DIR, { ignoreInitial: true })
.on('add', (filePath) => {
if (filePath.endsWith('.html')) {
// Clear events from previous screen
const eventsFile = path.join(SCREEN_DIR, '.events');
if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
clients.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'reload' }));
}
});
}
})
.on('change', (filePath) => {
if (filePath.endsWith('.html')) {
console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
clients.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'reload' }));
}
});
}
});
server.listen(PORT, HOST, () => {
console.log(JSON.stringify({
type: 'server-started',
port: PORT,
host: HOST,
url_host: URL_HOST,
url: `http://${URL_HOST}:${PORT}`,
screen_dir: SCREEN_DIR
}));
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
{
"name": "brainstorm-server",
"version": "1.0.0",
"description": "Visual brainstorming companion server for Claude Code",
"main": "index.js",
"dependencies": {
"chokidar": "^3.5.3",
"express": "^4.18.2",
"ws": "^8.14.2"
}
}

View File

@@ -0,0 +1,129 @@
#!/bin/bash
# Start the brainstorm server and output connection info
# Usage: start-server.sh [--project-dir <path>] [--host <bind-host>] [--url-host <display-host>] [--foreground] [--background]
#
# Starts server on a random high port, outputs JSON with URL.
# Each session gets its own directory to avoid conflicts.
#
# Options:
# --project-dir <path> Store session files under <path>/.superpowers/brainstorm/
# instead of /tmp. Files persist after server stops.
# --host <bind-host> Host/interface to bind (default: 127.0.0.1).
# Use 0.0.0.0 in remote/containerized environments.
# --url-host <host> Hostname shown in returned URL JSON.
# --foreground Run server in the current terminal (no backgrounding).
# --background Force background mode (overrides Codex auto-foreground).
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Parse arguments
PROJECT_DIR=""
FOREGROUND="false"
FORCE_BACKGROUND="false"
BIND_HOST="127.0.0.1"
URL_HOST=""
while [[ $# -gt 0 ]]; do
case "$1" in
--project-dir)
PROJECT_DIR="$2"
shift 2
;;
--host)
BIND_HOST="$2"
shift 2
;;
--url-host)
URL_HOST="$2"
shift 2
;;
--foreground|--no-daemon)
FOREGROUND="true"
shift
;;
--background|--daemon)
FORCE_BACKGROUND="true"
shift
;;
*)
echo "{\"error\": \"Unknown argument: $1\"}"
exit 1
;;
esac
done
if [[ -z "$URL_HOST" ]]; then
if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then
URL_HOST="localhost"
else
URL_HOST="$BIND_HOST"
fi
fi
# Codex environments may reap detached/background processes. Prefer foreground by default.
if [[ -n "${CODEX_CI:-}" && "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then
FOREGROUND="true"
fi
# Generate unique session directory
SESSION_ID="$$-$(date +%s)"
if [[ -n "$PROJECT_DIR" ]]; then
SCREEN_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}"
else
SCREEN_DIR="/tmp/brainstorm-${SESSION_ID}"
fi
PID_FILE="${SCREEN_DIR}/.server.pid"
LOG_FILE="${SCREEN_DIR}/.server.log"
# Create fresh session directory
mkdir -p "$SCREEN_DIR"
# Kill any existing server
if [[ -f "$PID_FILE" ]]; then
old_pid=$(cat "$PID_FILE")
kill "$old_pid" 2>/dev/null
rm -f "$PID_FILE"
fi
cd "$SCRIPT_DIR"
# Foreground mode for environments that reap detached/background processes.
if [[ "$FOREGROUND" == "true" ]]; then
echo "$$" > "$PID_FILE"
env BRAINSTORM_DIR="$SCREEN_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" node index.js
exit $?
fi
# Start server, capturing output to log file
# Use nohup to survive shell exit; disown to remove from job table
nohup env BRAINSTORM_DIR="$SCREEN_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" node index.js > "$LOG_FILE" 2>&1 &
SERVER_PID=$!
disown "$SERVER_PID" 2>/dev/null
echo "$SERVER_PID" > "$PID_FILE"
# Wait for server-started message (check log file)
for i in {1..50}; do
if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then
# Verify server is still alive after a short window (catches process reapers)
alive="true"
for _ in {1..20}; do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
alive="false"
break
fi
sleep 0.1
done
if [[ "$alive" != "true" ]]; then
echo "{\"error\": \"Server started but was killed. Retry in a persistent terminal with: $SCRIPT_DIR/start-server.sh${PROJECT_DIR:+ --project-dir $PROJECT_DIR} --host $BIND_HOST --url-host $URL_HOST --foreground\"}"
exit 1
fi
grep "server-started" "$LOG_FILE" | head -1
exit 0
fi
sleep 0.1
done
# Timeout - server didn't start
echo '{"error": "Server failed to start within 5 seconds"}'
exit 1

View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Stop the brainstorm server and clean up
# Usage: stop-server.sh <screen_dir>
#
# Kills the server process. Only deletes session directory if it's
# under /tmp (ephemeral). Persistent directories (.superpowers/) are
# kept so mockups can be reviewed later.
SCREEN_DIR="$1"
if [[ -z "$SCREEN_DIR" ]]; then
echo '{"error": "Usage: stop-server.sh <screen_dir>"}'
exit 1
fi
PID_FILE="${SCREEN_DIR}/.server.pid"
if [[ -f "$PID_FILE" ]]; then
pid=$(cat "$PID_FILE")
kill "$pid" 2>/dev/null
rm -f "$PID_FILE" "${SCREEN_DIR}/.server.log"
# Only delete ephemeral /tmp directories
if [[ "$SCREEN_DIR" == /tmp/* ]]; then
rm -rf "$SCREEN_DIR"
fi
echo '{"status": "stopped"}'
else
echo '{"status": "not_running"}'
fi

208
.claude/lib/skills-core.js Normal file
View File

@@ -0,0 +1,208 @@
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
/**
* Extract YAML frontmatter from a skill file.
* Current format:
* ---
* name: skill-name
* description: Use when [condition] - [what it does]
* ---
*
* @param {string} filePath - Path to SKILL.md file
* @returns {{name: string, description: string}}
*/
function extractFrontmatter(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
let inFrontmatter = false;
let name = '';
let description = '';
for (const line of lines) {
if (line.trim() === '---') {
if (inFrontmatter) break;
inFrontmatter = true;
continue;
}
if (inFrontmatter) {
const match = line.match(/^(\w+):\s*(.*)$/);
if (match) {
const [, key, value] = match;
switch (key) {
case 'name':
name = value.trim();
break;
case 'description':
description = value.trim();
break;
}
}
}
}
return { name, description };
} catch (error) {
return { name: '', description: '' };
}
}
/**
* Find all SKILL.md files in a directory recursively.
*
* @param {string} dir - Directory to search
* @param {string} sourceType - 'personal' or 'superpowers' for namespacing
* @param {number} maxDepth - Maximum recursion depth (default: 3)
* @returns {Array<{path: string, name: string, description: string, sourceType: string}>}
*/
function findSkillsInDir(dir, sourceType, maxDepth = 3) {
const skills = [];
if (!fs.existsSync(dir)) return skills;
function recurse(currentDir, depth) {
if (depth > maxDepth) return;
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
// Check for SKILL.md in this directory
const skillFile = path.join(fullPath, 'SKILL.md');
if (fs.existsSync(skillFile)) {
const { name, description } = extractFrontmatter(skillFile);
skills.push({
path: fullPath,
skillFile: skillFile,
name: name || entry.name,
description: description || '',
sourceType: sourceType
});
}
// Recurse into subdirectories
recurse(fullPath, depth + 1);
}
}
}
recurse(dir, 0);
return skills;
}
/**
* Resolve a skill name to its file path, handling shadowing
* (personal skills override superpowers skills).
*
* @param {string} skillName - Name like "superpowers:brainstorming" or "my-skill"
* @param {string} superpowersDir - Path to superpowers skills directory
* @param {string} personalDir - Path to personal skills directory
* @returns {{skillFile: string, sourceType: string, skillPath: string} | null}
*/
function resolveSkillPath(skillName, superpowersDir, personalDir) {
// Strip superpowers: prefix if present
const forceSuperpowers = skillName.startsWith('superpowers:');
const actualSkillName = forceSuperpowers ? skillName.replace(/^superpowers:/, '') : skillName;
// Try personal skills first (unless explicitly superpowers:)
if (!forceSuperpowers && personalDir) {
const personalPath = path.join(personalDir, actualSkillName);
const personalSkillFile = path.join(personalPath, 'SKILL.md');
if (fs.existsSync(personalSkillFile)) {
return {
skillFile: personalSkillFile,
sourceType: 'personal',
skillPath: actualSkillName
};
}
}
// Try superpowers skills
if (superpowersDir) {
const superpowersPath = path.join(superpowersDir, actualSkillName);
const superpowersSkillFile = path.join(superpowersPath, 'SKILL.md');
if (fs.existsSync(superpowersSkillFile)) {
return {
skillFile: superpowersSkillFile,
sourceType: 'superpowers',
skillPath: actualSkillName
};
}
}
return null;
}
/**
* Check if a git repository has updates available.
*
* @param {string} repoDir - Path to git repository
* @returns {boolean} - True if updates are available
*/
function checkForUpdates(repoDir) {
try {
// Quick check with 3 second timeout to avoid delays if network is down
const output = execSync('git fetch origin && git status --porcelain=v1 --branch', {
cwd: repoDir,
timeout: 3000,
encoding: 'utf8',
stdio: 'pipe'
});
// Parse git status output to see if we're behind
const statusLines = output.split('\n');
for (const line of statusLines) {
if (line.startsWith('## ') && line.includes('[behind ')) {
return true; // We're behind remote
}
}
return false; // Up to date
} catch (error) {
// Network down, git error, timeout, etc. - don't block bootstrap
return false;
}
}
/**
* Strip YAML frontmatter from skill content, returning just the content.
*
* @param {string} content - Full content including frontmatter
* @returns {string} - Content without frontmatter
*/
function stripFrontmatter(content) {
const lines = content.split('\n');
let inFrontmatter = false;
let frontmatterEnded = false;
const contentLines = [];
for (const line of lines) {
if (line.trim() === '---') {
if (inFrontmatter) {
frontmatterEnded = true;
continue;
}
inFrontmatter = true;
continue;
}
if (frontmatterEnded || !inFrontmatter) {
contentLines.push(line);
}
}
return contentLines.join('\n').trim();
}
export {
extractFrontmatter,
findSkillsInDir,
resolveSkillPath,
checkForUpdates,
stripFrontmatter
};

35
.claude/settings.json Normal file
View File

@@ -0,0 +1,35 @@
{
"permissions": {
"allow": [
"Bash",
"Read",
"Edit",
"Write",
"WebFetch",
"WebSearch",
"mcp__searxng__web_search",
"mcp__oracle-26ai-vector__search_similar"
],
"deny": [
"Bash(rm -rf /)",
"Bash(rm -rf /*)",
"Bash(rm -rf ~)",
"Bash(rm -rf ~/*)",
"Bash(sudo rm -rf *)",
"Edit(//Users/joungmin/.claude/settings.json)",
"Write(//Users/joungmin/.claude/settings.json)",
"Edit(//Users/joungmin/.claude/settings.local.json)",
"Write(//Users/joungmin/.claude/settings.local.json)",
"Edit(//Users/joungmin/.claude/agents/**)",
"Write(//Users/joungmin/.claude/agents/**)",
"Edit(//Users/joungmin/.claude/commands/**)",
"Write(//Users/joungmin/.claude/commands/**)",
"Edit(//Users/joungmin/.claude/CLAUDE.md)",
"Write(//Users/joungmin/.claude/CLAUDE.md)",
"Edit(//Users/joungmin/.gitconfig)",
"Write(//Users/joungmin/.gitconfig)",
"Edit(.git/**)",
"Write(.git/**)"
]
}
}

View File

@@ -0,0 +1,89 @@
export const meta = {
name: 'persona-pipeline',
description: 'tasteby: Redmine 큐의 열린 이슈를 8개 AI 페르소나 단계로 자동 통과시킨다 (Planner→...→Documenter, QA/Reviewer 반려 루프 포함)',
phases: [
{ title: 'Scan', detail: 'Redmine tasteby 의 열린 이슈와 현재 단계 수집' },
{ title: 'Pipeline', detail: '각 이슈를 현재 단계 페르소나부터 Done 까지 구동' },
],
}
// ── 단계 정의 ──────────────────────────────────────────────
const ORDER = ['01-Planner','02-Architect','03-Developer','04-QA','05-Designer','06-Reviewer','07-Release','08-Documenter']
const PERSONA = {
'01-Planner':'planner', '02-Architect':'architect', '03-Developer':'developer', '04-QA':'qa',
'05-Designer':'designer', '06-Reviewer':'reviewer', '07-Release':'release', '08-Documenter':'documenter',
}
const DONE = '09-Done'
const nextOf = (s) => { const i = ORDER.indexOf(s); return i < 0 ? '01-Planner' : (i+1 < ORDER.length ? ORDER[i+1] : DONE) }
const SCAN_SCHEMA = {
type: 'object', additionalProperties: false,
required: ['issues'],
properties: { issues: { type: 'array', items: {
type: 'object', additionalProperties: false, required: ['id','subject','currentStage'],
properties: { id: {type:'integer'}, subject: {type:'string'}, currentStage: {type:'string'} },
} } },
}
const STAGE_SCHEMA = {
type: 'object', additionalProperties: false,
required: ['issueId','persona','outcome','nextStage','summary'],
properties: {
issueId: { type: 'integer' },
persona: { type: 'string' },
outcome: { type: 'string', enum: ['advanced','rejected','done','blocked'] },
nextStage: { type: 'string', description: '다음 Redmine 카테고리명 (예: 04-QA, 03-Developer, 09-Done)' },
summary: { type: 'string' },
commitSha: { type: 'string' },
},
}
// ── 1. 큐 스캔 ─────────────────────────────────────────────
phase('Scan')
const scan = await agent(
`너는 tasteby 파이프라인 디스패처다. \`.env\` 를 로드(set -a; . ./.env; set +a)해서 ` +
`REDMINE_URL/REDMINE_API_KEY 를 얻은 뒤, 프로젝트 tasteby 에서 **열린(open)** 이슈를 모두 조회한다:\n` +
` curl -s -H "X-Redmine-API-Key: $REDMINE_API_KEY" "$REDMINE_URL/issues.json?project_id=tasteby&status_id=open&limit=100"\n` +
`각 이슈의 현재 단계 = 카테고리 이름(category.name). 카테고리가 없으면 "01-Planner" 로 본다.\n` +
`09-Done 이거나 닫힌 이슈는 제외한다. id·subject·currentStage 목록을 반환하라.`,
{ schema: SCAN_SCHEMA, phase: 'Scan', label: 'scan-queue' }
)
const queue = (scan && scan.issues) ? scan.issues : []
if (!queue.length) {
log('큐가 비어 있다. 처리할 열린 이슈가 없음. Redmine 에 작업 이슈를 추가한 뒤 다시 실행하라.')
return { processed: 0, message: 'empty queue' }
}
log(`큐에 ${queue.length}개 이슈: ` + queue.map(i => `#${i.id}(${i.currentStage})`).join(', '))
// ── 2. 각 이슈를 단계별로 구동 (이슈끼리는 병렬) ─────────────
phase('Pipeline')
const MAX_STEPS = 24 // 반려 핑퐁 등 무한루프 방지
const results = await parallel(queue.map((issue) => async () => {
let stage = issue.currentStage || '01-Planner'
if (!ORDER.includes(stage)) stage = '01-Planner'
const trail = []
for (let step = 0; step < MAX_STEPS && stage !== DONE; step++) {
const persona = PERSONA[stage]
if (!persona) { log(`#${issue.id}: 알 수 없는 단계 ${stage} — 중단`); break }
const res = await agent(
`Redmine 이슈 #${issue.id} ("${issue.subject}") 를 처리하라. 현재 단계: ${stage}.\n` +
`너의 역할 정의와 docs/pipeline/QUEUE-PROTOCOL.md 를 따라 작업하고, ` +
`결과(파일 변경 git 커밋/push, Redmine 저널 노트, 다음 단계로 카테고리·상태 전진/반려)를 모두 수행하라.\n` +
`완료 후 구조화 결과를 반환하라. nextStage 는 네가 실제로 Redmine 에 설정한 카테고리명이어야 한다.`,
{ agentType: persona, schema: STAGE_SCHEMA, phase: 'Pipeline', label: `${persona}#${issue.id}` }
)
if (!res) { log(`#${issue.id}: ${persona} 단계 결과 없음 — 중단`); break }
trail.push(`${persona}:${res.outcome}`)
log(`#${issue.id} ${persona}${res.outcome} (${res.summary || ''})`.slice(0, 200))
if (res.outcome === 'blocked') { log(`#${issue.id}: ${stage} 에서 블록됨 — ${res.summary}`); break }
const reported = (res.nextStage || '').trim()
stage = (ORDER.includes(reported) || reported === DONE) ? reported : nextOf(stage)
}
return { id: issue.id, finalStage: stage, done: stage === DONE, trail }
}))
const summary = results.filter(Boolean)
log('완료: ' + summary.map(r => `#${r.id}=${r.done ? 'DONE' : r.finalStage}`).join(', '))
return { processed: summary.length, results: summary }

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
REDMINE_URL=
REDMINE_API_KEY=
REDMINE_PROJECT=tasteby
GITEA_URL=
GITEA_USER=
GITEA_EMAIL=
GITEA_PASSWORD=
GITEA_REPO=tasteby

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@ __pycache__/
*.pyc *.pyc
.venv/ .venv/
.env .env
.ch-backup/
node_modules/ node_modules/
.next/ .next/
.env.local .env.local

61
CLAUDE.md Normal file
View File

@@ -0,0 +1,61 @@
# tasteby — Engineering Standards & AI Persona Pipeline
이 파일은 모든 AI 페르소나가 따르는 **단일 진실 기준(SoT)** 이다.
## 0. 필수 참조 (Tasteby 고유)
- 모든 작업 시작 전에 `/skills` 슬래시 커맨드를 실행하거나, `mcp__oracle-26ai-vector__search_similar`로 관련 스킬을 검색하여 적용할 규칙을 확인할 것
- 작업 완료 후 새로운 스킬/지식이 생기면 벡터 스토어에 기록할 것
- 코드 변경 시 `CHANGELOG.md` 업데이트 필수
## 1. 잘 설계된 코드 원칙
- 작게·단일 책임(함수 ≤ ~40줄), I/O 와 순수 로직 분리(테스트 가능성).
- 설정·비밀은 `.env` 에서 주입(하드코딩 금지). 명시적 에러 처리(삼키지 말 것).
- 외부 입력은 경계에서 검증. 의도를 드러내는 네이밍. 주석은 '왜'만.
- 핵심 로직은 테스트 없이 머지 금지. 모든 변경은 Redmine 이슈 ↔ 설계서 ↔ git 커밋으로 연결.
- 디자인 패턴 적용 (메모리의 feedback_design_patterns.md 참조).
## 2. 설계서 우선 (Design-First — 하드 게이트) ⛔
> **설계서 없이는 코드 없음.** 함수가 설계서로 덮이기 전엔 구현하지 않는다.
- Architect 가 구현 전 `docs/design/<issue-id>-<slug>/README.md`(`_TEMPLATE.md`)에 모든 함수를 등재.
- 복잡 함수(분기/상태·외부 I/O·리스크 경로·비자명 알고리즘)는 `fn-<name>.md`(`_FN_TEMPLATE.md`).
- Developer 는 설계서 없으면 구현 거부 → `02-Architect` 로 반려.
- 코드가 설계와 달라지면 **설계서를 먼저** 고친다. 되돌리기 어려운 결정은 ADR(`docs/adr/`).
## 3. 문서 아키텍처
Diátaxis + ADR + 설계서. 지도: `docs/README.md`.
| 종류 | 위치 | 시점 |
|------|------|------|
| 설계서 | `docs/design/` | 구현 전 |
| ADR | `docs/adr/` | 결정 시 |
| 레퍼런스 | `docs/reference/` | 구현 후 |
| 가이드 | `docs/guides/` | 릴리스 시 |
## 4. Git 규율
- 모든 산출물 = git 커밋 + Gitea push("추적 안 된 변경" 금지).
- 커밋: `[<Persona>] #<issue-id> <요약>` ... `Refs #<issue-id>`.
- `.env` 등 비밀 커밋 금지(`.gitignore` 차단).
## 5. AI 페르소나 파이프라인 (완전 자동)
```
[01 Planner]→[02 Architect]→[03 Developer]→[04 QA]→[05 Designer]→[06 Reviewer]→[07 Release]→[08 Documenter]→[09 Done]
(설계서) (설계서 게이트) └──── 반려 ────┘ (QA/Reviewer/설계서누락 시)
```
- 작업 큐 = Redmine 이슈(project `tasteby`). 프로토콜: `docs/pipeline/QUEUE-PROTOCOL.md`.
- 현재 단계 = 이슈 **카테고리**(`01-Planner``09-Done`). 수명주기 = 이슈 **상태**.
- 페르소나: `.claude/agents/`. 오케스트레이터: `.claude/workflows/persona-pipeline.js`.
## 6. 게이트
- 설계서 게이트(02→03), QA 게이트(04), Reviewer 게이트(06). 우회 금지, 반려 사유는 저널 노트.
## 7. 응대 규칙
- 존댓말 사용 (반말 금지).
## 8. 개발 환경 (Tasteby 고유)
- 새 HTTP 메서드 추가 시 `WebConfig.java` CORS allowedMethods 확인.
- 백엔드 코드 수정 후 빌드 성공 확인 → PM2 재시작.
- dev 환경 PM2 설정 변경 금지 (tasteby-web: PORT=3001, tasteby-api: 8000).
## 9. 작업 환경
- Gitea: https://gittea.cloud-handson.com/joungmin/tasteby (branch `main`)
- Redmine: https://redmine.cloud-handson.com/projects/tasteby
- 자격증명은 `.env` 에서 로드.

63
docs/README.md Normal file
View File

@@ -0,0 +1,63 @@
# tasteby 문서 아키텍처 (Documentation Map)
이 프로젝트의 문서는 **Diátaxis** 프레임워크 + **ADR** + **설계서(Design Spec)**
결합한 구조를 따른다. 모든 페르소나는 문서를 만들거나 참조할 때 이 지도를 기준으로 한다.
## 디렉토리 구조
```
docs/
README.md ← (이 파일) 문서 지도 · 인덱스
design/ ← 설계서: 구현 "전"에 작성하는 필수 산출물 (Design-First 게이트)
_TEMPLATE.md 기능 설계서 템플릿
_FN_TEMPLATE.md 함수별 설계서 템플릿
<issue-id>-<slug>/ 기능 1개(이슈 1개)당 폴더
README.md 기능 설계서 (전체 설계 + 함수 명세 표)
fn-<name>.md 복잡한 함수만 개별 함수 설계서
adr/ ← Architecture Decision Records: 가로지르는 결정 기록
_TEMPLATE.md
NNNN-<title>.md
reference/ ← 레퍼런스: 구현된 모듈/함수/설정 사양 (구현 "후" 동기화)
guides/ ← How-to / 사용 가이드 / 튜토리얼 (사용자·운영자 대상)
pipeline/ ← 개발 프로세스 문서 (큐 프로토콜·런북)
```
## Diátaxis 사분면 매핑
| 사분면 | 목적 | 여기서 위치 |
|--------|------|-------------|
| **Tutorials** (학습) | 처음 사용자가 따라하기 | `guides/` (getting-started) |
| **How-to** (문제해결) | 특정 작업 수행 | `guides/` |
| **Reference** (정보) | 정확한 사양 조회 | `reference/` |
| **Explanation** (이해) | 왜 이렇게 설계했나 | `design/`, `adr/` |
## 문서 종류와 책임
| 문서 | 작성 페르소나 | 시점 | 한 줄 |
|------|---------------|------|-------|
| 기능 설계서 `design/<id>/README.md` | **Architect** | 구현 **전** | 무엇을·어떻게 만들지의 청사진 |
| 함수 설계서 `design/<id>/fn-*.md` | **Architect** | 구현 **전** | 복잡 함수의 계약·알고리즘·테스트 |
| ADR `adr/NNNN-*.md` | **Architect** | 결정 시 | 되돌리기 어려운 선택과 근거 |
| 레퍼런스 `reference/*` | **Developer/Documenter** | 구현 **후** | 실제 코드 사양 |
| 가이드 `guides/*` | **Documenter** | 릴리스 시 | 사용/운영 방법 |
## 핵심 규칙 — Design-First (하드 게이트)
> **설계서 없이는 코드 없음.** 어떤 함수든 구현 전에 그 함수가 설계서로 덮여 있어야 한다
> (단순 함수: 기능 설계서의 함수 명세 표 / 복잡 함수: 개별 `fn-*.md`).
> Developer 는 설계서가 없으면 구현을 거부하고 Architect 단계로 반려한다.
> 자세한 기준은 `CLAUDE.md` §2 참조.
## 명명 · 추적성 규칙
- 설계서 폴더: `design/<issue-id>-<kebab-slug>/` (예: `design/45-trailing-stop/`).
- 함수 설계서: `fn-<function_name>.md` (예: `fn-calc_trailing_stop.md`).
- ADR: 4자리 일련번호 `adr/0001-<title>.md`, 번호 재사용 금지.
- 모든 설계서·ADR 상단에 **추적성 헤더**(Redmine 이슈, 관련 ADR, 구현 파일, 테스트)를 둔다.
- 코드 ↔ 설계서 양방향 링크: 설계서는 구현 파일 경로를, 코드 주석/문서는 설계서 경로를 가리킨다.
## 문서 수명주기
`Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지).
구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다.
```

24
docs/adr/_TEMPLATE.md Normal file
View File

@@ -0,0 +1,24 @@
<!-- ADR 템플릿. 복사해서 adr/NNNN-<kebab-title>.md (4자리 일련번호). -->
# ADR-NNNN: <제목>
> **상태**: Proposed <!-- Proposed | Accepted | Superseded by ADR-XXXX -->
> **날짜**: <YYYY-MM-DD> · **결정자**: [AI] Architect · **관련 이슈**: #<id>
## 맥락 (Context)
무엇이 이 결정을 강제하는가. 배경·제약·요구.
## 결정 (Decision)
우리는 무엇을 하기로 했는가. (명확한 한 문단)
## 근거 (Rationale)
왜 이 선택인가. 핵심 트레이드오프.
## 결과 (Consequences)
- **긍정**: ...
- **부정 / 비용**: ...
- **후속 작업**: ...
## 검토한 대안 (Alternatives Considered)
- **<대안 A>** — 기각 사유: ...
- **<대안 B>** — 기각 사유: ...

View File

@@ -0,0 +1,51 @@
<!-- 함수별 설계서 템플릿. 복잡 함수마다 design/<issue-id>-<slug>/fn-<function_name>.md 로 작성.
작성: [AI] Architect, 구현 전 필수. -->
# 함수 설계서: `<function_name>` (#<issue-id>)
> **부모 설계서**: ./README.md · **상태**: Draft <!-- Draft|Approved|Superseded -->
> **작성**: [AI] Architect · **구현**: <file:function 또는 TBD> · **테스트**: <경로 또는 TBD>
## 1. 시그니처
```
<returnType> <function_name>(<params>) # 언어 확정 후 정확히 기재
```
## 2. 책임 (단일 책임, 1줄)
이 함수가 하는 단 하나의 일.
## 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| `<p>` | | | |
## 4. 출력
- **반환**: 타입 / 의미.
- **부수효과**: (있으면 — I/O·상태변경 명시) / 없으면 **순수 함수**.
## 5. 동작 / 알고리즘
1. ...
2. ...
## 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| | | |
## 7. 엣지케이스
- 경계값(0, 음수, 빈값, 최대), 동시성, 부분 실패.
## 8. 복잡도 / 성능
- 시간/공간 복잡도. 호출 빈도(예: 시세 폴링 루프 내부인가?).
## 9. 의존성
- 호출하는 함수/모듈, 외부 API, 설정 키.
## 10. 테스트 케이스
- [ ] 정상: <입력 → 기대 출력>
- [ ] 경계: ...
- [ ] 실패: ...
## 11. 추적성
- 인수조건: #<issue-id> 의 "<항목>".
- 관련 ADR: <ADR-NNNN 또는 없음>.

66
docs/design/_TEMPLATE.md Normal file
View File

@@ -0,0 +1,66 @@
<!-- 기능 설계서 템플릿. 복사해서 design/<issue-id>-<slug>/README.md 로 작성.
작성: [AI] Architect, 구현 전 필수. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: <기능명> (#<issue-id>)
> **상태**: Draft <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: <YYYY-MM-DD>
> **추적성** — Redmine: #<issue-id> · 관련 ADR: <ADR-NNNN 또는 없음>
> · 구현 파일: <경로 또는 TBD> · 테스트: <경로 또는 TBD>
## 1. 목적 (Why)
이 기능이 푸는 문제. Planner 의 목표 1줄 인용.
## 2. 범위 (Scope)
- **포함**: ...
- **제외 (out of scope)**: ...
## 3. 인수조건 (Acceptance Criteria)
<!-- Planner 가 확정한 검증 가능한 항목. QA 가 이걸로 판정한다. -->
- [ ] ...
- [ ] ...
## 4. 컨텍스트 & 제약
- 의존성: 거래소 API / DB / 알림 / 외부 라이브러리.
- 제약: 성능, 레이트리밋, 리스크(돈), 보안.
- 가정: ...
## 5. 아키텍처 개요
- 모듈/파일 구조 (목록).
- 데이터 흐름 (텍스트 다이어그램).
- **I/O ↔ 순수 전략 로직 경계** 명시 (테스트 가능성).
```
<여기에 ASCII 흐름도>
```
## 6. 데이터 모델
- 입력 / 출력 / 저장 구조, 타입, **경계 검증 규칙**.
## 7. 함수 명세 (Function Specs)
<!-- 모든 함수를 나열. "복잡?" = 복잡이면 fn-<name>.md 개별 설계서 필수. -->
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `<name>` | | | | | | 단순 / **복잡** |
> 복잡 기준: 분기/상태기계, 외부 I/O, 리스크(주문·잔고) 경로, 비자명 알고리즘.
> → 해당 함수는 `fn-<name>.md` 작성. 단순(게터·포매터 등)은 이 표로 충분.
## 8. 흐름 / 알고리즘
- 핵심 시나리오 단계별. 상태 전이.
## 9. 엣지케이스 & 에러 처리
- 경계값, 실패 모드, 재시도/백오프.
- **안전한 기본값**(API 실패 시 거래 중단 등).
## 10. 테스트 계획
- 단위/통합 케이스 목록 (각 인수조건에 매핑).
- 모킹/드라이런 전략 (거래소 API 등).
## 11. 리스크 & 대안 검토
- 선택한 접근 vs 대안, 트레이드오프.
- 되돌리기 어려운 결정 → **ADR 로 분리** (`adr/NNNN-*.md`).
## 12. 미해결 질문 (Open Questions)
- ...

View File

@@ -0,0 +1,42 @@
# Queue Protocol — 모든 페르소나 공통 규약
작업 큐 = Redmine 이슈. 각 페르소나는 자기 단계 이슈를 처리하고 git/Redmine 에 남긴 뒤 다음으로 넘긴다.
## 0. 환경 로드
```bash
set -a; . ./.env; set +a
RK="$REDMINE_API_KEY"; RB="$REDMINE_URL"; PROJ="$REDMINE_PROJECT"
# 카테고리 id 는 이름으로 조회(프로젝트마다 id 다름):
catid(){ curl -s -H "X-Redmine-API-Key: $RK" "$RB/projects/$PROJ/issue_categories.json" \
| python3 -c "import sys,json;[print(c['id']) for c in json.load(sys.stdin)['issue_categories'] if c['name']=='$1']"; }
```
## 1. 큐 매핑
- 현재 단계 = 카테고리 `01-Planner``08-Documenter`,`09-Done`.
- 수명주기 = 상태 신규(대기)/진행/완료/거절.
## 2. 내 작업 꺼내기
```bash
DEV=$(catid 03-Developer)
curl -s -H "X-Redmine-API-Key: $RK" "$RB/issues.json?project_id=$PROJ&category_id=$DEV&status_id=1&sort=id:asc&limit=1"
# 시작 시 상태 진행(2):
curl -s -H "X-Redmine-API-Key: $RK" -H "Content-Type: application/json" -X PUT "$RB/issues/<ID>.json" -d '{"issue":{"status_id":2}}'
```
## 3~4. 결과 남기기 (필수 3가지)
- (a) git 커밋+push (`[<Persona>] #<ID> ...`)
- (b) Redmine 저널 노트(역할 태그)
- (c) 다음 단계 전진: 카테고리=다음이름의 id, 상태 신규(1)
```bash
NEXT=$(catid 04-QA)
curl -s -H "X-Redmine-API-Key: $RK" -H "Content-Type: application/json" -X PUT "$RB/issues/<ID>.json" \
-d "{\"issue\":{\"category_id\":$NEXT,\"status_id\":1,\"notes\":\"[<Persona>] ...\"}}"
```
## 5. 게이트 반려
- QA(04)/Reviewer(06) 실패 → `03-Developer`. Developer 설계서 누락 → `02-Architect`. 사유를 노트에.
## 6. 종료 (Documenter)
- `09-Done` + 상태 완료(5) + done_ratio 100.
원칙: 자기 역할 범위만, 모든 변경 git 추적, 비밀(.env) 노출 금지.

316
scripts/code_review.py Executable file
View File

@@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""
Tasteby 페르소나 기반 코드 리뷰
4개 페르소나(프론트엔드, 백엔드, 보안, 아키텍처)가 git diff를 리뷰하고 최종 권고를 출력.
사용법:
# 커밋되지 않은 변경 리뷰
python3 scripts/code_review.py
# 최근 N개 커밋 리뷰
python3 scripts/code_review.py --commits 3
# 특정 영역만 리뷰 (frontend / backend / all)
python3 scripts/code_review.py --scope frontend
python3 scripts/code_review.py --scope backend
# 특정 커밋 범위 리뷰
python3 scripts/code_review.py --range abc1234..def5678
"""
import subprocess, json, os, sys, argparse
from pathlib import Path
# .env 파일에서 환경변수 로드
def load_dotenv():
env_path = Path(__file__).resolve().parent.parent / ".env"
if env_path.exists():
with open(env_path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, val = line.partition("=")
os.environ.setdefault(key.strip(), val.strip())
load_dotenv()
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY")
MODEL = os.environ.get("REVIEW_MODEL", "anthropic/claude-haiku-4-5")
API_URL = "https://openrouter.ai/api/v1/chat/completions"
# --- 페르소나 정의 ---
PERSONAS = {
"frontend": [
{
"name": "프론트엔드 UX 전문가",
"emoji": "\U0001f3a8",
"prompt": """너는 Next.js/React 전문 프론트엔드 엔지니어야.
이 코드 변경에서 UX, 성능, 접근성 문제를 찾아내.
특히:
- 불필요한 리렌더링이나 useEffect 남용은 없는지
- 로딩/에러 상태 처리가 빠져있는지
- 모바일 반응형이 깨질 수 있는 레이아웃 변경은 없는지
- Tailwind CSS 클래스 사용이 적절한지
- 네이버 지도 API 관련 변경이 올바른지
한국어로 3줄 이내로 핵심만 답변."""
},
{
"name": "타입스크립트 품질 검사관",
"emoji": "\U0001f9d0",
"prompt": """너는 TypeScript 타입 안전성과 코드 품질만 보는 엄격한 리뷰어야.
이 코드 변경에서 타입 안전성과 코드 품질 문제를 찾아내.
특히:
- any 타입 사용이나 타입 단언(as) 남용은 없는지
- API 응답 타입이 제대로 정의되어 있는지
- null/undefined 처리가 빠져있는지
- 컴포넌트 props 인터페이스가 명확한지
한국어로 3줄 이내로 핵심만 답변."""
},
],
"backend": [
{
"name": "백엔드 API 설계자",
"emoji": "\u2699\ufe0f",
"prompt": """너는 Spring Boot + MyBatis 전문 백엔드 엔지니어야.
이 코드 변경에서 API 설계, 데이터 접근, 성능 문제를 찾아내.
특히:
- MyBatis SQL에 N+1 쿼리나 비효율적인 조인은 없는지
- Oracle DB 특성(인덱스, CLOB 처리, ROWNUM)을 고려했는지
- REST API 설계가 일관적인지 (네이밍, HTTP 메서드, 상태 코드)
- Redis 캐싱 전략이 적절한지 (TTL, 키 설계, 무효화)
- ClobTypeHandler와 @JsonRawValue 사용이 올바른지
한국어로 3줄 이내로 핵심만 답변."""
},
{
"name": "보안 감사관",
"emoji": "\U0001f6e1\ufe0f",
"prompt": """너는 OWASP Top 10을 기준으로 보안 취약점만 찾는 보안 전문가야.
이 코드 변경에서 보안 위험 요소를 찾아내.
특히:
- SQL Injection 가능성 (MyBatis ${} 사용 여부)
- XSS 취약점 (사용자 입력이 그대로 렌더링되는지)
- 민감 정보 노출 (API 키, DB 비밀번호, 에러 스택트레이스)
- CORS 설정이 너무 개방적이지 않은지
- JWT/인증 토큰 처리가 안전한지
- 입력값 검증이 빠져있는지
한국어로 3줄 이내로 핵심만 답변."""
},
],
"common": [
{
"name": "아키텍처 리뷰어",
"emoji": "\U0001f3d7\ufe0f",
"prompt": """너는 전체 시스템 아키텍처를 보는 테크 리드야.
이 코드 변경이 전체 아키텍처에 미치는 영향을 평가해.
특히:
- 프론트엔드-백엔드 간 API 계약이 맞는지
- 관심사 분리가 제대로 되어 있는지 (컨트롤러/서비스/매퍼)
- 기존 코드 패턴과 일관성이 있는지
- 확장성이나 유지보수성을 해치는 변경은 없는지
한국어로 3줄 이내로 핵심만 답변."""
},
]
}
PROJECT_CONTEXT = """
[프로젝트 컨텍스트 — Tasteby]
- YouTube 맛집 영상에서 식당 정보를 추출하여 지도에 표시하는 서비스
- 스택: Spring Boot 3.3.5 + Java 21 + MyBatis + Oracle 23ai + Redis + Next.js 16 + TypeScript + Tailwind CSS
- 프론트엔드: Next.js App Router, TypeScript, Tailwind CSS, 네이버 지도 API
- 백엔드: Spring Boot REST API, MyBatis XML 매퍼, Oracle ADB, Redis 캐싱
- 주요 패턴: Controller(thin) -> Service(비즈니스 로직) -> Mapper(MyBatis XML)
- 도메인: Lombok @Data/@Builder, Jackson SNAKE_CASE, CLOB은 ClobTypeHandler
- UUID 생성: IdGenerator.newId() (32자 대문자 hex)
- 인증: JWT 토큰 (AuthUtil.requireAdmin())
[리뷰 시 참고]
- MyBatis에서 #{} 는 안전(PreparedStatement), ${} 는 SQL Injection 위험
- Oracle CLOB 필드는 ClobTypeHandler + @JsonRawValue 조합으로 처리
- Oracle 대문자 컬럼명 → JsonUtil.lowerKeys()로 변환
- resultType="map" 쿼리 시 서비스 레이어에서 JsonUtil.lowerKeys() 필수
- UserInfo.isAdmin은 @JsonProperty("is_admin") 필요 (JavaBeans boolean 네이밍)
- CORS 설정: WebConfig.java에서 관리, 새 HTTP 메서드 추가 시 allowedMethods 확인
"""
def get_diff(args):
"""변경사항 diff 가져오기"""
if args.range:
cmd = ["git", "diff", args.range]
elif args.commits:
cmd = ["git", "diff", f"HEAD~{args.commits}", "HEAD"]
else:
diff = subprocess.run(["git", "diff", "HEAD"], capture_output=True, text=True).stdout
if not diff.strip():
diff = subprocess.run(["git", "diff", "HEAD~1", "HEAD"], capture_output=True, text=True).stdout
return diff
return subprocess.run(cmd, capture_output=True, text=True).stdout
def filter_diff_by_scope(diff, scope):
"""scope에 따라 diff 필터링"""
if scope == "all":
return diff
lines = diff.split("\n")
filtered = []
include = False
for line in lines:
if line.startswith("diff --git"):
if scope == "frontend":
include = "frontend/" in line
elif scope == "backend":
include = "backend-java/" in line
if include:
filtered.append(line)
return "\n".join(filtered)
def get_changed_files(args):
"""변경된 파일 목록"""
if args.range:
cmd = ["git", "diff", "--name-only", args.range]
elif args.commits:
cmd = ["git", "diff", "--name-only", f"HEAD~{args.commits}", "HEAD"]
else:
result = subprocess.run(["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True).stdout.strip()
if not result:
result = subprocess.run(["git", "diff", "--name-only", "HEAD~1", "HEAD"], capture_output=True, text=True).stdout.strip()
return result
return subprocess.run(cmd, capture_output=True, text=True).stdout.strip()
def call_llm(system_prompt, user_content):
"""OpenRouter API 호출"""
import urllib.request
body = json.dumps({
"model": MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content}
],
"max_tokens": 500,
}).encode()
req = urllib.request.Request(API_URL, data=body, headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read())
return data["choices"][0]["message"]["content"]
except Exception as e:
return f"[API 호출 실패] {e}"
def select_personas(scope, files):
"""변경 파일에 따라 적절한 페르소나 선택"""
personas = list(PERSONAS["common"])
has_frontend = any(f.startswith("frontend/") for f in files.split("\n")) if files else False
has_backend = any(f.startswith("backend-java/") for f in files.split("\n")) if files else False
if scope == "frontend" or (scope == "all" and has_frontend):
personas = PERSONAS["frontend"] + personas
if scope == "backend" or (scope == "all" and has_backend):
personas = PERSONAS["backend"] + personas
if scope == "all" and not has_frontend and not has_backend:
personas = PERSONAS["frontend"] + PERSONAS["backend"] + personas
return personas
def main():
parser = argparse.ArgumentParser(description="Tasteby 페르소나 기반 코드 리뷰")
parser.add_argument("--commits", "-n", type=int, help="최근 N개 커밋 리뷰")
parser.add_argument("--range", "-r", type=str, help="커밋 범위 (예: abc..def)")
parser.add_argument("--scope", "-s", choices=["frontend", "backend", "all"], default="all",
help="리뷰 범위 (frontend/backend/all)")
parser.add_argument("--model", "-m", type=str, help="사용할 모델 (기본: anthropic/claude-haiku-4-5)")
args = parser.parse_args()
if args.model:
global MODEL
MODEL = args.model
if not OPENROUTER_API_KEY:
print("ERROR: OPENROUTER_API_KEY 환경변수가 필요합니다")
print(" .env 파일에 OPENROUTER_API_KEY=sk-or-... 추가하거나")
print(" export OPENROUTER_API_KEY=sk-or-...")
sys.exit(1)
diff = get_diff(args)
if not diff.strip():
print("변경사항이 없습니다.")
sys.exit(0)
diff = filter_diff_by_scope(diff, args.scope)
if not diff.strip():
print(f"'{args.scope}' 범위에 해당하는 변경사항이 없습니다.")
sys.exit(0)
files = get_changed_files(args)
personas = select_personas(args.scope, files)
max_diff = 8000
truncated = len(diff) > max_diff
diff_for_review = diff[:max_diff]
print("=" * 60)
print(f" Tasteby 코드 리뷰 ({args.scope})")
print("=" * 60)
print(f"\n 모델: {MODEL}")
print(f" 변경 파일:\n")
for f in files.split("\n"):
if f.strip():
if f.startswith("frontend/"):
tag = "[FE]"
elif f.startswith("backend-java/"):
tag = "[BE]"
else:
tag = "[--]"
print(f" {tag} {f}")
if truncated:
print(f"\n (diff가 {max_diff}자로 잘림, 원본 {len(diff)}자)")
print()
opinions = []
for p in personas:
print(f" {p['emoji']} [{p['name']}] 리뷰 중...")
user_msg = f"{PROJECT_CONTEXT}\n\n아래 코드 변경을 리뷰해줘:\n\n```diff\n{diff_for_review}\n```"
result = call_llm(p["prompt"], user_msg)
opinions.append({"name": p["name"], "emoji": p["emoji"], "opinion": result})
print(f" {result}\n")
print("=" * 60)
summary_prompt = """너는 프론트엔드/백엔드 리뷰어들의 의견을 종합하는 테크 리드야.
각 의견을 보고 최종 권고를 내려줘:
- PASS: 문제 없음, 배포 가능
- WARN: 사소한 이슈 있지만 배포 가능, 후속 작업 필요
- FAIL: 수정 필요, 배포 전 반드시 고쳐야 함
한국어로 최종 판정 1줄 + 이유/액션 아이템 2-3줄로 답변."""
opinions_text = "\n\n".join(
f"[{o['name']}]\n{o['opinion']}" for o in opinions
)
print(f"\n [최종 권고]")
final = call_llm(
summary_prompt,
f"{PROJECT_CONTEXT}\n\n변경사항 요약:\n{diff_for_review[:3000]}\n\n리뷰 의견:\n{opinions_text}"
)
print(f" {final}")
print("\n" + "=" * 60)
if __name__ == "__main__":
main()

21
scripts/enqueue.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# 새 작업을 파이프라인 큐에 투입 = 01-Planner/신규 Redmine 이슈 생성.
# 사용법: ./scripts/enqueue.sh "제목" ["요구사항"]
set -euo pipefail
cd "$(dirname "$0")/.."
set -a; . ./.env; set +a
SUBJECT="${1:?사용법: enqueue.sh \"제목\" [\"요구\"]}"; BODY="${2:-}"
PLANNER=$(curl -s -H "X-Redmine-API-Key: $REDMINE_API_KEY" \
"$REDMINE_URL/projects/$REDMINE_PROJECT/issue_categories.json" \
| python3 -c "import sys,json;[print(c['id']) for c in json.load(sys.stdin)['issue_categories'] if c['name']=='01-Planner']")
DESC=$(printf '## [AI] Planner\n\n(요구사항)\n%s\n\n---\nWorking dir: %s' "$BODY" "$(pwd)")
python3 - "$REDMINE_URL" "$REDMINE_API_KEY" "$REDMINE_PROJECT" "$SUBJECT" "$DESC" "$PLANNER" <<'PY'
import sys,json,urllib.request
base,key,proj,subject,desc,cat=sys.argv[1:7]
p={"issue":{"project_id":proj,"tracker_id":2,"subject":subject,"description":desc,
"category_id":int(cat),"status_id":1}}
r=urllib.request.Request(base+"/issues.json",data=json.dumps(p).encode(),
headers={"X-Redmine-API-Key":key,"Content-Type":"application/json"},method="POST")
i=json.load(urllib.request.urlopen(r))["issue"]
print(f"enqueued #{i['id']}: {i['subject']} -> 01-Planner/신규")
PY

586
scripts/persona_review.py Executable file
View File

@@ -0,0 +1,586 @@
#!/usr/bin/env python3
"""
Tasteby 페르소나 기반 UX/기능 리뷰
사용자 페르소나 관점에서 앱 기능을 리뷰하고 개선점을 도출.
결과는 reviews/ 디렉토리에 타임스탬프로 저장.
사용법:
# 전체 페르소나 리뷰
python3 scripts/persona_review.py
# 특정 페르소나만
python3 scripts/persona_review.py --persona foodie
python3 scripts/persona_review.py --persona tourist
python3 scripts/persona_review.py --persona beginner
python3 scripts/persona_review.py --persona mobile
# 특정 영역만 리뷰
python3 scripts/persona_review.py --focus home
python3 scripts/persona_review.py --focus map
python3 scripts/persona_review.py --focus detail
python3 scripts/persona_review.py --focus admin
# 모델 변경
python3 scripts/persona_review.py --model anthropic/claude-sonnet-4
# 결과만 보기 (API 호출 없이 마지막 리뷰)
python3 scripts/persona_review.py --latest
"""
import json, os, sys, argparse, glob
from pathlib import Path
from datetime import datetime, timezone, timedelta
# .env 로드
def load_dotenv():
env_path = Path(__file__).resolve().parent.parent / ".env"
if env_path.exists():
with open(env_path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, val = line.partition("=")
os.environ.setdefault(key.strip(), val.strip())
load_dotenv()
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY")
MODEL = os.environ.get("REVIEW_MODEL", "anthropic/claude-haiku-4-5")
API_URL = "https://openrouter.ai/api/v1/chat/completions"
PROJECT_ROOT = Path(__file__).resolve().parent.parent
REVIEWS_DIR = PROJECT_ROOT / "reviews"
KST = timezone(timedelta(hours=9))
# --- 페르소나 정의 ---
PERSONAS = {
"foodie": {
"name": "맛집 탐방러",
"emoji": "🍜",
"desc": "유튜브 맛집 영상을 자주 보고, 주말마다 맛집 탐방을 다니는 30대 직장인",
"prompt": """너는 유튜브 먹방/맛집 채널을 매일 보는 30대 직장인이야.
주말마다 새로운 맛집을 찾아가는 게 취미고, 성시경, 김사원세끼 같은 채널을 구독해.
지도 앱으로 맛집 위치를 확인하고, 리뷰를 남기는 걸 좋아해.
이 앱의 현재 상태를 보고, 너 같은 사용자 입장에서 리뷰해줘:
1. 유튜버별로 맛집을 찾고 → 지도에서 위치 확인 → 상세 정보를 보는 흐름이 자연스러운가?
2. 식당 필터링(장르, 지역, 유튜버)이 편리한가?
3. 식당 상세 정보(평가, 메뉴, 가격대, 예약 링크)가 충분한가?
4. 즐겨찾기, 리뷰, 메모 기능이 실용적인가?
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
"tourist": {
"name": "여행자",
"emoji": "✈️",
"desc": "서울 여행 계획 중인 지방/해외 거주자. 유명 유튜버가 간 맛집을 찾고 싶음",
"prompt": """너는 서울 여행을 계획하고 있는 부산 거주 20대야.
유튜브에서 본 맛집들을 미리 정리해서 여행 코스를 짜고 싶어.
"이 동네에 갔을 때 뭘 먹을지" 지역별로 정리하는 게 중요해.
이 앱의 현재 상태를 보고, 너 같은 사용자 입장에서 리뷰해줘:
1. 특정 지역(예: 강남, 홍대)의 맛집을 한눈에 볼 수 있는가?
2. 지도에서 동선을 짜기 편한가? (근처 맛집 묶어보기)
3. 즐겨찾기로 "가볼 곳 리스트"를 만들기 편한가?
4. 예약 정보(테이블링, 캐치테이블 링크)가 잘 연결되어 있는가?
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
"beginner": {
"name": "앱 첫 방문자",
"emoji": "🆕",
"desc": "앱에 처음 들어온 사람. 이 앱이 뭔지, 어떻게 쓰는지 모름",
"prompt": """너는 친구가 보내준 링크를 타고 이 앱에 처음 들어온 사람이야.
이 앱이 뭘 하는 건지, 어떻게 쓰는 건지 전혀 모르는 상태야.
이 앱의 현재 상태를 보고, 첫 방문자 입장에서 리뷰해줘:
1. 첫 화면을 보고 3초 안에 "이 앱이 뭔지" 이해할 수 있는가?
2. 어디를 눌러야 하는지 직관적으로 알 수 있는가?
3. 회원가입/로그인 없이도 앱을 체험해볼 수 있는가?
4. 처음 사용자에게 혼란스럽거나 막히는 지점은?
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
"mobile": {
"name": "모바일 사용자",
"emoji": "📱",
"desc": "스마트폰으로만 앱을 사용. 지하철에서 맛집 검색하거나 현장에서 확인",
"prompt": """너는 항상 스마트폰으로만 이 앱을 쓰는 사용자야.
지하철에서 주말 맛집을 검색하거나, 현장에서 "내 주변 맛집"을 찾아볼 때 사용해.
한 손 조작이 중요하고, 지도 + 리스트를 빠르게 전환해야 해.
이 앱의 현재 프론트엔드 코드를 보고, 모바일 사용자 입장에서 리뷰해줘:
1. 모바일에서 터치 영역이 충분한가? (최소 44x44px)
2. 지도와 리스트 전환이 매끄러운가?
3. "내 주변" 기능이 현장에서 유용한가?
4. 바텀시트, 필터 등 모바일 UI가 네이티브 앱처럼 자연스러운가?
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
"power": {
"name": "파워 유저",
"emoji": "",
"desc": "매주 맛집 탐방 후 리뷰/메모를 남기는 헤비 유저. 즐겨찾기 50개 이상",
"prompt": """너는 이 앱을 3개월째 매주 쓰는 파워 유저야.
즐겨찾기 50개 이상, 리뷰도 20개 넘게 남겼어. 영상관리 페이지도 잘 알아.
이 앱의 현재 상태를 보고, 파워 유저 입장에서 리뷰해줘:
1. 즐겨찾기가 많아졌을 때 관리가 편한가? (검색, 정렬, 폴더)
2. 내가 남긴 리뷰/메모를 다시 찾아보기 편한가?
3. 새로 추가된 식당이나 영상을 빠르게 확인할 수 있는가?
4. 반복 사용하면서 느끼는 불편함은?
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
"frontend_dev": {
"name": "프론트엔드 개발자",
"emoji": "💻",
"desc": "Next.js/React/TypeScript 전문 프론트엔드 개발자. 코드 품질과 성능에 민감",
"prompt": """너는 Next.js + TypeScript 경력 5년차 프론트엔드 개발자야.
코드 품질, 성능 최적화, 컴포넌트 설계에 까다로운 편이야.
이 앱의 프론트엔드 코드를 보고, 개발자 관점에서 리뷰해줘:
1. 컴포넌트 구조가 적절한가? (크기, 책임 분리, 재사용성)
2. 상태 관리가 효율적인가? (불필요한 리렌더링, prop drilling)
3. API 호출 패턴이 적절한가? (로딩/에러 처리, 캐싱, 중복 요청)
4. 성능 문제가 있는가? (큰 컴포넌트, 메모이제이션 부족, 번들 크기)
5. 구체적인 리팩토링/개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
"backend_dev": {
"name": "백엔드 개발자",
"emoji": "⚙️",
"desc": "Spring Boot/MyBatis/Oracle 전문 백엔드 개발자. API 설계와 DB 성능에 민감",
"prompt": """너는 Spring Boot + MyBatis + Oracle DB 경력 7년차 백엔드 개발자야.
REST API 설계, DB 쿼리 최적화, 캐싱 전략에 까다로운 편이야.
이 앱의 백엔드 코드를 보고, 개발자 관점에서 리뷰해줘:
1. Controller/Service/Mapper 계층 분리가 적절한가?
2. MyBatis 쿼리에 성능 문제가 있는가? (N+1, 불필요한 JOIN, 인덱스 활용)
3. 에러 처리/예외 전략이 일관적인가?
4. Redis 캐싱 전략이 적절한가? (키 설계, TTL, 무효화)
5. 구체적인 리팩토링/개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
"devops": {
"name": "DevOps 엔지니어",
"emoji": "🚀",
"desc": "CI/CD, 컨테이너, 모니터링 전문. 운영 안정성과 배포 자동화에 민감",
"prompt": """너는 Kubernetes + Docker + CI/CD 전문 DevOps 엔지니어야.
운영 안정성, 배포 자동화, 모니터링, 로깅에 까다로운 편이야.
이 앱의 인프라/배포 관련 코드를 보고, DevOps 관점에서 리뷰해줘:
1. 배포 프로세스가 안전하고 자동화되어 있는가?
2. 환경별 설정(dev/prod) 분리가 적절한가?
3. 로깅/모니터링이 장애 대응에 충분한가?
4. 컨테이너 이미지 빌드가 최적화되어 있는가? (레이어 캐싱, 이미지 크기)
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
}
# --- 앱 컨텍스트 수집 ---
FOCUS_PATHS = {
"home": [
"frontend/src/app/page.tsx",
"frontend/src/app/layout.tsx",
],
"map": [
"frontend/src/components/MapView.tsx",
"frontend/src/components/FilterSheet.tsx",
"frontend/src/components/RestaurantList.tsx",
],
"detail": [
"frontend/src/components/RestaurantDetail.tsx",
"frontend/src/components/ReviewSection.tsx",
"frontend/src/components/MemoSection.tsx",
],
"admin": [
"frontend/src/app/admin/page.tsx",
],
"api": [
"frontend/src/lib/api.ts",
],
"backend": [
"backend-java/src/main/java/com/tasteby/controller/RestaurantController.java",
"backend-java/src/main/java/com/tasteby/controller/VideoController.java",
"backend-java/src/main/java/com/tasteby/service/RestaurantService.java",
"backend-java/src/main/java/com/tasteby/service/VideoService.java",
"backend-java/src/main/java/com/tasteby/service/CacheService.java",
"backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml",
],
"infra": [
"ecosystem.config.js",
"deploy.sh",
"backend-java/Dockerfile",
"frontend/Dockerfile",
"k8s/backend-deployment.yaml",
"k8s/frontend-deployment.yaml",
],
}
def collect_app_context(focus, persona_keys=None):
"""프론트엔드/백엔드 파일 읽어서 앱 상태 컨텍스트 생성"""
if focus == "all":
paths = []
for key in ["home", "map", "detail", "api"]:
paths.extend(FOCUS_PATHS[key])
# 개발자 페르소나면 백엔드/인프라도 포함
if persona_keys:
dev_personas = {"frontend_dev", "backend_dev", "devops"}
if dev_personas & set(persona_keys):
paths.extend(FOCUS_PATHS.get("backend", []))
paths.extend(FOCUS_PATHS.get("infra", []))
paths = list(dict.fromkeys(paths))
else:
paths = FOCUS_PATHS.get(focus, [])
context_parts = []
for rel_path in paths:
full_path = PROJECT_ROOT / rel_path
if full_path.exists():
try:
content = full_path.read_text()
if len(content) > 4000:
content = content[:4000] + "\n... (truncated)"
context_parts.append(f"=== {rel_path} ===\n{content}")
except Exception:
pass
# 디자인 가이드 포함
for doc in ["frontend/docs/design-concepts.md", "frontend/docs/brand-guide.md"]:
doc_path = PROJECT_ROOT / doc
if doc_path.exists():
try:
dg = doc_path.read_text()
if len(dg) > 2000:
dg = dg[:2000] + "\n... (truncated)"
context_parts.append(f"=== {doc} ===\n{dg}")
except Exception:
pass
return "\n\n".join(context_parts)
# --- LLM 호출 ---
def call_llm(system_prompt, user_content):
"""OpenRouter API 호출"""
import urllib.request
body = json.dumps({
"model": MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content}
],
"max_tokens": 2000,
}).encode()
req = urllib.request.Request(API_URL, data=body, headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read())
return data["choices"][0]["message"]["content"]
except Exception as e:
return f"[API 호출 실패] {e}"
# --- 개발자 토론 ---
DEV_PERSONA = {
"name": "풀스택 개발자",
"emoji": "🧑‍💻",
"prompt": """너는 이 프로젝트의 풀스택 개발자야.
Spring Boot + MyBatis + Oracle 백엔드와 Next.js + TypeScript 프론트엔드를 모두 다룰 수 있어.
현재 코드베이스를 잘 알고 있고, 무엇이 쉽고 어려운지 정확히 판단할 수 있어.
사용자 페르소나들이 아래와 같은 개선 제안을 했어.
각 제안에 대해 개발자 입장에서 대화하듯 응답해줘:
응답 형식 (각 제안마다):
- ✅ "좋은 생각이에요! 이건 OO 파일 수정하면 금방 됩니다" (쉬움, 1일 이내)
- ⚠️ "가능은 한데, OO 때문에 좀 복잡해요. N일 정도 걸릴 듯" (보통)
- 🔴 "이건 현재 구조에서 어려워요. OO를 먼저 바꿔야 합니다" (어려움/큰 작업)
- 💡 "그것보다 이렇게 하면 더 좋을 것 같아요" (대안 제시)
각 제안에 예상 작업량(시간/일)도 함께 알려줘.
한국어로, 실제 동료 개발자에게 말하듯 자연스럽게 답변."""
}
def dev_discussion(user_opinions, app_context):
"""사용자 페르소나 의견을 개발자가 검토하고 토론"""
opinions_text = "\n\n".join(
f"[{o['name']} {o['emoji']}]\n{o['opinion']}" for o in user_opinions
)
user_msg = f"""아래는 앱의 현재 코드 상태야:
{app_context[:5000]}
---
아래는 사용자 페르소나들의 개선 제안이야. 각 제안에 대해 실현 가능성을 판단해줘:
{opinions_text}"""
return call_llm(DEV_PERSONA["prompt"], user_msg)
# --- 종합 판정 ---
def synthesize(user_opinions, dev_response, app_context):
"""사용자 의견 + 개발자 판단을 종합해 최종 태스크 리스트 생성"""
system_prompt = """너는 PM(프로덕트 매니저)이야.
사용자 페르소나들의 요구사항과 개발자의 실현 가능성 판단을 종합해서
최종 실행 태스크 리스트를 만들어줘.
규칙:
- 개발자가 "쉽다"고 한 것 중 여러 페르소나가 원한 것 → [P0] (바로 하자)
- 개발자가 "가능하다"고 한 것 중 임팩트 큰 것 → [P1] (다음 스프린트)
- 개발자가 "어렵다"고 한 것 중 꼭 필요한 것 → [P2] (로드맵에 넣자)
- 개발자가 대안을 제시한 경우 대안으로 태스크 작성
- 각 태스크에 예상 작업량, 수정 대상 파일 포함
- 최대 15개 태스크
- 한국어로 작성"""
opinions_text = "\n\n".join(
f"[{o['name']} {o['emoji']}]\n{o['opinion']}" for o in user_opinions
)
user_msg = f"""사용자 의견:
{opinions_text}
---
개발자 검토:
{dev_response}"""
return call_llm(system_prompt, user_msg)
# --- 결과 저장 ---
def save_result(opinions, dev_response, summary, focus, model):
"""리뷰 결과를 reviews/ 디렉토리에 저장"""
REVIEWS_DIR.mkdir(exist_ok=True)
now = datetime.now(KST)
filename = f"persona-review-{now.strftime('%Y%m%d-%H%M')}.md"
filepath = REVIEWS_DIR / filename
lines = []
lines.append(f"# Tasteby 페르소나 UX 리뷰 — {now.strftime('%Y-%m-%d %H:%M')} KST")
lines.append("")
lines.append(f"- **모델**: {model}")
lines.append(f"- **리뷰 범위**: {focus}")
lines.append(f"- **페르소나**: {', '.join(o['name'] for o in opinions)}")
lines.append("")
lines.append("---")
lines.append("")
# 1단계: 사용자 리뷰
lines.append("# 1단계: 사용자 페르소나 리뷰")
lines.append("")
for o in opinions:
lines.append(f"## {o['emoji']} {o['name']}")
lines.append(f"> {PERSONAS[o['key']]['desc']}")
lines.append("")
lines.append(o["opinion"])
lines.append("")
lines.append("---")
lines.append("")
# 2단계: 개발자 토론
lines.append("# 2단계: 🧑‍💻 개발자 실현 가능성 검토")
lines.append("")
lines.append(dev_response)
lines.append("")
lines.append("---")
lines.append("")
# 3단계: 종합
lines.append("# 3단계: 📋 최종 태스크 리스트")
lines.append("")
lines.append(summary)
lines.append("")
filepath.write_text("\n".join(lines))
return filepath
def show_latest():
"""가장 최근 리뷰 파일 출력"""
if not REVIEWS_DIR.exists():
print("리뷰 기록이 없습니다.")
return
files = sorted(REVIEWS_DIR.glob("persona-review-*.md"))
if not files:
print("리뷰 기록이 없습니다.")
return
latest = files[-1]
print(f"최근 리뷰: {latest}\n")
print(latest.read_text())
# --- 메인 ---
def main():
parser = argparse.ArgumentParser(description="Tasteby 페르소나 기반 UX 리뷰")
parser.add_argument("--persona", "-p", type=str,
help=f"특정 페르소나만 ({', '.join(PERSONAS.keys())})")
parser.add_argument("--focus", "-f", type=str, default="all",
choices=["all", "home", "map", "detail", "admin", "api", "backend", "infra"],
help="리뷰 대상 영역")
parser.add_argument("--model", "-m", type=str, help="사용할 모델")
parser.add_argument("--latest", "-l", action="store_true",
help="마지막 리뷰 결과 보기")
parser.add_argument("--list", action="store_true",
help="리뷰 기록 목록 보기")
args = parser.parse_args()
if args.latest:
show_latest()
return
if args.list:
if not REVIEWS_DIR.exists():
print("리뷰 기록이 없습니다.")
return
files = sorted(REVIEWS_DIR.glob("persona-review-*.md"))
if not files:
print("리뷰 기록이 없습니다.")
return
print(f"리뷰 기록 ({len(files)}건):\n")
for f in files:
print(f" {f.name}")
return
if args.model:
global MODEL
MODEL = args.model
if not OPENROUTER_API_KEY:
print("ERROR: OPENROUTER_API_KEY가 필요합니다")
print(" .env 파일에 OPENROUTER_API_KEY=sk-or-... 추가하거나")
print(" export OPENROUTER_API_KEY=sk-or-...")
sys.exit(1)
# 사용자 페르소나 분류 (개발자 페르소나는 제외 — 자동으로 토론에 참여)
USER_PERSONAS = {k: v for k, v in PERSONAS.items()
if k not in ("frontend_dev", "backend_dev", "devops")}
# 페르소나 선택
if args.persona:
if args.persona not in USER_PERSONAS:
print(f"ERROR: 알 수 없는 페르소나 '{args.persona}'")
print(f" 가능한 값: {', '.join(USER_PERSONAS.keys())}")
sys.exit(1)
selected = {args.persona: USER_PERSONAS[args.persona]}
else:
selected = USER_PERSONAS
# 앱 컨텍스트 수집 (개발자 토론을 위해 백엔드도 포함)
all_keys = list(selected.keys()) + ["backend_dev"]
print("=" * 60)
print(f" Tasteby 페르소나 UX 리뷰")
print("=" * 60)
print(f"\n 모델: {MODEL}")
print(f" 범위: {args.focus}")
print(f" 사용자 페르소나: {', '.join(p['name'] for p in selected.values())}")
print(f" 개발자: {DEV_PERSONA['emoji']} {DEV_PERSONA['name']} (자동 참여)")
print(f"\n 앱 컨텍스트 수집 중...")
app_context = collect_app_context(args.focus, all_keys)
print(f" 컨텍스트 크기: {len(app_context):,}")
# ── 1단계: 사용자 페르소나 리뷰 ──
print(f"\n{'=' * 60}")
print(f" 1단계: 사용자 페르소나 리뷰")
print(f"{'=' * 60}\n")
opinions = []
for key, persona in selected.items():
print(f" {persona['emoji']} [{persona['name']}] 리뷰 중...")
user_msg = f"""아래는 유튜브 맛집 지도 서비스 'Tasteby'의 코드야.
유튜버들이 소개한 식당을 지도에 표시하고, 필터링/검색/즐겨찾기/리뷰를 제공하는 앱이야.
이 코드를 보고 앱의 현재 상태를 파악한 뒤 리뷰해줘.
{app_context}"""
result = call_llm(persona["prompt"], user_msg)
opinions.append({
"key": key,
"name": persona["name"],
"emoji": persona["emoji"],
"opinion": result,
})
print(f" 완료 ✓")
print(f"\n{'' * 50}")
print(f" {persona['emoji']} {persona['name']}:")
print(f"{'' * 50}")
for line in result.split("\n"):
print(f" {line}")
print()
# ── 2단계: 개발자 토론 ──
print(f"{'=' * 60}")
print(f" 2단계: {DEV_PERSONA['emoji']} 개발자 실현 가능성 검토")
print(f"{'=' * 60}\n")
print(f" {DEV_PERSONA['emoji']} [{DEV_PERSONA['name']}] 각 제안 검토 중...")
dev_response = dev_discussion(opinions, app_context)
print(f" 완료 ✓")
print(f"\n{'' * 50}")
print(f" {DEV_PERSONA['emoji']} {DEV_PERSONA['name']}:")
print(f"{'' * 50}")
for line in dev_response.split("\n"):
print(f" {line}")
print()
# ── 3단계: PM 종합 ──
print(f"{'=' * 60}")
print(f" 3단계: 📋 PM 종합 — 최종 태스크 리스트")
print(f"{'=' * 60}\n")
print(f" 📋 사용자 의견 + 개발자 판단 종합 중...")
summary = synthesize(opinions, dev_response, app_context)
print(f"\n{'' * 50}")
print(f" 📋 최종 태스크 리스트:")
print(f"{'' * 50}")
for line in summary.split("\n"):
print(f" {line}")
print()
# 저장
filepath = save_result(opinions, dev_response, summary, args.focus, MODEL)
print("=" * 60)
print(f" 💾 결과 저장: {filepath}")
print("=" * 60)
if __name__ == "__main__":
main()