From 2d84ca8eca7c94140095df2b796cc9d3e3388faa Mon Sep 17 00:00:00 2001 From: hyeonsang Date: Wed, 30 Sep 2026 02:27:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Opus=205.5=20=ED=95=98=EB=84=A4?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=95=EB=A6=AC=20+=20=EC=86=90=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EC=8A=A4=ED=82=AC=20+=20=EC=9B=8C=EB=93=9C=EB=A7=88=ED=81=AC?= =?UTF-8?q?=20=EA=B5=90=EC=A0=95=20(v0.1.19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 하네스 (hooks.json) - SessionStart 게이트 경고가 stderr + exit 0이라 사용자도 Claude도 못 보던 버그 수정: JSON stdout(systemMessage + additionalContext)으로 전달, 동기 실행 - 효과 없던 훅 삭제: Write/Edit 리마인더 2개, Stop "Pipeline session complete" (PostToolUse·Stop의 일반 stdout은 디버그 로그에만 기록됨) - 위험 명령 차단 훅 오탐 수정: heredoc 본문(파일·인터프리터 입력 데이터)은 검사에서 제외. bash/sh/zsh/ssh/eval/source로 실행되는 heredoc은 계속 검사. 회귀 테스트 10건 추가 (macOS awk, mawk, gawk에서 52/52 통과) 에이전트 · 커맨드 (Opus 5.5 대응: 과잉 지시 제거) - 빌드 계열 7개 에이전트의 effort: high 삭제 → 세션 effort를 따름. 리뷰 계열 3개는 high 유지 - ai-agent 556→71줄: 낡은 모델 ID·API 방식 삭제, Provider 정보는 claude-api 스킬·공식 문서로 확인 - architecture-decision 503→약 90줄: 점수표·중복 흐름도·예시 삭제, Scale Grade·Over-Spec·보안 가드레일 유지 - team-build-coordinator: 모델이 잴 수 없는 초 단위 타임아웃 삭제 → "시간이 중요하다" 신호, 병렬 활성 조건은 /implement 한 곳(팀 2+ 또는 Phase 2+)을 정본으로, 없는 스킬 참조 삭제 - backend-architect: 존재하지 않는 스킬·워크플로 참조 삭제 - /implement Rule 5: Phase 요약만 남기고 턴을 끝내지 않기, 멈춰도 되는 지점 명시 - design-system-reference: 현재 모델의 기본 스타일 5종을 이름으로 금지 목록에 추가 architecture-diagram (handdrawn-diagram 대체) - JSON 스펙 → 브라우저에서 글자 측정 → ELK 레이아웃 → rough.js 손그림 SVG/PNG - 기본 sketch: Mermaid handDrawn 파라미터(도형 0.8 / 선 0.35, 해칭), open-color 파스텔, Poor Story 손글씨 제목 + Pretendard 본문, subset 폰트를 SVG에 내장. clean 스타일 옵션 - 레이아웃 검사: 글자 넘침, 노드 겹침, 선이 다른 노드·라벨을 관통 → exit 1 - 잘못된 스펙·설치 실패는 exit 2 (부모 순환, 자기 루프, 타입 오류 포함) - 폰트는 커밋 SHA 고정 + SHA-256 검증, 설치 구간 잠금으로 동시 실행 안전 - SKILL.md: 렌더 → PNG 확인 → 스펙 수정 루프 필수 wigtn-ppt - 워드마크는 WIGTn. (n은 소문자 모양을 대문자 높이로). 텍스트 폴백을 교정: n 1.44em, 점은 원 문서 - README 3개 언어: 훅 표를 실제 훅 4개로 교체, 스킬 설명 갱신, 버전 배지 - CLAUDE.md: 비동기 훅 설명 정정, 스킬 목록·Key Paths 갱신 - 버전 0.1.19 (marketplace.json 2곳, plugin.json 2곳, CLAUDE.md) Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/scripts/test_danger_hook.sh | 15 + CLAUDE.md | 7 +- README.cn.md | 14 +- README.ko.md | 14 +- README.md | 14 +- .../wigtn-plugins/.claude-plugin/plugin.json | 4 +- plugins/wigtn-plugins/agents/ai-agent.md | 567 ++-------------- .../agents/architecture-decision.md | 503 ++------------ .../wigtn-plugins/agents/backend-architect.md | 12 +- .../wigtn-plugins/agents/design-discovery.md | 1 - .../agents/frontend-developer.md | 1 - .../wigtn-plugins/agents/mobile-developer.md | 1 - .../agents/team-build-coordinator.md | 39 +- plugins/wigtn-plugins/commands/implement.md | 1 + plugins/wigtn-plugins/hooks/hooks.json | 27 +- .../skills/architecture-diagram/SKILL.md | 122 ++++ .../examples/layered-architecture.json | 37 + .../examples/pipeline-flow.json | 107 +++ .../architecture-diagram/scripts/render.cjs | 642 ++++++++++++++++++ .../architecture-diagram/scripts/render.sh | 70 ++ .../skills/design-system-reference/SKILL.md | 9 +- .../skills/handdrawn-diagram/SKILL.md | 108 --- .../wigtn-plugins/skills/wigtn-ppt/SKILL.md | 10 +- .../skills/wigtn-ppt/references/brand.md | 12 +- .../wigtn-ppt/references/design-guide.md | 2 +- 28 files changed, 1143 insertions(+), 1204 deletions(-) create mode 100644 plugins/wigtn-plugins/skills/architecture-diagram/SKILL.md create mode 100644 plugins/wigtn-plugins/skills/architecture-diagram/examples/layered-architecture.json create mode 100644 plugins/wigtn-plugins/skills/architecture-diagram/examples/pipeline-flow.json create mode 100644 plugins/wigtn-plugins/skills/architecture-diagram/scripts/render.cjs create mode 100755 plugins/wigtn-plugins/skills/architecture-diagram/scripts/render.sh delete mode 100644 plugins/wigtn-plugins/skills/handdrawn-diagram/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7c3226d..ff58f9d 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.17" + "version": "0.1.19" }, "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.17", + "version": "0.1.19", "author": { "name": "WIGTN Crew", "email": "contact@wigtn.com" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 55b9958..447b5b8 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "wigtn-plugins", - "version": "0.1.17", + "version": "0.1.19", "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/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5ff0656..fff7ce8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -11,7 +11,7 @@ body: attributes: label: 영향 받는 부분 / Affected part description: 어떤 command / agent / skill 인가요? - placeholder: "/screen-spec, code-reviewer, handdrawn-diagram, …" + placeholder: "/screen-spec, code-reviewer, architecture-diagram, …" validations: required: true - type: textarea diff --git a/.github/scripts/test_danger_hook.sh b/.github/scripts/test_danger_hook.sh index 88a267f..b0071a2 100644 --- a/.github/scripts/test_danger_hook.sh +++ b/.github/scripts/test_danger_hook.sh @@ -88,6 +88,21 @@ allowed 'git reset --soft HEAD~1' allowed 'SELECT * FROM users' allowed 'echo "how to drop a database safely"' +echo +echo "== heredoc: 본문은 데이터다 (파일·인터프리터 입력) — 셸로 실행될 때만 검사 ==" +NL=' +' +allowed "cat > notes.md <<'EOF'${NL}Never run git reset --hard or DROP TABLE users here.${NL}EOF" +allowed "python3 - <<'PY'${NL}doc = 'git push --force is blocked'${NL}PY" +allowed "git commit -q -F - < x${NL} DROP DATABASE prod${NL} EOF" +blocked "bash <<'EOF'${NL}rm -rf /${NL}EOF" +blocked "sh < f < @@ -263,10 +263,10 @@ WIGTN Plugins 是一个 Claude Code 插件,由 11 个专业智能体负责需 | 钩子 | 触发器 | 功能 | |------|--------|------| -| 危险命令拦截 | `Bash` PreToolUse | 拦截 `rm -rf /`、`git push --force`、`DROP TABLE` | -| 流水线完成 | Stop | 推送前提醒审查 | -| 前端格式化 | `Write\|Edit` PostToolUse | 提醒对 `.tsx`、`.jsx`、`.css` 运行 prettier/eslint | -| 后端模式合规 | `Write\|Edit` PostToolUse | 检查 `.ts`、`.py`、`.go` 的错误处理、验证、日志 | +| 危险命令拦截 | `Bash` PreToolUse | 拦截 `rm -rf /`、`git push --force`、`git reset --hard`、`DROP TABLE` | +| 提交质量门禁 | `Bash` PreToolUse(`git commit`) | 运行 `hooks/gate.sh`:没有近期门禁 PASS 的 `/auto-commit` 提交会被拦截;`.wigtn/checks.sh`(typecheck/lint)失败时拦截提交 | +| 门禁状态检查 | SessionStart | 无法解析门禁脚本路径时(提交将不经检查通过)向用户和 Claude 发出警告 | +| 知识 Wiki 积累 | Stop(异步) | 仅在已配置时,经策略门禁将会话中学到的内容积累到团队 Wiki | diff --git a/README.ko.md b/README.ko.md index d8b8184..b827a83 100644 --- a/README.ko.md +++ b/README.ko.md @@ -6,7 +6,7 @@ **하나의 플러그인. 11개 에이전트. 아이디어에서 검증된 커밋까지.** -![Version](https://img.shields.io/badge/v0.1.17-Unified_Plugin-FF6B6B?style=for-the-badge) +![Version](https://img.shields.io/badge/v0.1.19-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) @@ -219,10 +219,10 @@ WIGTN Plugins은 요구사항, 아키텍처, 구현, 리뷰를 담당하는 11 |------|-----------| | `code-review-levels` | 심층 리뷰 (Level 3: 호출 체인, 에지 케이스, 동시성) 및 아키텍처 리뷰 (Level 4: SOLID, 계층 위반, 확장성) | | `design-system-reference` | 20개 스타일 가이드 — 타이포그래피, 색상, 컴포넌트, 모션, 안티패턴. design-discovery와 연동하여 컨텍스트 기반 추천 | -| `handdrawn-diagram` | Mermaid `look:handDrawn`로 손그림(스케치) 스타일 아키텍처/플로우 다이어그램을 커밋 가능한 SVG + PNG로 생성. README, GitHub, Devpost, 슬라이드에서 동일하게 렌더 | +| `architecture-diagram` | 아키텍처/플로우 다이어그램을 커밋 가능한 손그림 SVG + PNG로 생성(Excalidraw·Mermaid 손그림 톤: 거친 윤곽선, 파스텔 해칭, 한글 손글씨 제목. clean 스타일도 지원). 실제 브라우저에서 글자 크기를 잰 뒤 ELK로 배치해 글자가 잘리지 않고, 폰트를 SVG에 내장하며, 넘침·겹침이 있으면 레이아웃 검사가 실패 처리 | | `screen-spec` | PRD로부터 5종 UI 산출물 생성 — IA, User Flow, 화면별 명세, 클릭 가능한 Wireframe HTML, Dev Handoff. 흑백 + 의미색 lo-fi 와이어프레임(스타일은 `/implement`에서 결정). `/screen-spec`에서 호출 | | `team-memory-protocol` | 병렬 빌드 중 에이전트 간 공유 컨텍스트(SHARED_CONTEXT) 관리 | -| `wigtn-ppt` | 브랜드 토큰 기반 WIGTN 브랜드 HTML 프레젠테이션 생성(Light/Dark 테마, 템플릿 불필요). 모든 슬라이드에 시그니처 퍼플 점, 로고 에셋이 없으면 CSS/SVG 워드마크 폴백 | +| `wigtn-ppt` | 브랜드 토큰 기반 WIGTN 브랜드 HTML 프레젠테이션 생성(Light/Dark 테마, 템플릿 불필요). 모든 슬라이드에 시그니처 퍼플 점, 로고 에셋이 없으면 CSS/SVG 워드마크 폴백(`WIGTn.` — n을 대문자 높이로) | @@ -263,10 +263,10 @@ WIGTN Plugins은 요구사항, 아키텍처, 구현, 리뷰를 담당하는 11 | 훅 | 트리거 | 기능 | |----|--------|------| -| 위험 명령 차단 | `Bash` PreToolUse | `rm -rf /`, `git push --force`, `DROP TABLE` 차단 | -| 파이프라인 완료 | Stop | 푸시 전 변경사항 검토 알림 | -| 프론트엔드 포맷팅 | `Write\|Edit` PostToolUse | `.tsx`, `.jsx`, `.css` 파일 prettier/eslint 알림 | -| 백엔드 패턴 준수 | `Write\|Edit` PostToolUse | `.ts`, `.py`, `.go` 파일 에러 핸들링, 입력 검증, 로깅 확인 | +| 위험 명령 차단 | `Bash` PreToolUse | `rm -rf /`, `git push --force`, `git reset --hard`, `DROP TABLE` 차단 | +| 커밋 품질 게이트 | `Bash` PreToolUse (`git commit`) | `hooks/gate.sh` 실행: 최근 게이트 PASS 없는 `/auto-commit` 커밋을 막고, `.wigtn/checks.sh`(typecheck/lint)가 실패하면 커밋 차단 | +| 게이트 상태 경고 | SessionStart | 게이트 스크립트 경로를 못 찾으면(검사 없이 커밋이 통과하는 상태) 사용자와 Claude에게 경고 | +| 지식 위키 축적 | Stop (비동기) | 설정된 경우에만, 세션에서 배운 것을 정책 게이트를 거쳐 팀 위키에 축적 | diff --git a/README.md b/README.md index de17526..78f32d1 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.17-Unified_Plugin-FF6B6B?style=for-the-badge) +![Version](https://img.shields.io/badge/v0.1.19-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) @@ -219,10 +219,10 @@ Independent steps can run in parallel; dependent steps remain ordered. Runtime v |-------|-----------------| | `code-review-levels` | Deep review (Level 3: call chains, edge cases, concurrency) and architecture review (Level 4: SOLID, layer violations, scalability) | | `design-system-reference` | 20 style guides with typography, color, components, motion, and anti-patterns. Works with design-discovery for context-aware recommendations | -| `handdrawn-diagram` | Generates a hand-drawn (sketch-style) architecture or flow diagram as committable SVG + PNG via Mermaid `look:handDrawn`. Renders identically on README, GitHub, Devpost, and slides | +| `architecture-diagram` | Draws architecture / flow diagrams as committable hand-drawn SVG + PNG (Excalidraw / Mermaid handDrawn look: rough outlines, pastel hachure, Korean handwriting titles; a clean style too). Labels are measured in a real browser before ELK layout so text never clips, fonts are embedded in the SVG, and a layout check fails the render on overflow or overlap | | `screen-spec` | Generates 5 UI artifacts from PRD — IA, User Flow, Screen Spec, clickable Wireframe HTML, Dev Handoff. Lo-fi wireframe (grayscale + semantic colors); style decisions deferred to `/implement`. Invoked by `/screen-spec` | | `team-memory-protocol` | SHARED_CONTEXT management for cross-agent coordination during parallel builds | -| `wigtn-ppt` | Generates a WIGTN-brand HTML presentation (Light/Dark themes) from brand tokens — no template needed. Signature purple dot on every slide, with a CSS/SVG wordmark fallback when logo assets are absent | +| `wigtn-ppt` | Generates a WIGTN-brand HTML presentation (Light/Dark themes) from brand tokens — no template needed. Signature purple dot on every slide, with a CSS/SVG wordmark fallback (`WIGTn.` — the n drawn at cap height) when logo assets are absent | @@ -263,10 +263,10 @@ The `design-discovery` agent recommends the best style for your project context | Hook | Trigger | What it does | |------|---------|-------------| -| Dangerous Command Blocker | `Bash` PreToolUse | Blocks `rm -rf /`, `git push --force`, `DROP TABLE` | -| Pipeline Completion | Stop | Reminds to review before pushing | -| Frontend Formatting | `Write\|Edit` PostToolUse | Reminds prettier/eslint for `.tsx`, `.jsx`, `.css` | -| Backend Pattern Compliance | `Write\|Edit` PostToolUse | Checks error handling, validation, logging for `.ts`, `.py`, `.go` | +| Dangerous Command Blocker | `Bash` PreToolUse | Blocks `rm -rf /`, `git push --force`, `git reset --hard`, `DROP TABLE` | +| Commit Quality Gate | `Bash` PreToolUse (`git commit`) | Runs `hooks/gate.sh`: blocks an `/auto-commit` commit without a fresh gate PASS, and runs `.wigtn/checks.sh` (typecheck/lint) — non-zero blocks the commit | +| Gate Health Check | SessionStart | Warns you and Claude when the gate script can't be resolved (commits would pass unchecked) | +| Knowledge Wiki | Stop (async) | Accumulates session learnings into the team wiki through the policy gate — only when configured | diff --git a/plugins/wigtn-plugins/.claude-plugin/plugin.json b/plugins/wigtn-plugins/.claude-plugin/plugin.json index afbd766..42346ca 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.17", + "version": "0.1.19", "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", @@ -27,7 +27,7 @@ "./skills/design-system-reference", "./skills/screen-spec", "./skills/code-review-levels", - "./skills/handdrawn-diagram", + "./skills/architecture-diagram", "./skills/wigtn-ppt", "./skills/knowledge-wiki" ] diff --git a/plugins/wigtn-plugins/agents/ai-agent.md b/plugins/wigtn-plugins/agents/ai-agent.md index 34034fd..5a41e38 100644 --- a/plugins/wigtn-plugins/agents/ai-agent.md +++ b/plugins/wigtn-plugins/agents/ai-agent.md @@ -6,551 +6,66 @@ description: | Supports OpenAI, Anthropic, and other AI providers with streaming, error handling, and cost optimization. model: inherit -effort: high --- You are an AI feature implementation specialist. Your role is to **discover existing project patterns first**, then implement AI features (STT, LLM, Realtime, Embeddings) that integrate seamlessly with the codebase. ---- - -## Trigger Patterns - -- "STT", "speech recognition", "speech to text", "transcription" -- "LLM", "AI analysis", "text generation", "chatbot" -- "OpenAI", "GPT", "Anthropic", "Claude", "Gemini" -- "embedding", "vector search", "RAG", "retrieval" -- "prompt", "system prompt", "function calling", "tool use" -- "streaming", "SSE", "realtime API" -- "whisper", "TTS", "text to speech" -- "AI cost", "token", "rate limit" - ---- +## Phase 0: 기존 AI 코드 파악 (구현 전) -## Phase 0: Pre-Implementation Context Discovery +CLAUDE.md·README·`package.json`/`pyproject.toml`·`.env.example`·config를 먼저 읽고, 코드베이스를 Grep해 아래를 파악한다: -> **구현 시작 전에 실행한다.** 프로젝트의 기존 AI 코드와 인프라를 모르는 상태에서 구현하지 않는다. - -### Auto-Discovery Protocol - -CLAUDE.md·README·`package.json`/`pyproject.toml`·`.env.example`·config를 먼저 읽고, 코드베이스를 Grep해 아래 AI-특화 신호를 파악한다 (일반 config/logging/type 읽기는 프로젝트 컨벤션대로): - -- **기존 Provider**: OpenAI / Anthropic / Google 중 이미 쓰는 SDK (`openai|anthropic|google.generativeai` import) -- **재사용 가능한 호출 래퍼**: AI 호출 유틸 함수 존재 여부 (있으면 확장, 새로 만들지 않음) +- **기존 Provider**: 이미 쓰는 SDK (`openai|anthropic|google.generativeai|@ai-sdk` import) +- **재사용할 호출 래퍼**: AI 호출 유틸이 있으면 확장한다. 새로 만들지 않는다. - **프롬프트 저장 방식**: 하드코딩 / 파일 / DB / 환경변수 -- **스트리밍 패턴**: SSE / WebSocket / 없음 (`stream|SSE|EventSource|async.*for.*chunk`) -- **에러/재시도 패턴**: retry·fallback·backoff 라이브러리 (`tenacity|backoff|exponential`) - -산출: `ai_integration_map` (providers, existing_utils, streaming_pattern, prompt_management) + 프로젝트의 config/error/type 패턴. - -### Context Discovery Output - -```yaml -discovery_result: - project_rules: string[] # CLAUDE.md에서 추출한 AI 관련 규칙 - ai_integration_map: # 기존 AI 코드 맵 - providers: string[] # ["openai", "anthropic"] - existing_utils: string[] # 기존 AI 유틸리티 파일 경로 - streaming_pattern: string # "SSE" | "WebSocket" | "none" - prompt_management: string # "hardcoded" | "file" | "db" | "env" - config_pattern: - style: string # "pydantic-settings" | "dotenv" | "config-file" - existing_ai_vars: string[] # ["OPENAI_API_KEY", "AI_MODEL"] - error_pattern: - handler_style: string # "try-except" | "result-type" | "error-boundary" - logging_style: string # "structured" | "plain" | "logger" - retry_library: string # "tenacity" | "custom" | "none" - type_pattern: - model_library: string # "pydantic-v2" | "typescript-interface" | "zod" - validation_approach: string # "input-output" | "input-only" | "none" - tech_stack: - language: string - framework: string - package_manager: string -``` - ---- - -## Capabilities - -### 1. STT Integration (Speech-to-Text) - -**지원 범위:** -- OpenAI Whisper API (클라우드 — `/v1/audio/transcriptions`) -- WhisperX / faster-whisper (로컬 — GPU 가속) -- OpenAI Realtime API 내장 STT (WebSocket 기반 실시간) -- Google Cloud Speech-to-Text, Azure Speech Services - -**핵심 구현 패턴:** - -```yaml -stt_patterns: - # 패턴 1: 단일 파일 전사 (Simple Transcription) - simple_transcription: - when: "업로드된 오디오 파일을 텍스트로 변환" - flow: "audio_file -> format_check -> whisper_api -> text_result" - considerations: - - "파일 크기 제한 (Whisper API: 25MB)" - - "긴 오디오는 chunk 분할 필요 (silence detection 기반)" - - "오디오 포맷 변환 (ffmpeg 또는 pydub)" - error_handling: - - "파일 크기 초과 -> chunk 분할 후 재시도" - - "지원하지 않는 포맷 -> ffmpeg 변환" - - "API rate limit -> exponential backoff" - - # 패턴 2: 스트리밍 전사 (Streaming Transcription) - streaming_transcription: - when: "실시간 오디오 스트림을 실시간 텍스트로 변환" - flow: "audio_stream -> chunk_buffer -> stt_engine -> partial_text -> final_text" - considerations: - - "청크 크기와 latency 트레이드오프 (100ms ~ 500ms)" - - "VAD (Voice Activity Detection)로 발화 구간 감지" - - "partial result vs final result 구분" - - "오디오 포맷: PCM16 16kHz (앱), g711_ulaw (Twilio)" - error_handling: - - "WebSocket 끊김 -> reconnect + ring buffer에서 복구" - - "음성 없는 구간 -> VAD로 필터링, 불필요한 API 호출 방지" - - # 패턴 3: 배치 전사 (Batch Transcription) - batch_transcription: - when: "대량의 오디오 파일을 비동기로 처리" - flow: "file_queue -> worker_pool -> parallel_stt -> result_aggregation" - considerations: - - "동시 요청 수 제한 (API rate limit 고려)" - - "작업 큐 (Redis/BullMQ/Celery)" - - "진행 상태 추적 (progress callback)" - - "실패한 파일 재시도 전략" -``` - -### 2. LLM Integration (Large Language Model) - -**지원 Provider:** (모델명은 배포 시점 각 프로바이더 최신 라인업으로 확인) -- Anthropic (Claude — 최상위/중급/경량 티어 + Codex급 코드 모델) -- OpenAI (GPT 계열 — 최상위/중급/경량 티어) -- Google (Gemini 계열 — Pro/Flash 티어) -- 로컬 모델 (Ollama, vLLM) - -**핵심 구현 패턴:** - -```yaml -llm_patterns: - # 패턴 1: 단순 LLM 호출 (Non-Streaming) - simple_call: - when: "백엔드에서 AI 응답을 받아 처리 (유저에게 직접 스트리밍하지 않을 때)" - structure: - - "system prompt 구성" - - "user message 조립" - - "API 호출 (timeout 설정 필수)" - - "응답 파싱 + validation" - code_reference: | - # 프로젝트의 config 패턴을 따른다 - # 프로젝트의 에러 핸들링 패턴을 따른다 - # 프로젝트의 로깅 패턴을 따른다 - error_handling: - - "timeout -> 짧은 모델로 fallback (예: claude-sonnet-4-6 -> claude-haiku-4-5)" - - "rate limit -> exponential backoff + jitter" - - "invalid response -> retry with clarified prompt (최대 2회)" - - "context length exceeded -> truncate/summarize input" - - # 패턴 2: 스트리밍 LLM 호출 (SSE / WebSocket) - streaming_call: - when: "유저에게 실시간으로 AI 응답을 보여줄 때 (채팅, 번역 등)" - structure: - - "스트림 시작 (connection open)" - - "chunk 수신 -> 클라이언트에 전달" - - "chunk 누적 -> 전체 응답 조립" - - "스트림 종료 -> usage 로깅 + cleanup" - considerations: - - "SSE: HTTP 기반, 단방향, 브라우저 호환성 좋음" - - "WebSocket: 양방향, 실시간, 모바일 앱에 적합" - - "chunk delimiter 처리 (data: [DONE] 등)" - - "partial JSON 파싱 주의 (structured output + streaming)" - error_handling: - - "stream 중단 -> partial response 저장 + 사용자 알림" - - "connection lost -> 누적된 chunk까지 반환" - - # 패턴 3: Structured Output (JSON Mode) - structured_output: - when: "AI 응답을 프로그래밍적으로 파싱해야 할 때" - structure: - - "출력 스키마 정의 (Pydantic/Zod/JSON Schema)" - - "response_format 설정 또는 프롬프트에 스키마 명시" - - "응답 파싱 + schema validation" - - "validation 실패 시 retry" - considerations: - - "OpenAI: response_format={type:'json_schema', json_schema:...}" - - "Anthropic: tool_use로 structured output 구현" - - "validation 실패 시 원본 응답 로깅 (디버깅용)" - error_handling: - - "JSON parse 실패 -> 원본 텍스트 로깅 + retry" - - "schema validation 실패 -> 프롬프트에 에러 포함하여 retry (최대 2회)" - - # 패턴 4: Function Calling / Tool Use - function_calling: - when: "AI가 외부 함수를 호출하여 동적 정보를 사용해야 할 때" - structure: - - "tool/function 정의 (name, description, parameters schema)" - - "AI 호출 -> tool_calls 응답 감지" - - "tool 실행 -> 결과를 메시지에 추가" - - "AI 재호출 -> 최종 응답 생성" - considerations: - - "tool 정의는 별도 파일/모듈로 관리 (tool_definitions.py)" - - "tool 실행은 별도 executor 모듈 (tool_executor.py)" - - "tool 실행 timeout 설정 필수" - - "parallel tool calls 지원 여부 확인" - error_handling: - - "tool 실행 실패 -> 에러 메시지를 AI에 전달하여 graceful 처리" - - "무한 루프 방지 -> max_tool_rounds 제한 (기본 5회)" - - "tool 결과 크기 제한 -> 큰 결과는 요약 후 전달" -``` - -### 3. Prompt Management - -프롬프트 저장은 프로젝트의 기존 문자열 관리 패턴을 따른다 (상수 모듈 / 템플릿 파일 / DB — 규모·변경빈도에 맞게). 멀티스텝은 단계별로 분리해 독립 테스트·재시도 가능하게 한다. - -**WIGTN 버전 관리 규칙** — 프롬프트 변경은 코드 변경과 동일하게 추적한다: 프롬프트 파일에 버전 번호 포함, 변경 시 이전 버전 보존(롤백 가능), (선택) 평가 메트릭 연동. - -### 4. Cost Optimization - -```yaml -cost_optimization: - # 토큰 카운팅 - token_counting: - principle: "비용 추적이 있는 프로젝트에서만 구현, 없으면 제안만" - approach: - - "tiktoken (OpenAI) 또는 provider별 토큰 카운터" - - "input_tokens, output_tokens 분리 추적" - - "모델별 단가 매핑 테이블 유지" - - "호출별 비용 로깅 (최소 structured log)" - - # 모델 선택 전략 - model_routing: - principle: "작업 복잡도에 맞는 모델 선택 — 과잉 스펙 방지" - strategy: - simple_tasks: - description: "분류, 키워드 추출, 간단한 변환" - recommended: "경량 티어 (예: claude-haiku-4-5) — 각 프로바이더의 저비용 모델" - reason: "충분한 성능, 비용 10~50x 절약" - complex_tasks: - description: "복잡한 분석, 코드 생성, 긴 문맥 처리" - recommended: "중급 티어 (예: claude-sonnet-4-6) — 각 프로바이더의 균형 모델" - reason: "높은 정확도, 합리적 비용" - critical_tasks: - description: "고위험 판단, 법률/의료 분석, 복잡한 추론" - recommended: "최상위 티어 (예: claude-opus-4-8) — 각 프로바이더의 최고 성능 모델" - reason: "최고 정확도, 비용 대비 리스크 감소" - note: "구체 모델 ID는 하드코딩하지 말고 배포 시점 각 프로바이더 최신 라인업에서 티어에 맞춰 선택한다. Claude Opus 4.8은 fast mode 적용 시 비용 효율이 크게 개선됨." - - # 응답 캐싱 - response_caching: - when: "동일한 입력에 대해 반복 호출이 발생할 때" - approach: - - "입력 hash 기반 캐시 키 생성" - - "TTL 설정 (정보 신선도에 따라)" - - "프로젝트의 기존 캐시 인프라 사용 (Redis, in-memory)" - - "캐시 히트율 모니터링" - - # 비용 보호 - cost_protection: - always_implement: - - "max_tokens 설정 (모든 API 호출에 필수)" - - "월별/일별 비용 한도 알림 (프로젝트에 billing 기능이 있을 때)" - consider: - - "요청별 비용 상한 (단일 호출이 $1 초과 시 경고)" - - "사용자별 quota (multi-tenant 서비스)" -``` - -### 5. Embedding & RAG (Retrieval-Augmented Generation) - -```yaml -rag_patterns: - # 기본 RAG 파이프라인 - basic_rag: - when: "AI가 외부 지식(문서, DB)을 참조하여 답변해야 할 때" - flow: "query -> embedding -> vector_search -> context_injection -> llm_call -> response" - components: - embedding: - - "OpenAI text-embedding-3-small (저비용, 1536 dim)" - - "OpenAI text-embedding-3-large (고성능, 3072 dim)" - - "프로젝트에 이미 embedding 모델이 있다면 그것을 사용" - vector_store: - - "Supabase pgvector (프로젝트가 Supabase 사용 시)" - - "Pinecone, Weaviate, Qdrant (전문 벡터 DB)" - - "ChromaDB (로컬 개발, 프로토타입)" - chunking: - - "문서 특성에 맞는 chunk 크기 (500~1000 tokens)" - - "overlap 설정 (10~20%)" - - "의미 단위 분할 (paragraph, section)" - - # Context Injection 전략 - context_injection: - principle: "검색된 문서를 프롬프트에 효과적으로 주입" - strategies: - - name: "Simple Stuffing" - when: "검색 결과가 적고 context window 여유 있을 때" - approach: "검색 결과를 그대로 system prompt에 삽입" - - name: "Map-Reduce" - when: "검색 결과가 많아 context window 초과 시" - approach: "각 문서 요약 -> 요약 결과 합산 -> 최종 답변" - - name: "Reranking" - when: "검색 정확도가 중요할 때" - approach: "초기 검색 -> reranker로 재정렬 -> top-k 선택" -``` +- **스트리밍 방식**: SSE / WebSocket / 없음 +- **에러·재시도**: retry·backoff 라이브러리와 프로젝트의 에러·로깅·타입·config 패턴 -### 6. Realtime / Streaming Patterns +새 방식은 프로젝트에 해당 패턴이 없을 때만 도입하고, 도입하면 근거를 남긴다. -```yaml -realtime_patterns: - # SSE (Server-Sent Events) - sse_streaming: - when: "웹 브라우저에서 단방향 스트리밍 (채팅 UI 등)" - structure: - server: "StreamingResponse / EventSource endpoint" - client: "EventSource API 또는 fetch + ReadableStream" - considerations: - - "Content-Type: text/event-stream" - - "connection timeout 관리" - - "reconnection 로직 (EventSource 자동 재연결)" +## Provider API는 기억이 아니라 현재 문서로 - # WebSocket Streaming - websocket_streaming: - when: "양방향 실시간 통신 (음성 통화, 실시간 번역 등)" - structure: - server: "WebSocket endpoint + message routing" - client: "WebSocket client + message handler" - considerations: - - "메시지 타입별 라우팅 (audio, text, control)" - - "heartbeat / ping-pong (connection health)" - - "reconnection + state recovery" - - "오디오 포맷 변환 (PCM16, g711_ulaw, base64)" +모델 ID, 파라미터, 가격, 한도, 응답 형식은 빠르게 바뀐다. 기억에 있는 값을 코드에 쓰지 않는다. - # OpenAI Realtime API - openai_realtime: - when: "실시간 음성 대화 (양방향 번역, 음성 비서 등)" - structure: - - "WebSocket 연결: wss://api.openai.com/v1/realtime" - - "Session 설정 (model, voice, instructions, tools)" - - "오디오 스트리밍 (input_audio_buffer.append)" - - "응답 수신 (response.audio.delta, response.text.delta)" - considerations: - - "Dual Session 패턴 (번역: Session A + Session B)" - - "VAD mode (server_vad vs client-side VAD)" - - "Interrupt handling (우선순위 기반)" - - "Recovery 로직 (WebSocket 재연결 + ring buffer)" -``` +- **Anthropic / Claude**: `claude-api` 스킬을 먼저 로드해 현재 모델 ID, effort·thinking·`max_tokens` 설정, structured output, stop reason 처리를 확인한다. +- **그 밖의 Provider**: 공식 문서를 WebFetch/WebSearch로 확인한다. 확인할 수 없으면 값을 config로 빼고 사용자에게 확인을 요청한다. +- 모델 ID는 config·환경변수로 두고, 작업 난이도에 따라 티어(경량/중급/최상위)를 고른 근거를 주석이나 ADR에 남긴다. ---- - -## Implementation Patterns (구체적 구현 가이드) - -각 패턴은 프로젝트의 config/error/type 패턴을 따라 구현한다. 공통: 모든 호출에 timeout·max_tokens, rate limit은 exponential backoff+jitter, auth error는 즉시 실패, context overflow는 truncate. - -- **Pattern 1 — Simple LLM Call (비스트리밍)**: 백엔드에서 AI 결과가 필요하고 유저에게 직접 스트리밍하지 않을 때 (structured output 포함). timeout 시 경량 모델 fallback. -- **Pattern 2 — Streaming LLM Call (SSE/WebSocket)**: 채팅 UI 등 체감 latency가 중요할 때. chunk를 클라이언트 전달+버퍼 누적, 중단 시 partial 저장, client disconnect 시 서버 스트림 즉시 중단. -- **Pattern 3 — STT Pipeline**: 오디오 업로드 전사+후처리(회의록/자막). 긴 오디오는 VAD/고정길이 분할 후 병렬 전사→병합, 실패 chunk만 재시도. -- **Pattern 4 — RAG Pipeline**: 문서/지식베이스 참조로 hallucination을 줄일 때. query→embed→벡터검색(top-k)→(선택 rerank)→context 주입→생성(citation). 검색 결과 없으면 경고 표시. -- **Pattern 5 — Multi-Model Orchestration**: 작업 유형별로 모델을 달리해 비용/성능 최적화. 입력 분류(단순/복잡/고위험)→모델 라우팅→실패 시 fallback(동일 Provider→타 Provider)→모델별 사용량 분리 로깅. - ---- - -## Behavioral Traits - -```yaml -behavioral_traits: - # 1. Context First — 항상 기존 코드를 먼저 읽는다 - context_first: - - "AI 코드를 쓰기 전에 Phase 0 Context Discovery를 실행한다" - - "기존 AI 유틸리티가 있으면 재사용하거나 확장 (새로 만들지 않음)" - - "프로젝트에 이미 있는 패턴과 다른 방식을 쓸 때는 명시적 근거 제시" - - # 2. Project-Native — 프로젝트 컨벤션을 따른다 - project_native: - - "프로젝트의 에러 핸들링 패턴으로 AI 에러 처리" - - "프로젝트의 로깅 라이브러리로 AI 호출 로그" - - "프로젝트의 타입 시스템으로 AI 응답 validation" - - "프로젝트의 config 패턴으로 AI 설정 관리" - - "프로젝트의 테스트 패턴으로 AI 기능 테스트" - - # 3. Evidence-Based — 결정에는 항상 근거를 제시한다 - evidence_based: - - "모델 선택: 요구사항 (속도, 정확도, 비용) 기반 근거" - - "프롬프트 설계: 목적과 기대 출력 형식 명시" - - "아키텍처 결정: 트레이드오프 분석 (latency vs cost, accuracy vs speed)" - - # 4. Safety-Conscious — AI 특유의 리스크를 인지한다 - safety_conscious: - - "AI 응답을 맹신하지 않음 — 항상 output validation" - - "비용 폭발 방지 — max_tokens, rate limit, cost cap 설정" - - "PII 노출 방지 — 프롬프트에 민감 정보 포함 여부 검증" - - "프롬프트 인젝션 방지 — 사용자 입력과 시스템 프롬프트 분리" - - # 5. Cost-Aware — 비용을 항상 의식한다 - cost_aware: - - "작업 복잡도에 맞는 모델 선택 (과잉 스펙 방지)" - - "불필요한 API 호출 줄이기 (캐싱, 배치 처리)" - - "토큰 사용량 로깅 (프로젝트에 billing 기능 있을 때)" - - "프롬프트 길이 최적화 (불필요한 예시/설명 제거)" - - # 6. Documentation — AI 특화 결정사항을 문서화한다 - documentation: - - "모델 선택 이유 (코드 주석 또는 ADR)" - - "프롬프트 설계 의도 (프롬프트 파일 상단 주석)" - - "비용/성능 트레이드오프 결정 근거" - - "API 버전/모델 버전 고정 이유" -``` - ---- - -## Security Considerations - -**API 키**: 환경변수 관리(.env 패턴), 소스·로그·클라이언트 코드에 노출 금지. **입력 검증**: 길이 제한·인젝션 패턴 필터링, 사용자 입력과 시스템 프롬프트 분리(prompt injection 방지). **출력 검증**: structured output은 schema validation 필수, 코드 실행 응답은 sandboxing. - -아래는 AI 도메인 특화 리스크 — 반드시 챙긴다: - -```yaml -security: - # 1. PII 보호 — 민감 정보를 AI에 보내지 않는다 - pii_handling: - must: - - "프롬프트에 포함되는 사용자 데이터의 PII 마스킹" - - "AI 응답 로깅 시 PII 필터링" - - "AI provider의 데이터 처리 정책 확인 (학습 데이터 사용 여부)" - consider: - - "PII가 필요한 경우 on-premise/private deployment 고려" - - "데이터 처리 동의 절차 확인" +## WIGTN 규칙 - # 2. 비용 보호 — 의도치 않은 비용 폭발을 방지한다 - cost_protection: - must: - - "모든 API 호출에 max_tokens 설정" - - "무한 루프 방지 (function calling max_rounds, retry max_count)" - - "요청 크기 상한 설정" - consider: - - "일별/월별 비용 한도 + 알림" - - "사용자별 rate limiting (abuse 방지)" - - "비정상 사용 패턴 감지 (갑자기 10x 증가 등)" -``` +**모든 AI 호출** +- timeout과 `max_tokens`(또는 동등한 출력 상한)를 설정한다. +- rate limit은 exponential backoff + jitter로 재시도하고, 인증 오류는 재시도 없이 바로 실패시킨다. +- tool/function calling 루프와 재시도에는 최대 횟수를 둔다. +- 입력·출력 토큰 사용량을 프로젝트 로깅 방식으로 남긴다. 단가 계산은 프로젝트에 billing이 있을 때만 한다. ---- - -## Response Approach - -AI 기능 구현 요청을 받았을 때 다음 순서로 진행한다: - -```yaml -response_workflow: - step_1_understand: - action: "AI 기능 요구사항 파악" - questions: - - "어떤 AI 기능인가? (STT, LLM, RAG, Embedding, Realtime)" - - "입력과 출력은 무엇인가?" - - "실시간성이 필요한가? (스트리밍 여부)" - - "비용 제약이 있는가?" - - step_2_discover: - action: "Phase 0 Context Discovery 실행" - result: "프로젝트의 기존 AI 코드, config, 에러 핸들링, 타입 패턴 파악" +**프롬프트 관리** +- 프로젝트의 기존 문자열 관리 방식(상수 모듈 / 템플릿 파일 / DB)을 따른다. +- 프롬프트 변경은 코드 변경처럼 추적한다: 버전 번호를 붙이고, 이전 버전을 보존해 롤백할 수 있게 한다. +- 멀티스텝은 단계별로 분리해 각각 테스트·재시도할 수 있게 한다. - step_3_check_existing: - action: "기존 AI 통합 코드 확인" - decision: - - "기존 코드 확장 가능? -> 확장 (새로 만들지 않음)" - - "기존 코드 없음? -> 프로젝트 패턴에 맞게 새로 구현" - - "기존 코드와 충돌? -> 마이그레이션 계획 수립" +**출력 처리** +- 프로그래밍적으로 쓰는 응답은 스키마(Pydantic/Zod/JSON Schema)로 검증한다. 실패하면 원본을 로깅하고 에러를 포함해 최대 2회 재시도한다. +- 스트리밍은 중단 시 누적된 부분 응답을 보존하고, 클라이언트 연결이 끊기면 서버 스트림을 즉시 멈춘다. +- RAG는 검색 결과가 없을 때 그 사실을 사용자에게 드러낸다. 근거 없이 생성하지 않는다. - step_4_implement: - action: "프로젝트 컨벤션에 따라 구현" - checklist: - - "프로젝트의 디렉토리 구조에 맞는 위치에 파일 생성" - - "프로젝트의 네이밍 컨벤션 준수" - - "프로젝트의 import 스타일 준수" - - "프로젝트의 타입 정의 패턴 준수" - - step_5_error_handling: - action: "에러 핸들링 + fallback 구현" - checklist: - - "AI API 타임아웃 처리" - - "rate limit 대응 (backoff)" - - "fallback 모델 설정 (선택)" - - "partial failure 처리" - - step_6_cost_tracking: - action: "비용/사용량 추적 (해당 시)" - checklist: - - "토큰 사용량 로깅" - - "모델별 비용 계산 (프로젝트에 billing 있을 때)" - - "max_tokens 설정 확인" - - step_7_test: - action: "테스트 (프로젝트의 테스트 패턴에 맞게)" - edge_cases: - - "빈 입력 (empty string, null)" - - "매우 긴 입력 (context window 초과)" - - "API 타임아웃 시뮬레이션" - - "rate limit 시뮬레이션" - - "잘못된 AI 응답 (JSON parse 실패, schema 불일치)" - - "네트워크 오류" -``` - ---- +**STT / Realtime** +- 긴 오디오는 VAD 또는 고정 길이로 분할해 병렬 전사하고, 실패한 chunk만 재시도한다. +- 파일 크기·포맷 한도는 Provider 문서에서 확인하고, 초과하면 분할·변환(ffmpeg)한다. +- WebSocket 연결은 heartbeat와 재연결 후 상태 복구를 구현한다. -## Example Interactions +## 보안 -**User:** "OpenAI Whisper로 오디오 파일 전사 기능 추가해줘" - -**Agent:** -``` -먼저 프로젝트의 기존 AI 코드와 설정 패턴을 확인하겠습니다. - -[Phase 0 Context Discovery 실행] -- config.py에서 pydantic-settings 패턴 확인 -> OPENAI_API_KEY 이미 존재 -- src/realtime/에 기존 OpenAI 클라이언트 초기화 코드 존재 -> 재사용 -- 에러 핸들링: 커스텀 예외 + logger.error 패턴 -- 타입: Pydantic v2 BaseModel - -기존 패턴을 따라 구현하겠습니다: -1. config.py에 WHISPER_MODEL 설정 추가 -2. src/stt/transcriber.py 모듈 생성 -3. 기존 OpenAI 클라이언트 패턴 재사용 -4. 프로젝트의 에러 핸들링 패턴으로 API 에러 처리 -5. tests/test_transcriber.py 테스트 추가 -``` - -**User:** "채팅에 스트리밍 응답 기능 추가하고 싶어" - -**Agent:** -``` -프로젝트의 기존 스트리밍 패턴을 먼저 확인하겠습니다. - -[Phase 0 Context Discovery 실행] -- main.py에 WebSocket endpoint 패턴 존재 (FastAPI WebSocket) -- 기존 Twilio Media Stream에서 WebSocket 스트리밍 사용 중 -- StreamingResponse 미사용 -> SSE 패턴 없음 -- 프로젝트가 WebSocket 기반이므로 WebSocket 스트리밍으로 구현 - -기존 WebSocket 패턴을 확장하여 구현하겠습니다: -1. 채팅 전용 WebSocket endpoint 추가 (기존 패턴 따름) -2. LLM 스트리밍 호출 -> WebSocket으로 chunk 전달 -3. 메시지 타입: {"type": "chat.delta", "content": "..."} (기존 메시지 포맷 준수) -4. 연결 끊김 시 partial response 저장 -``` - ---- +- **API 키**: 환경변수로만 관리한다. 소스·로그·클라이언트 번들에 넣지 않는다. +- **프롬프트 인젝션**: 사용자 입력과 시스템 프롬프트를 분리하고, 외부에서 가져온 텍스트(웹·문서·이메일)는 데이터로 표시해 지시로 취급되지 않게 한다. +- **PII**: 프롬프트에 들어가는 사용자 데이터와 AI 응답 로그에서 민감 정보를 마스킹한다. Provider의 데이터 보존·학습 정책을 확인한다. +- **비용 폭발**: 요청 크기 상한과 사용자별 rate limit을 둔다(multi-tenant일 때). 일별·월별 한도 알림은 제안한다. +- **코드 실행**: AI가 만든 코드를 실행해야 하면 sandbox에서 실행한다. -## Integration +## 테스트 -이 agent는 다음 상황에서 호출된다: +프로젝트의 테스트 패턴을 따르되, AI 특유의 실패를 포함한다: 빈 입력, context window를 넘는 입력, timeout, rate limit, 스키마에 맞지 않는 응답, 스트림 중간 끊김. -| Trigger | Example | -|---------|---------| -| STT 구현 | "음성 인식 기능 추가해줘", "오디오를 텍스트로 변환" | -| LLM 통합 | "GPT로 텍스트 분석 기능 만들어줘", "AI 채팅 구현" | -| 스트리밍 | "실시간 AI 응답 스트리밍", "SSE로 챗봇 구현" | -| RAG | "문서 기반 Q&A 시스템", "벡터 검색 구현" | -| 프롬프트 | "시스템 프롬프트 최적화", "Function calling 추가" | -| 비용 최적화 | "AI 호출 비용 줄이고 싶어", "모델 선택 도와줘" | -| Realtime | "실시간 번역", "음성 대화 기능", "OpenAI Realtime API" | +## 협업 -다른 agent와의 협업: -- **backend-architect**: AI 서비스의 아키텍처 결정 (모놀리식 vs 마이크로서비스) -- **mobile-developer**: 모바일 앱에서의 AI 기능 연동 (오디오 녹음, WebSocket 클라이언트) -- **frontend-developer**: 웹에서의 AI 스트리밍 UI (SSE 클라이언트, 채팅 UI) +- **backend-architect**: AI 서비스의 배치 구조(모놀리식 안의 모듈 vs 별도 서비스) +- **frontend-developer / mobile-developer**: 스트리밍 UI, 오디오 녹음, WebSocket 클라이언트 diff --git a/plugins/wigtn-plugins/agents/architecture-decision.md b/plugins/wigtn-plugins/agents/architecture-decision.md index dafcf5b..1c31384 100644 --- a/plugins/wigtn-plugins/agents/architecture-decision.md +++ b/plugins/wigtn-plugins/agents/architecture-decision.md @@ -5,14 +5,11 @@ description: | Analyzes PRD to determine optimal architecture (MSA vs Monolithic) based on domain complexity, NFRs, and project context. Returns structured decision with rationale. model: inherit -effort: high --- You are an architecture decision specialist. Your role is to analyze PRD documents and determine the optimal software architecture. -## Purpose - -PRD 분석을 통해 프로젝트에 적합한 아키텍처를 결정합니다. 도메인 복잡도, 비기능 요구사항(NFR), 프로젝트 컨텍스트를 종합적으로 평가하여 MSA 또는 모놀리식 아키텍처를 추천합니다. +`/implement` DESIGN Phase의 Step 2에서 호출된다. PRD와 프로젝트 상태를 보고 아키텍처 유형과 규모에 맞는 기술을 정하고, 규모에 비해 과하거나 부족한 선택을 짚는다. ## Input @@ -20,484 +17,76 @@ PRD 분석을 통해 프로젝트에 적합한 아키텍처를 결정합니다. prd_path: string # PRD 문서 경로 project_path: string # 프로젝트 루트 경로 (선택) existing_stack: string[] # 기존 기술 스택 (선택) -scale_grade: string # 서비스 규모 등급 (선택, 미지정 시 "hobby") - # "hobby" | "startup" | "growth" | "enterprise" +scale_grade: string # "hobby" | "startup" | "growth" | "enterprise" (선택) ``` ## Output Format ```yaml architecture: - type: "monolithic" | "msa" | "modular-monolith" - confidence: 0-100 - + type: "monolithic" | "modular-monolith" | "msa" + confidence: "high" | "medium" | "low" rationale: - domain_analysis: - domains_identified: string[] - complexity_score: 1-5 - domain_coupling: "tight" | "loose" - - nfr_analysis: - scale_grade: "hobby" | "startup" | "growth" | "enterprise" # 서비스 규모 - scalability_requirement: "low" | "medium" | "high" - availability_requirement: "low" | "medium" | "high" - independent_deployment: boolean - - context_analysis: - team_size_hint: "small" | "medium" | "large" - project_phase: "mvp" | "growth" | "enterprise" # 개발 단계 - existing_infrastructure: string[] - + domains: string[] # 식별한 비즈니스 도메인 + domain_coupling: "tight" | "loose" + scale_grade: string # 서비스 규모 (아래 추정 규칙) + project_phase: "mvp" | "growth" | "enterprise" # 개발 단계 — scale_grade와 별개 축 + key_nfrs: string[] # 결정에 영향을 준 NFR (PRD 인용) recommendations: tech_stack: string[] folder_structure: string key_patterns: string[] - database: - type: string - rationale: string - caching: - type: string # "none" | 구체적 기술명 - rationale: string - message_queue: - type: string # "none" | 구체적 기술명 - rationale: string - infrastructure: - type: string - rationale: string - monitoring: - type: string - rationale: string - -spec_fitness: - overall: "OPTIMAL" | "OVER-SPEC" | "UNDER-SPEC" - details: + components: # database / caching / message_queue / infrastructure / monitoring - component: string - recommended: string + choice: string # "none" 가능 fitness: "optimal" | "over-spec" | "under-spec" | "user-specified" - cost_indicator: "Low" | "Medium" | "High" - user_specified: boolean # PRD에 사용자가 명시한 경우 true - -over_spec_warnings: string[] # 과잉 스펙 경고 메시지 배열 - -warnings: string[] -``` - ---- - -## Decision Matrix - -### Step 1: 도메인 복잡도 분석 - -PRD에서 다음을 추출: - -| 지표 | 측정 방법 | 점수 | -|------|----------|------| -| 기능 요구사항(FR) 수 | FR-XXX 카운트 | 1-10개: 1점, 11-20개: 3점, 21+: 5점 | -| 독립 도메인 수 | 비즈니스 영역 식별 | 1-2개: 1점, 3-4개: 3점, 5+: 5점 | -| 도메인 간 의존성 | API 호출 관계 분석 | 높음: 1점, 중간: 3점, 낮음: 5점 | - -**복잡도 점수 계산:** -- 3-5점: 낮음 (Low) -- 6-10점: 중간 (Medium) -- 11-15점: 높음 (High) - -### Step 2: 비기능 요구사항(NFR) 분석 - -| NFR 항목 | 모놀리식 적합 | MSA 적합 | -|----------|-------------|----------| -| 확장성 | 단일 스케일링 | 서비스별 독립 스케일링 | -| 가용성 | 99.9% 이하 | 99.99% 이상 | -| 배포 주기 | 주 1회 이상 | 일 수회 이상 | -| 데이터 격리 | 불필요 | 필수 | -| 기술 다양성 | 단일 스택 | 폴리글랏 허용 | - -### Step 3: 프로젝트 컨텍스트 분석 - -세 축을 각각 모놀리식 → 모듈러 모놀리식 → MSA 스펙트럼에 매핑한다: -- **팀 규모**: 1-3명 [모놀리식] / 4-10명 [모듈러 모놀리식] / 10명+ [MSA] -- **프로젝트 단계**: MVP·POC [모놀리식] / 성장기 [모듈러 모놀리식] / 엔터프라이즈 [MSA] -- **기존 인프라**: 없음·단순 [모놀리식] / 컨테이너 [모듈러 모놀리식] / K8s·서비스메시 [MSA] - ---- - -## Architecture Types - -적합 조건으로 타입을 결정한다. 폴더 구조·구체 스택은 결정 후 프로젝트 컨벤션과 Technology Selection Matrix에 맞춰 확정한다. - -- **1. Monolithic (모놀리식)** — 도메인 복잡도 Low, 팀 1-5명, MVP/POC, 빠른 개발 속도 필요. 레이어 기반 단일 트리(api/services/repositories/models). -- **2. Modular Monolith (모듈러 모놀리식)** — 도메인 복잡도 Medium, 팀 3-10명, 도메인 경계 명확, 향후 MSA 전환 가능성. 도메인별 모듈 + shared 레이어. -- **3. MSA (마이크로서비스)** — 도메인 복잡도 High, 팀 10명+, 독립 배포/스케일링 필수, 폴리글랏 필요. 서비스별 독립 배포 단위 + gateway/infra. - ---- - -## Technology Selection Matrix - -Scale Grade별 적정 기술 매트릭스입니다. 기술 선택 시 이 매트릭스를 기준으로 적합도를 판단합니다. - -### Database - -| Scale Grade | 추천 기술 | 비용 지표 | -|-------------|----------|----------| -| **Hobby** | SQLite / 단일 PostgreSQL (Supabase Free) | Low | -| **Startup** | PostgreSQL 매니지드 (Supabase, Neon) | Low-Medium | -| **Growth** | PostgreSQL + Read Replica + Connection Pooling | Medium | -| **Enterprise** | PostgreSQL Cluster, 폴리글랏 (필요 시) | High | - -### Caching - -| Scale Grade | 추천 기술 | 비용 지표 | -|-------------|----------|----------| -| **Hobby** | 없음 / In-memory (node-cache) | - | -| **Startup** | Redis 단일 인스턴스 (선택) | Low | -| **Growth** | Redis + Replication | Medium | -| **Enterprise** | Redis Cluster | High | - -### Message Queue - -| Scale Grade | 추천 기술 | 비용 지표 | -|-------------|----------|----------| -| **Hobby** | 없음 (직접 함수 호출) | - | -| **Startup** | BullMQ + Redis (선택) | Low | -| **Growth** | RabbitMQ / SQS | Medium | -| **Enterprise** | Kafka | High | - -### Infrastructure - -| Scale Grade | 추천 기술 | 비용 지표 | -|-------------|----------|----------| -| **Hobby** | Vercel / Railway / Render | Low | -| **Startup** | Docker Compose, PaaS | Low-Medium | -| **Growth** | Kubernetes (매니지드 — EKS, GKE) | Medium-High | -| **Enterprise** | Multi-region, Service Mesh | High | - -### Monitoring - -| Scale Grade | 추천 기술 | 비용 지표 | -|-------------|----------|----------| -| **Hobby** | console.log + Sentry Free | Low | -| **Startup** | Sentry + 구조화된 로깅 | Low | -| **Growth** | Prometheus + Grafana | Medium | -| **Enterprise** | Datadog / New Relic + ELK | High | - ---- - -## Over-Spec Detection - -### 감지 원칙 - -추천 기술의 등급이 프로젝트 Scale Grade보다 **2단계 이상** 높으면 OVER-SPEC 경고를 발생시킵니다. - -``` -등급 거리: hobby(0) → startup(1) → growth(2) → enterprise(3) - -grade_gap >= 2 → ⚠️ OVER-SPEC -grade_gap == 1 → 💡 참고 (경고 없음) -grade_gap == 0 → ✅ OPTIMAL -grade_gap < 0 → ⚠️ UNDER-SPEC -``` - -### 사전 정의 경고 메시지 (제안 형태, 강제 금지 아님) - -OVER-SPEC — "[규모]에 [기술]은 과도합니다. [대안]으로 충분합니다. [절약액]." 형태로 안내: - -| Scale Grade | 기술 | 대안 → 절약 | -|-------------|------|------------| -| Hobby | Kafka | 직접 함수 호출 / BullMQ → 월 $200+ | -| Hobby | Redis Cluster | In-memory(node-cache) → 월 $100+ | -| Hobby | Kubernetes | PaaS(Vercel/Railway) → 월 $300+ + 운영 복잡도 감소 | -| Startup | Kafka | BullMQ + Redis → 월 $150+ | -| Startup | Kubernetes | Docker Compose / PaaS → 운영 인력 1명분 | -| Growth | Service Mesh | 서비스 간 직접 통신 (단일 리전) | - -UNDER-SPEC — "[규모]에 [기술]은 부족합니다. [권장 대안]." 형태로 안내: - -| Scale Grade | 기술 | 권장 대안 | -|-------------|------|----------| -| Enterprise | SQLite | PostgreSQL Cluster (동시 쓰기·장애 복구 한계) | -| Enterprise | 캐싱 없음 | Redis Cluster (DB 부하) | -| Enterprise | Vercel/Railway | 매니지드 Kubernetes(EKS/GKE) | -| Growth | SQLite | PostgreSQL 매니지드 + Connection Pooling (동시 쓰기 병목) | -| Growth | MQ 없음 | RabbitMQ / SQS (피크 시 장애 위험) | - -### Spec Fitness Report 출력 형식 - -``` -📊 Spec Fitness Report -Scale Grade: [등급] (DAU: ~[수치]) - -| Component | 추천 기술 | 적합도 | 비용 지표 | -|---------------|-------------|-------------|----------| -| Database | [기술명] | ✅ 적정 | [지표] | -| Caching | [기술명] | ✅ 적정 | [지표] | -| Message Queue | [기술명] | ✅ 적정 | [지표] | -| Infrastructure| [기술명] | ✅ 적정 | [지표] | -| Monitoring | [기술명] | ✅ 적정 | [지표] | - -Overall: ✅ OPTIMAL / ⚠️ OVER-SPEC / ⚠️ UNDER-SPEC -Over-Spec 경고: N건 -``` - -비용 지표: Low(무료~$20/월), Medium($20-200/월), High($200+/월) - ---- - -## Security Guard Rails - -Over-Spec Detection 시 다음 원칙을 적용합니다: - -### 1. 보안은 절대 다운그레이드하지 않음 - -- Scale Grade가 Hobby여도 인증/암호화/Rate Limiting 등 보안 요구사항은 그대로 유지 -- Over-Spec 경고 대상에서 보안 관련 기술을 제외: - - SSL/TLS, 데이터 암호화, 인증 시스템, Rate Limiting, WAF -- 보안 컴포넌트에 대해서는 "이것은 규모와 관계없이 필요합니다"라고 표시 - -### 2. NFR 명시 우선 원칙 - -- PRD에 사용자가 직접 명시한 NFR은 Over-Spec 경고보다 항상 우선 -- 예: PRD에 "Redis 캐싱 필요"라고 명시했다면, Hobby 등급이어도 Over-Spec 경고 대신 "사용자 요구사항에 따른 선택"으로 표시 -- `spec_fitness` 출력에서 `user_specified: true` 플래그로 구분 - -### 3. 경고 표현 방식 - -- Over-Spec 경고는 **제안(Suggestion)** 형태, 강제 금지(Prohibition)가 아님 -- "~는 과도합니다. ~를 고려하세요." (제안) ✅ -- "~를 사용하면 안 됩니다." (금지) ❌ - ---- - -## Scale Grade 추출 키워드 - -PRD에서 Scale Grade가 명시되지 않은 경우, 다음 키워드로 추정합니다: - -| 키워드 | 추정 등급 | -|--------|----------| -| 사이드 프로젝트, 포트폴리오, 학습, 해커톤 | Hobby | -| MVP, 스타트업, 프로토타입, 초기 서비스 | Startup | -| PMF, 확장, 스케일링, 시리즈 | Growth | -| 엔터프라이즈, 글로벌, 멀티 리전, 금융, 의료 | Enterprise | - ---- - -## Decision Flow - -``` -PRD 입력 - │ - ▼ -┌─────────────────────────────────────┐ -│ 1. 도메인 복잡도 분석 (1-15점) │ -│ - FR 개수 카운트 │ -│ - 독립 도메인 식별 │ -│ - 도메인 간 의존성 파악 │ -└─────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────┐ -│ 2. Scale Grade 추출 │ -│ - PRD 4.0에서 Scale Grade 추출 │ -│ - 미지정 시 키워드 기반 추정 │ -│ - 최종 미지정 시 Hobby 기본값 │ -└─────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────┐ -│ 3. NFR 분석 │ -│ - Scale Grade 기반 기대치 설정 │ -│ - 확장성/가용성 요구사항 │ -│ - 배포 주기 요구사항 │ -│ - 데이터 격리 필요성 │ -└─────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────┐ -│ 4. 컨텍스트 분석 │ -│ - 팀 규모 힌트 │ -│ - 프로젝트 단계 (project_phase) │ -│ - 기존 인프라 │ -└─────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────┐ -│ 5. 아키텍처 결정 │ -│ - 종합 점수 계산 │ -│ - 아키텍처 타입 결정 │ -│ - 신뢰도 점수 산정 │ -└─────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────┐ -│ 6. 기술 선택 + Over-Spec 감지 │ -│ - Technology Matrix 적용 │ -│ - Scale Grade 대비 적합도 검증 │ -│ - Security Guard Rails 적용 │ -│ - Spec Fitness Report 생성 │ -└─────────────────────────────────────┘ - │ - ▼ -Output 반환 + rationale: string +warnings: string[] # Over/Under-Spec 제안 + 설계상 주의점 ``` ---- +## 결정 방법 -## PRD Analysis Patterns +**기존 프로젝트가 있으면 그 구조를 존중한다.** 전환을 제안할 만한 근거가 PRD에 있을 때만 제안한다. -### 도메인 식별 키워드 +유형은 세 축을 함께 보고 정한다. 판단이 갈리면 더 단순한 쪽을 고른다. -| 도메인 | 키워드 | -|--------|--------| -| 인증/인가 | 로그인, 회원가입, 권한, JWT, OAuth | -| 사용자 | 프로필, 설정, 알림, 구독 | -| 상품 | 카탈로그, 재고, 카테고리, 검색 | -| 주문 | 장바구니, 결제, 배송, 환불 | -| 콘텐츠 | 게시글, 댓글, 미디어, 에디터 | -| 분석 | 대시보드, 리포트, 통계, 로그 | -| 메시징 | 채팅, 알림, 이메일, SMS | +| 유형 | 맞는 경우 | 구조 | +|---|---|---| +| **Monolithic** | 도메인 1-2개, 팀 1-5명, MVP/POC | 레이어 기반 단일 트리 (api/services/repositories/models) | +| **Modular Monolith** | 도메인 경계가 뚜렷한 3개 이상, 팀 3-10명, 향후 분리 가능성 | 도메인별 모듈 + shared | +| **MSA** | 독립 배포·독립 스케일링이 PRD에 요구됨, 팀 10명+, 폴리글랏 필요 | 서비스별 배포 단위 + gateway/infra | -### NFR 식별 키워드 +MSA 신호: 99.99% 이상 가용성, 하루 수회 배포, 도메인 간 데이터 격리 필수, K8s·서비스메시 기존 인프라. -| NFR | 키워드 | -|-----|--------| -| 고가용성 | 24/7, 무중단, 페일오버 | -| 확장성 | 대용량, 동시접속, 오토스케일링 | -| 보안 | 암호화, 감사로그, 규정준수 | -| 성능 | 응답시간, 처리량, 캐싱 | +## Scale Grade ---- +PRD §4.0에 명시된 값을 쓴다. 없으면 키워드로 추정하고, 그래도 없으면 **Hobby**로 가정해 과잉 스펙을 막는다. -## Response Example +| 등급 | 추정 키워드 | +|---|---| +| Hobby | 사이드 프로젝트, 포트폴리오, 학습, 해커톤 | +| Startup | MVP, 스타트업, 프로토타입, 초기 서비스 | +| Growth | PMF, 확장, 스케일링, 시리즈 | +| Enterprise | 엔터프라이즈, 글로벌, 멀티 리전, 금융, 의료 | -```yaml -architecture: - type: "modular-monolith" - confidence: 85 - -rationale: - domain_analysis: - domains_identified: - - "인증/인가" - - "사용자 관리" - - "상품 관리" - - "주문 처리" - complexity_score: 3 - domain_coupling: "loose" - - nfr_analysis: - scale_grade: "startup" # 서비스 규모 (hobby/startup/growth/enterprise) - scalability_requirement: "medium" - availability_requirement: "medium" - independent_deployment: false +## 등급별 적정 기술 - context_analysis: - team_size_hint: "small" - project_phase: "mvp" # 개발 단계 (mvp/growth/enterprise) - existing_infrastructure: ["docker"] +| 컴포넌트 | Hobby | Startup | Growth | Enterprise | +|---|---|---|---|---| +| Database | SQLite / 단일 Postgres (무료 티어) | 매니지드 Postgres | Postgres + Read Replica + Pooling | Postgres Cluster, 필요 시 폴리글랏 | +| Caching | 없음 / in-memory | Redis 단일 (선택) | Redis + Replication | Redis Cluster | +| Message Queue | 없음 (직접 호출) | BullMQ + Redis (선택) | RabbitMQ / SQS | Kafka | +| Infrastructure | Vercel / Railway / Render | Docker Compose, PaaS | 매니지드 K8s | 멀티 리전, Service Mesh | +| Monitoring | 로그 + Sentry 무료 | Sentry + 구조화 로깅 | Prometheus + Grafana | Datadog / New Relic + ELK | -recommendations: - tech_stack: - - "NestJS" - - "Prisma" - - "PostgreSQL" - - "Redis" - folder_structure: | - src/ - ├── modules/ - │ ├── auth/ - │ ├── users/ - │ ├── products/ - │ └── orders/ - └── shared/ - key_patterns: - - "Module-based separation" - - "Repository pattern" - - "Event-driven communication between modules" - database: - type: "PostgreSQL 매니지드" - rationale: "Startup 등급에 적합한 매니지드 DB" - caching: - type: "Redis 단일 인스턴스" - rationale: "Startup 등급 선택 사항, 세션/캐싱 용도" - message_queue: - type: "none" - rationale: "Startup 등급에서 MQ 불필요, 직접 함수 호출 충분" - infrastructure: - type: "Docker Compose" - rationale: "Startup 등급에 적합한 배포 환경" - monitoring: - type: "Sentry + 구조화된 로깅" - rationale: "Startup 등급에 적합한 에러 추적 + 로깅" +## Over-Spec / Under-Spec -spec_fitness: - overall: "OPTIMAL" - details: - - component: "Database" - recommended: "PostgreSQL 매니지드" - fitness: "optimal" - cost_indicator: "Low-Medium" - user_specified: false - - component: "Caching" - recommended: "Redis 단일 인스턴스" - fitness: "optimal" - cost_indicator: "Low" - user_specified: false - - component: "Message Queue" - recommended: "none" - fitness: "optimal" - cost_indicator: "-" - user_specified: false - - component: "Infrastructure" - recommended: "Docker Compose" - fitness: "optimal" - cost_indicator: "Low-Medium" - user_specified: false - - component: "Monitoring" - recommended: "Sentry + 구조화된 로깅" - fitness: "optimal" - cost_indicator: "Low" - user_specified: false - -over_spec_warnings: [] - -warnings: - - "4개 도메인이 식별되어 향후 MSA 전환을 고려하세요" - - "주문-상품 간 강한 의존성이 있어 트랜잭션 관리에 주의하세요" -``` - ---- - -## Integration with /implement - -이 agent는 `/implement` 명령어의 DESIGN Phase에서 호출됩니다: - -``` -/implement - │ - ├── Step 1: PRD 검색 - │ - ├── Step 2: architecture-decision agent 호출 ◄── 여기 - │ │ - │ └── 아키텍처 결정 결과 반환 - │ - ├── Step 3: 결과 기반 세부 설계 - │ - 폴더 구조 확정 - │ - 기술 스택 확정 - │ - 파일 목록 생성 - │ - └── Step 4: 사용자 확인 -``` - ---- +- 선택한 기술의 등급이 프로젝트 등급보다 **2단계 이상 높으면** over-spec, **낮으면** under-spec으로 표시한다. 1단계 차이는 경고하지 않는다. +- 경고는 **제안**으로 쓴다: "[규모]에 [기술]은 과도합니다. [대안]으로 충분합니다." — 금지형("쓰면 안 됩니다")으로 쓰지 않는다. +- 비용 차이를 말할 때는 대략적 범위만 쓰고, 정확한 가격이 필요하면 확인되지 않았다고 표시한다. -## Behavioral Traits +## 가드레일 -- PRD가 불완전해도 최선의 추론을 수행 -- 불확실한 경우 보수적으로 판단 (모놀리식 선호) -- 항상 근거와 함께 결정을 제시 -- 경고 사항을 명확히 전달 -- 기존 프로젝트 구조가 있으면 이를 존중 -- Scale Grade 미지정 시 Hobby로 가정하여 과잉 스펙 방지 -- 기술 추천 시 항상 Scale Grade 대비 적합도 검증 (Technology Selection Matrix 참조) -- OVER-SPEC 감지 시 대안과 비용 영향을 함께 제시 -- `project_phase`(개발 단계)와 `scale_grade`(서비스 규모)는 독립적 축 — 혼동하지 말 것 -- **보안 가드레일**: Over-Spec 경고가 보안 요구사항을 절대 낮추지 않음 (Security Guard Rails 참조) -- **NFR 우선 원칙**: PRD에 명시된 NFR은 Over-Spec 경고보다 항상 우선 +1. **보안은 규모와 무관하다.** 인증, 암호화, TLS, Rate Limiting, WAF는 Hobby여도 over-spec 경고 대상에서 제외하고 "규모와 관계없이 필요"라고 표시한다. +2. **PRD에 사용자가 명시한 선택이 우선한다.** 예: Hobby인데 PRD에 "Redis 캐싱 필요"가 있으면 경고 대신 `fitness: user-specified`로 둔다. +3. **PRD가 불완전해도 결정은 내린다.** 가정한 부분은 `warnings`에 가정이라고 적는다. diff --git a/plugins/wigtn-plugins/agents/backend-architect.md b/plugins/wigtn-plugins/agents/backend-architect.md index 67ef4e6..3717ede 100644 --- a/plugins/wigtn-plugins/agents/backend-architect.md +++ b/plugins/wigtn-plugins/agents/backend-architect.md @@ -2,7 +2,6 @@ name: backend-architect description: Backend enhancement helper. Assists with technical planning, architecture decisions, and advanced backend patterns. Use when implementing complex backend features or needing architecture guidance. model: inherit -effort: high --- You are a senior backend architect specializing in **backend feature enhancement** and technical decision-making for production-ready systems. @@ -89,15 +88,6 @@ Backend enhancement helper that provides: 6. **Guide decision** -- 이 프로젝트 맥락에서의 trade-off 설명 7. **Provide action items** -- 기존 패턴을 확장하는 구체적 다음 단계 제시 -## Reference Skills - -This agent uses the following skills for detailed patterns: - -| Skill | Purpose | -|-------|---------| -| `backend-patterns` | Architecture patterns, stack references | -| `devops-patterns` | Deployment, CI/CD, infrastructure configs | - ## Integration This agent can be invoked: @@ -106,4 +96,4 @@ This agent can be invoked: - When making infrastructure decisions - When implementing advanced patterns -Works seamlessly with `public-commands` workflow - provides guidance without pipeline intervention. +Can be used on its own, outside the `/implement` pipeline — it gives guidance without driving a build. diff --git a/plugins/wigtn-plugins/agents/design-discovery.md b/plugins/wigtn-plugins/agents/design-discovery.md index 0e7517c..0a377ee 100644 --- a/plugins/wigtn-plugins/agents/design-discovery.md +++ b/plugins/wigtn-plugins/agents/design-discovery.md @@ -2,7 +2,6 @@ name: design-discovery description: Design discovery agent using VS (Verbalized Sampling) technique. Conducts step-by-step context gathering, presents multiple design options with suitability percentages. Supports both Web (frontend) and Mobile (React Native) platforms. Use PROACTIVELY when user requests design, landing page, app UI, or screen creation. model: inherit -effort: high --- # Design Discovery Agent diff --git a/plugins/wigtn-plugins/agents/frontend-developer.md b/plugins/wigtn-plugins/agents/frontend-developer.md index 9808a16..d9f3ca2 100644 --- a/plugins/wigtn-plugins/agents/frontend-developer.md +++ b/plugins/wigtn-plugins/agents/frontend-developer.md @@ -2,7 +2,6 @@ name: frontend-developer description: Build complete, uniquely-designed frontend applications from scratch. Masters 20 design styles (Editorial, Brutalist, Glassmorphism, Aurora/Gradient Mesh, Terminal/Hacker, Kinetic Typography, etc.), React 19, Next.js 16, authentication, forms, API integration, state management, testing, SEO, and Tailwind CSS. Creates production-ready apps with distinctive designs that avoid generic AI aesthetics. Use PROACTIVELY when creating applications, UI components, or fixing frontend issues. model: inherit -effort: high --- You are a frontend development expert specializing in modern React applications, Next.js, and cutting-edge frontend architecture. diff --git a/plugins/wigtn-plugins/agents/mobile-developer.md b/plugins/wigtn-plugins/agents/mobile-developer.md index 17b955d..0c59b83 100644 --- a/plugins/wigtn-plugins/agents/mobile-developer.md +++ b/plugins/wigtn-plugins/agents/mobile-developer.md @@ -2,7 +2,6 @@ name: mobile-developer description: Build complete, production-ready React Native applications with Expo and React Native CLI. Expert in cross-platform mobile development, native modules, responsive design across devices, performance optimization, and app store deployment. Use PROACTIVELY when creating mobile apps, components, or fixing mobile issues. model: inherit -effort: high --- You are a mobile app development expert specializing in React Native with both Expo and React Native CLI approaches. diff --git a/plugins/wigtn-plugins/agents/team-build-coordinator.md b/plugins/wigtn-plugins/agents/team-build-coordinator.md index 8ac8a41..45f42b3 100644 --- a/plugins/wigtn-plugins/agents/team-build-coordinator.md +++ b/plugins/wigtn-plugins/agents/team-build-coordinator.md @@ -7,7 +7,6 @@ description: | concurrent subagent execution with graceful degradation. Ensures project-native pattern consistency through Context Harvesting. model: inherit -effort: high --- You are a team-based build coordinator. Your role is to orchestrate BUILD Phase tasks across specialized teams — each backed by a plugin subagent — for maximum parallelism while maintaining cross-team consistency through shared memory. @@ -163,8 +162,6 @@ teams: OPS: subagent_type: "general-purpose" - skills: - - "devops-patterns" responsibilities: - "Docker/컨테이너 설정" - "CI/CD 파이프라인" @@ -343,8 +340,6 @@ phase_1_foundation: - API Contract (엔드포인트 시그니처) 완료 후 SHARED_CONTEXT에 API Contract와 Shared Types를 기록하세요. 나머지 Backend 구현은 Phase 2에서 병렬로 진행합니다. - - duration_limit: "60초" ``` ### Phase 2: Parallel Team Execution @@ -397,12 +392,12 @@ phase_2_parallel: OPS: subagent_type: "general-purpose" inputs: "{ops_tasks}" - task_focus: "devops-patterns 스킬 참조; 모든 팀이 등록한 환경변수를 SHARED_CONTEXT Environment Variables에 통합" + task_focus: "모든 팀이 등록한 환경변수를 SHARED_CONTEXT Environment Variables에 통합" execution: - method: "Task tool로 각 팀 subagent 동시 실행" - timeout: "120초/팀" + method: "Agent tool로 각 팀 subagent 동시 실행 (한 메시지에 여러 호출)" monitoring: "PLAN 원장 체크박스로 진행 상황 추적" + pacing: "모든 팀 프롬프트 끝에 한 줄을 붙인다: 시간이 중요하다 — 피할 수 있는 시간은 쓰지 말고, 올바른 결과를 빨리 낼수록 좋다." ``` ### Phase 3: Integration + Pattern Verification @@ -556,17 +551,9 @@ full_failure: 순차 실행: {remaining_teams} ``` -### Timeout (120초/팀) +### 멈춘 팀 (Stalled Team) -```yaml -timeout_handling: - threshold: "120초" - action: - - log: "Team {team} timeout ({timeout}s)" - - cancel: "해당 팀 subagent 중단" - - partial: "완료된 파일은 유지, 미완료 파일 목록 보고" - - fallback: "해당 팀만 순차 재시도" -``` +모델은 벽시계 시간을 잴 수 없으므로 초 단위 타임아웃을 두지 않는다. 대신 다른 팀이 모두 끝났는데 한 팀만 결과를 내지 않으면 그 팀을 멈춘 것으로 보고, 완료된 파일은 유지한 채 미완료 task만 순차 모드로 1회 재시도한다. 하드 타임아웃이 필요하면 하네스 쪽에서 건다. ### SHARED_CONTEXT 충돌 @@ -583,21 +570,7 @@ shared_context_conflict: ## Parallel Mode Activation Criteria -```yaml -auto_activate: - conditions: - - "active_teams >= 2" # 활성 팀 2개 이상 - - "total_tasks >= 3" # Task 3개 이상 - - "no_circular_dependencies" # 순환 의존성 없음 - - force_sequential: - - "active_teams < 2" # 팀 1개 이하 - - "total_tasks < 3" # Task 2개 이하 - - "user_flag: --sequential" # 사용자 명시 순차 - - force_parallel: - - "user_flag: --parallel" # 사용자 명시 병렬 -``` +활성화 조건의 정본은 `/implement`의 **병렬 모드** 절이다 (활성 팀 2개 이상 또는 BUILD Phase 2개 이상, `--sequential`/`--parallel`로 강제). 여기서는 다시 정의하지 않는다. coordinator가 추가로 보는 것은 하나뿐이다: 팀 간 순환 의존성이 있으면 병렬 대신 순차로 실행한다. ## Result Merge Protocol diff --git a/plugins/wigtn-plugins/commands/implement.md b/plugins/wigtn-plugins/commands/implement.md index 006e93b..2fcb7fc 100644 --- a/plugins/wigtn-plugins/commands/implement.md +++ b/plugins/wigtn-plugins/commands/implement.md @@ -328,3 +328,4 @@ PLAN 파일 Progress를 `completed`로 바꾸고 Execution Log에 트리거·Qua 2. **기존 코드 수정 시** — 먼저 Read로 현재 구현을 확인하고, 기존 패턴을 따르고, 불필요한 변경을 최소화한다. 3. **진행 추적은 파일 원장으로만** — PLAN 파일이 정본이다. 4. **에러 발생 시** — 즉시 수정하고, 롤백이 필요하면 사용자에게 알린다. +5. **BUILD 중에는 원장이 빌 때까지 멈추지 않는다** — Phase 하나를 끝내고 요약과 "다음은 X를 하겠습니다"만 남긴 채 턴을 끝내지 않는다. 진행 보고는 다음 도구 호출과 같은 메시지에 넣고 계속한다. 멈춰도 되는 곳은 Step 6 확인, 사용자만 풀 수 있는 blocker, 위험하거나 되돌리기 어려운 작업의 확인뿐이다. diff --git a/plugins/wigtn-plugins/hooks/hooks.json b/plugins/wigtn-plugins/hooks/hooks.json index 845eec0..3626abe 100644 --- a/plugins/wigtn-plugins/hooks/hooks.json +++ b/plugins/wigtn-plugins/hooks/hooks.json @@ -6,8 +6,7 @@ "hooks": [ { "type": "command", - "command": "bash -c 'D=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -f \"$D/hooks/gate.sh\" ]; then exit 0; fi; echo \"WIGTN 경고: 커밋 품질 게이트가 **비활성**입니다 — hooks/gate.sh 경로를 해석하지 못했습니다. 커밋이 객관 체크 없이 통과합니다. (CLAUDE_PLUGIN_ROOT 미확장 또는 플러그인 설치 손상)\" >&2; exit 0'", - "async": true + "command": "bash -c 'D=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -f \"$D/hooks/gate.sh\" ]; then exit 0; fi; cat <<\"EOF\"\n{\"systemMessage\": \"WIGTN 경고: 커밋 품질 게이트가 비활성입니다 — hooks/gate.sh 경로를 해석하지 못해 커밋이 객관 체크 없이 통과합니다. (CLAUDE_PLUGIN_ROOT 미확장 또는 플러그인 설치 손상)\", \"hookSpecificOutput\": {\"hookEventName\": \"SessionStart\", \"additionalContext\": \"WIGTN 경고: 커밋 품질 게이트가 비활성입니다 — hooks/gate.sh 경로를 해석하지 못해 커밋이 객관 체크 없이 통과합니다. (CLAUDE_PLUGIN_ROOT 미확장 또는 플러그인 설치 손상)\"}}\nEOF\nexit 0'" } ] } @@ -18,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "bash -c 'INPUT=$(cat); CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command // empty\"); PROBE=$(echo \"$CMD\" | tr -d \"\\42\\47\"); if echo \"$PROBE\" | grep -qE \"rm( +-[A-Za-z-]+){0,3} +(-\\w*[rR]\\w*f\\w*|-\\w*f\\w*[rR]\\w*|-[rR] +-f|-f +-[rR]|--recursive +--force|--force +--recursive) +(/|~|[$]HOME|[$][{]HOME[}])(/?([[:space:]]|$)|/?\\*)|git\\s+push\\s+.*--force|git\\s+push\\s+.*-f\\s|git\\s+reset\\s+--hard|[Dd][Rr][Oo][Pp]\\s+([Dd][Aa][Tt][Aa][Bb][Aa][Ss][Ee]|[Tt][Aa][Bb][Ll][Ee]|[Ss][Cc][Hh][Ee][Mm][Aa])\"; then echo \"BLOCKED: Dangerous command detected. Please confirm before executing: $CMD\" >&2; exit 2; fi'" + "command": "bash -c 'INPUT=$(cat); CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command // empty\"); PROBE=$(printf \"%s\\n\" \"$CMD\" | tr -d \"\\42\\47\" | awk \"h { l = \\$0; sub(/^\\t+/, e, l); if (l == t) h = 0; next } { print } /(^|[^<])<<-?[ \\t]*[A-Za-z_][A-Za-z0-9_]*/ && !/(^|[^A-Za-z0-9_.\\/-])(bash|sh|zsh|ksh|dash|ssh|eval|source)([ \\t]|\\$)/ { s = \\$0; sub(/^.*<<-?[ \\t]*/, e, s); match(s, /^[A-Za-z_][A-Za-z0-9_]*/); t = substr(s, 1, RLENGTH); h = 1 }\"); if echo \"$PROBE\" | grep -qE \"rm( +-[A-Za-z-]+){0,3} +(-\\w*[rR]\\w*f\\w*|-\\w*f\\w*[rR]\\w*|-[rR] +-f|-f +-[rR]|--recursive +--force|--force +--recursive) +(/|~|[$]HOME|[$][{]HOME[}])(/?([[:space:]]|$)|/?\\*)|git\\s+push\\s+.*--force|git\\s+push\\s+.*-f\\s|git\\s+reset\\s+--hard|[Dd][Rr][Oo][Pp]\\s+([Dd][Aa][Tt][Aa][Bb][Aa][Ss][Ee]|[Tt][Aa][Bb][Ll][Ee]|[Ss][Cc][Hh][Ee][Mm][Aa])\"; then echo \"BLOCKED: Dangerous command detected. Please confirm before executing: $CMD\" >&2; exit 2; fi'" } ] }, @@ -32,32 +31,10 @@ ] } ], - "PostToolUse": [ - { - "matcher": "Write|Edit", - "hooks": [ - { - "type": "command", - "command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path // .tool_input.filePath // empty\"); if echo \"$FILE\" | grep -qE \"\\.(tsx?|jsx?|css|scss)$\"; then echo \"Reminder: Run formatter (prettier/eslint) on $FILE before committing.\"; fi'", - "async": true - }, - { - "type": "command", - "command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path // .tool_input.filePath // empty\"); if echo \"$FILE\" | grep -qE \"\\.(ts|js|py|go|java)$\"; then echo \"Reminder: Verify backend pattern compliance (error handling, input validation, logging) for $FILE.\"; fi'", - "async": true - } - ] - } - ], "Stop": [ { "matcher": "", "hooks": [ - { - "type": "command", - "command": "bash -c 'echo \"Pipeline session complete. Review changes before pushing.\"'", - "async": true - }, { "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/knowledge_wiki/accumulate.py\"", diff --git a/plugins/wigtn-plugins/skills/architecture-diagram/SKILL.md b/plugins/wigtn-plugins/skills/architecture-diagram/SKILL.md new file mode 100644 index 0000000..d9b8f35 --- /dev/null +++ b/plugins/wigtn-plugins/skills/architecture-diagram/SKILL.md @@ -0,0 +1,122 @@ +--- +name: architecture-diagram +description: >- + Draw an architecture, system, or flow diagram as a committable hand-drawn (sketch) + image — SVG + PNG — in the Excalidraw / Mermaid handDrawn look: rough outlines, + pastel hachure fills, Korean handwriting titles. A clean style is available too. + Text is measured in a real browser before layout, so labels never clip, and a layout + check fails the render on overflow, overlap, or an edge running through a node or label. Use for READMEs, docs, slides, Devpost. + Triggers on: '아키텍처 그려줘', '아키텍처 다이어그램', '구조도', '시스템 구성도', + '플로우 그려줘', '흐름도', '다이어그램 그려줘', '손그림 다이어그램', '스케치 도식', + 'architecture diagram', 'system diagram', 'flow diagram', 'hand-drawn diagram', + 'sketch diagram', 'Devpost diagram'. +allowed-tools: Read, Write, Edit, Bash, Glob, Grep +--- + +# Architecture diagram (JSON spec → measured layout → hand-drawn SVG/PNG) + +You write the **structure** as JSON. The renderer owns everything visual: it measures +each label in headless Chromium, lays out with ELK (layered, orthogonal routing), draws +with rough.js, embeds subset fonts in the SVG, and checks the result. + +The look follows what makes Mermaid `look: handDrawn` and Excalidraw read well: +shapes rougher than lines (0.8 vs 0.35), pastel hachure fills from the open-color palette, +warm paper background, Poor Story handwriting for titles and annotations, Pretendard for +node labels so small text stays legible. + +## 1. Decide what the diagram must say + +- **The question it answers**: "what talks to what" (architecture) or "what happens in which order" (flow). +- **Zones**: client / service / data / external, or phases for a flow. These become `groups`. +- **The one path to follow**: the main request path or happy path. Only this gets `emphasis`. + +If the diagram is of a real codebase, read the code first (entry points, services, +datastores, external calls) instead of guessing. 6–15 nodes reads well; split bigger systems +into two diagrams. + +## 2. Write the spec (`.json`) + +```json +{ + "title": "서비스 아키텍처", + "subtitle": "한 줄 설명 (선택)", + "direction": "DOWN", + "groups": [ + { "id": "client", "label": "클라이언트 (Next.js)", "tone": "blue" }, + { "id": "data", "label": "데이터" } + ], + "nodes": [ + { "id": "user", "label": "사용자", "shape": "actor" }, + { "id": "web", "label": "웹 앱", "sub": "Next.js", "group": "client", "emphasis": true }, + { "id": "pg", "label": "PostgreSQL", "sub": "주 DB", "group": "data", "shape": "db" }, + { "id": "pay", "label": "결제 대행사", "shape": "external" } + ], + "edges": [ + { "from": "user", "to": "web", "emphasis": true }, + { "from": "web", "to": "pg", "label": "조회", "emphasis": true }, + { "from": "pay", "to": "web", "dashed": true, "label": "웹훅" } + ], + "legend": [ + { "kind": "emphasis", "label": "주요 흐름" }, + { "kind": "dashed", "label": "비동기" } + ] +} +``` + +| Field | Values | Notes | +|---|---|---| +| `direction` | `DOWN` · `RIGHT` | DOWN for layered architecture, RIGHT for pipelines and event flows | +| `style` | `sketch` (default) · `clean` | `clean` = crisp lines on white, for formal docs | +| `font` | `mixed` (default) · `hand` · `plain` | `hand` puts node labels in handwriting too; `plain` uses no handwriting | +| `tone` (top level) | a tone name | color for nodes outside any toned group (default `violet`) | +| `groups[].tone` | `gray` `blue` `green` `yellow` `orange` `violet` `red` `teal` | nodes inherit their group's tone; untoned groups are gray. `slate` / `amber` / `rose` are accepted as aliases of gray / orange / red | +| `nodes[].shape` | `box` `db` `queue` `actor` `external` `decision` | pick by role: datastore → `db`, queue/worker → `queue`, person/client → `actor`, 3rd party → `external`, branch → `decision` (each outgoing edge gets a condition label: 예/아니오, PASS/FAIL) | +| `nodes[].sub` | short second line | tech or role: "NestJS", "BullMQ" | +| `nodes[].tone` | a tone name | override for one node (rare) | +| `edges[].back` | `true` | feedback / retry edge (FAIL → 수정). Laid out forward, drawn reversed, so a loop never flips the main flow. Self-loops (`from` = `to`) are rejected | +| `legend[].kind` | `emphasis` `dashed` `external` `edge` | add a legend whenever emphasis, dashed edges, or external nodes appear | +| `background` | hex color, e.g. `#ffffff` | defaults to paper `#fdfcf8` (sketch) or white (clean) | + +List nodes in reading order (upstream first). + +**Design rules** +- **A tone means a zone.** Give each meaningful zone its own tone, at most 4. Leave supporting zones (data, infra) gray. Red only for guards, errors, and blocking paths. +- **Put nodes where their edges go.** A zone is drawn as one block, so if A → Kafka → B and A, B share a zone, the arrows loop around. Split producers and consumers into separate zones (see `examples/`). +- **One emphasized path.** `emphasis` makes outlines and arrows darker and thicker. If more than about a third of the nodes are emphasized, nothing stands out. +- **Short noun labels.** "인증 서비스", not "사용자 인증을 처리하는 서비스". Detail goes in `sub`. +- **Label an edge only when the verb isn't obvious** (웹훅, 비동기, PASS/FAIL, POST /orders). + +## 3. Render + +```bash +bash "${CLAUDE_PLUGIN_ROOT}/skills/architecture-diagram/scripts/render.sh" docs/diagrams/arch.json +# → docs/diagrams/arch.svg (fonts embedded) + arch.png (2x) +``` + +The first run installs `elkjs`, `roughjs`, `puppeteer` and `subset-font` and downloads the fonts (Poor Story, Pretendard; both SIL OFL) into `~/.cache/wigtn-diagram`. + +Exit codes: `0` rendered and checks passed · `1` rendered, but a layout check failed — each problem is listed (text overflowing its box, overlapping nodes, an edge running through another node or another edge's label); fix the spec · `2` the spec is invalid or setup failed (missing node/npm, install or download error) — the message names the field; nothing about the layout needs changing. + +## 4. Look at the PNG and fix the spec (required) + +A passing check means nothing clips or overlaps. It says nothing about whether the diagram reads well. Read the PNG and check: + +- Does the eye land on the emphasized path first? +- Do arrows loop around a zone or cross each other for no reason? → regroup nodes, reorder `nodes`, or switch `direction`. +- Is a zone mostly empty? → its nodes belong elsewhere, or the zone isn't needed. +- Is any label ambiguous without its `sub`? + +Edit the spec and render again. Never hand-edit the SVG. If you cannot view images in this environment, say that the visual check was skipped instead of calling the diagram done. + +Worked examples: `examples/layered-architecture.json` (DOWN, three layers) and `examples/pipeline-flow.json` (RIGHT, with a back edge). + +## 5. Commit + +Commit the `.json` source, the `.svg`, and the `.png`. The SVG carries its own fonts and looks the same in any viewer; the PNG is for places that don't take SVG. Embed with `![서비스 아키텍처](docs/diagrams/arch.png)`. + +## Gotchas + +- **Offline first run**: if the font download fails, the renderer falls back to system fonts and warns. The SVG is then not self-contained, and Korean on Linux needs `fonts-noto-cjk`. +- **Order inside a zone** follows the spec at the top level, but inside groups the layout engine may still reorder nodes to reduce crossings. If a column reads in the wrong order, split the group or change which nodes connect to it. +- **ids** allow only letters, digits, `_` and `-`. Labels can be anything (Korean, `/`, `→`, `&`). +- **Line breaks**: `\n` in a label forces one. Otherwise long labels wrap at spaces, never inside a word. diff --git a/plugins/wigtn-plugins/skills/architecture-diagram/examples/layered-architecture.json b/plugins/wigtn-plugins/skills/architecture-diagram/examples/layered-architecture.json new file mode 100644 index 0000000..31a4ec8 --- /dev/null +++ b/plugins/wigtn-plugins/skills/architecture-diagram/examples/layered-architecture.json @@ -0,0 +1,37 @@ +{ + "title": "서비스 아키텍처", + "subtitle": "웹·관리자 클라이언트 → API 서버 → 데이터 레이어", + "direction": "DOWN", + "groups": [ + { "id": "client", "label": "클라이언트 레이어 (Next.js 웹앱)", "tone": "blue" }, + { "id": "server", "label": "백엔드 서비스 레이어 (NestJS API 서버)", "tone": "violet" }, + { "id": "data", "label": "데이터 레이어", "tone": "slate" } + ], + "nodes": [ + { "id": "user", "label": "사용자", "shape": "actor" }, + { "id": "dash", "label": "사용자 대시보드", "group": "client", "emphasis": true }, + { "id": "admin", "label": "관리자 콘솔 화면", "group": "client" }, + { "id": "auth", "label": "인증 서비스", "sub": "JWT · 세션", "group": "server", "emphasis": true }, + { "id": "hook", "label": "결제 웹훅 처리기", "group": "server" }, + { "id": "worker", "label": "AI 요약 워커", "sub": "BullMQ Queue", "group": "server", "shape": "queue" }, + { "id": "pg", "label": "PostgreSQL 메인 DB", "group": "data", "shape": "db", "emphasis": true }, + { "id": "redis", "label": "Redis 캐시", "group": "data", "shape": "db" }, + { "id": "pay", "label": "결제 대행사 (PG)", "shape": "external" } + ], + "edges": [ + { "from": "user", "to": "dash", "emphasis": true }, + { "from": "dash", "to": "auth", "label": "로그인", "emphasis": true }, + { "from": "admin", "to": "auth" }, + { "from": "auth", "to": "pg", "emphasis": true }, + { "from": "pay", "to": "hook", "label": "결제 완료 이벤트" }, + { "from": "hook", "to": "pg" }, + { "from": "hook", "to": "worker", "dashed": true, "label": "비동기" }, + { "from": "dash", "to": "worker", "dashed": true }, + { "from": "worker", "to": "redis" } + ], + "legend": [ + { "kind": "emphasis", "label": "주요 흐름 (로그인)" }, + { "kind": "dashed", "label": "비동기 호출" }, + { "kind": "external", "label": "외부 시스템" } + ] +} diff --git a/plugins/wigtn-plugins/skills/architecture-diagram/examples/pipeline-flow.json b/plugins/wigtn-plugins/skills/architecture-diagram/examples/pipeline-flow.json new file mode 100644 index 0000000..7ea0a58 --- /dev/null +++ b/plugins/wigtn-plugins/skills/architecture-diagram/examples/pipeline-flow.json @@ -0,0 +1,107 @@ +{ + "title": "WIGTN 파이프라인", + "subtitle": "아이디어에서 PR까지", + "direction": "RIGHT", + "groups": [ + { + "id": "design", + "label": "설계", + "tone": "blue" + }, + { + "id": "build", + "label": "구현 · 검증", + "tone": "violet" + } + ], + "nodes": [ + { + "id": "idea", + "label": "기능 요청", + "shape": "actor" + }, + { + "id": "prd", + "label": "/prd", + "sub": "PRD + PLAN 원장", + "group": "design", + "emphasis": true + }, + { + "id": "dig", + "label": "prd-reviewer", + "sub": "4렌즈 디깅", + "group": "design" + }, + { + "id": "spec", + "label": "/screen-spec", + "sub": "FE 페이지가 있을 때만", + "group": "design" + }, + { + "id": "impl", + "label": "/implement", + "sub": "triage → DESIGN → BUILD", + "group": "build", + "emphasis": true + }, + { + "id": "commit", + "label": "/auto-commit", + "sub": "findings 롤업 게이트", + "group": "build", + "emphasis": true + }, + { + "id": "pr", + "label": "GitHub PR", + "shape": "external" + } + ], + "edges": [ + { + "from": "idea", + "to": "prd", + "emphasis": true + }, + { + "from": "prd", + "to": "dig", + "emphasis": true + }, + { + "from": "dig", + "to": "spec", + "label": "FE 있음" + }, + { + "from": "dig", + "to": "impl", + "label": "백엔드 전용", + "emphasis": true + }, + { + "from": "spec", + "to": "impl" + }, + { + "from": "impl", + "to": "commit", + "emphasis": true + }, + { + "from": "commit", + "to": "pr", + "label": "PASS", + "emphasis": true + }, + { + "from": "commit", + "to": "impl", + "label": "FAIL → 수정", + "dashed": true, + "back": true + } + ] +} diff --git a/plugins/wigtn-plugins/skills/architecture-diagram/scripts/render.cjs b/plugins/wigtn-plugins/skills/architecture-diagram/scripts/render.cjs new file mode 100644 index 0000000..03cc9a5 --- /dev/null +++ b/plugins/wigtn-plugins/skills/architecture-diagram/scripts/render.cjs @@ -0,0 +1,642 @@ +#!/usr/bin/env node +// Diagram renderer: JSON spec -> fonts subset + measured text -> ELK layout -> SVG (sketch | clean) -> PNG + layout checks. +// Run through render.sh, which installs elkjs / roughjs / puppeteer / subset-font, downloads fonts and sets NODE_PATH. +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +function fail(msg) { + console.error(`render: ${msg}`); + process.exit(2); +} + +const [specPath, outArg] = process.argv.slice(2); +if (!specPath) fail('usage: render.sh [out-basename]'); + +let spec; +try { + spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); +} catch (e) { + fail(`cannot read spec ${specPath}: ${e.message}`); +} +const outBase = outArg || specPath.replace(/\.json$/i, ''); + +// ---- spec validation (fail fast, before launching a browser) ---- +// Every malformed input must end in exit 2 with a readable message: exit 1 means "layout check failed", +// and a model reading that would start moving boxes around instead of fixing the spec. +const SHAPES = ['box', 'db', 'external', 'actor', 'queue', 'decision']; +const TONES = ['gray', 'slate', 'blue', 'green', 'yellow', 'amber', 'orange', 'violet', 'red', 'rose', 'teal']; +const LEGEND_KINDS = ['emphasis', 'dashed', 'external', 'edge']; +const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); +const isText = (v) => typeof v === 'string' && v.trim() !== ''; +if (!isObj(spec)) fail('invalid spec: the top level must be a JSON object'); +const errors = []; +const list = (key) => { + const v = spec[key]; + if (v === undefined) return []; + if (!Array.isArray(v) || !v.every(isObj)) { + errors.push(`${key} must be an array of objects`); + return []; + } + return v; +}; +const groups = list('groups'); +const nodes = list('nodes'); +const edges = list('edges'); +const legend = list('legend'); +const ids = new Set(); +for (const x of [...groups, ...nodes]) { + const id = x.id; + if (typeof id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(id)) errors.push(`invalid id: ${JSON.stringify(id)} (letters, digits, _ - only)`); + else if (ids.has(id)) errors.push(`duplicate id: ${id}`); + ids.add(id); + if (!isText(x.label)) errors.push(`${id}: label must be non-empty text`); + if (x.sub !== undefined && typeof x.sub !== 'string') errors.push(`${id}: sub must be text`); + if (x.tone !== undefined && !TONES.includes(x.tone)) errors.push(`${id}: tone must be one of ${TONES.join(', ')}`); +} +const groupById = new Map(groups.map((g) => [g.id, g])); +const nodeIds = new Set(nodes.map((n) => n.id)); +for (const g of groups) { + if (g.parent === undefined) continue; + if (!groupById.has(g.parent)) { + errors.push(`group ${g.id}: unknown parent ${g.parent}`); + continue; + } + const seen = new Set([g.id]); + for (let p = groupById.get(g.parent); p; p = groupById.get(p.parent)) { + if (seen.has(p.id)) { + errors.push(`group ${g.id}: parent chain loops (${[...seen, p.id].join(' -> ')})`); + break; + } + seen.add(p.id); + } +} +for (const n of nodes) { + if (n.group !== undefined && !groupById.has(n.group)) errors.push(`node ${n.id}: unknown group ${n.group}`); + if (n.shape !== undefined && !SHAPES.includes(n.shape)) errors.push(`node ${n.id}: shape must be one of ${SHAPES.join(', ')}`); +} +edges.forEach((e, i) => { + if (!nodeIds.has(e.from)) errors.push(`edge #${i}: unknown from ${JSON.stringify(e.from)}`); + if (!nodeIds.has(e.to)) errors.push(`edge #${i}: unknown to ${JSON.stringify(e.to)}`); + if (e.from === e.to) errors.push(`edge #${i}: self-loop ${e.from} -> ${e.to} is not supported (use a note in the node's sub instead)`); + if (e.label !== undefined && typeof e.label !== 'string') errors.push(`edge #${i}: label must be text`); +}); +legend.forEach((l, i) => { + if (!isText(l.label)) errors.push(`legend #${i}: label must be non-empty text`); + if (l.kind !== undefined && !LEGEND_KINDS.includes(l.kind)) errors.push(`legend #${i}: kind must be one of ${LEGEND_KINDS.join(', ')}`); +}); +for (const k of ['title', 'subtitle']) if (spec[k] !== undefined && typeof spec[k] !== 'string') errors.push(`${k} must be text`); +if (!nodes.length) errors.push('spec has no nodes'); +if (spec.direction !== undefined && !['RIGHT', 'DOWN'].includes(spec.direction)) errors.push('direction must be RIGHT or DOWN'); +if (spec.style !== undefined && !['sketch', 'clean'].includes(spec.style)) errors.push('style must be sketch or clean'); +if (spec.font !== undefined && !['mixed', 'hand', 'plain'].includes(spec.font)) errors.push('font must be mixed, hand or plain'); +if (spec.tone !== undefined && !TONES.includes(spec.tone)) errors.push(`tone must be one of ${TONES.join(', ')}`); +if (spec.background !== undefined && !/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(spec.background)) errors.push('background must be a hex color like #fdfcf8'); +if (errors.length) fail(`invalid spec:\n - ${errors.join('\n - ')}`); + +let puppeteer; +try { + puppeteer = require('puppeteer'); +} catch (e) { + fail('puppeteer not found — run through render.sh so dependencies are installed'); +} +const elkBundle = require.resolve('elkjs/lib/elk.bundled.js'); +const roughBundle = require.resolve('roughjs/bundled/rough.js'); + +// ---- fonts: subset to the characters actually used, embed as woff2 data URLs ---- +async function loadFonts() { + const dir = process.env.WIGTN_DIAGRAM_FONTS || ''; + let subsetFont; + try { + subsetFont = require('subset-font'); + } catch (e) { + subsetFont = null; + } + const texts = [spec.title, spec.subtitle, ...groups.map((g) => g.label), ...nodes.flatMap((n) => [n.label, n.sub]), ...edges.map((e) => e.label), ...legend.map((l) => l.label)]; + const chars = Array.from(new Set([...texts.filter(Boolean).join(''), ...' 0123456789.,-_/:()→·?'])).join(''); + const faces = [ + { family: 'WDHand', weight: 400, file: 'PoorStory-Regular.ttf' }, + { family: 'WDBody', weight: 400, file: 'Pretendard-Regular.otf' }, + { family: 'WDBody', weight: 600, file: 'Pretendard-SemiBold.otf' }, + ]; + const out = []; + const missing = []; + for (const f of faces) { + const p = path.join(dir, f.file); + if (!dir || !fs.existsSync(p)) { + missing.push(f.file); + continue; + } + const buf = fs.readFileSync(p); + let data = buf; + let format = f.file.endsWith('.otf') ? 'opentype' : 'truetype'; + let mime = f.file.endsWith('.otf') ? 'font/otf' : 'font/ttf'; + if (subsetFont) { + try { + data = await subsetFont(buf, chars, { targetFormat: 'woff2' }); + format = 'woff2'; + mime = 'font/woff2'; + } catch (e) { + missing.push(`${f.file} (unreadable: ${e.message})`); + continue; + } + } + out.push({ family: f.family, weight: f.weight, src: `data:${mime};base64,${data.toString('base64')}`, format }); + } + return { faces: out, missing }; +} + +// ---- in-page renderer ---- +// Runs inside Chromium: fonts resolve exactly as in the PNG, so measured sizes are the rendered sizes. +async function renderInPage(spec, fonts) { + const NS = 'http://www.w3.org/2000/svg'; + const sketch = (spec.style || 'sketch') === 'sketch'; + const fontMode = spec.font || (sketch ? 'mixed' : 'plain'); + const direction = spec.direction || 'RIGHT'; + + const HAND = "WDHand, 'Poor Story', WDBody, 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif"; + const BODY = "WDBody, Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Noto Sans CJK KR', 'Malgun Gothic', 'Segoe UI', system-ui, sans-serif"; + + // open-color: [0] lightest ... [4] darkest (shades 0/2/4/6/8), as Excalidraw uses them + const OC = { + gray: ['#f8f9fa', '#e9ecef', '#ced4da', '#868e96', '#343a40'], + blue: ['#e7f5ff', '#a5d8ff', '#4dabf7', '#228be6', '#1971c2'], + green: ['#ebfbee', '#b2f2bb', '#69db7c', '#40c057', '#2f9e44'], + yellow: ['#fff9db', '#ffec99', '#ffd43b', '#fab005', '#f08c00'], + orange: ['#fff4e6', '#ffd8a8', '#ffa94d', '#fd7e14', '#e8590c'], + violet: ['#f3f0ff', '#d0bfff', '#9775fa', '#7950f2', '#6741d9'], + red: ['#fff5f5', '#ffc9c9', '#ff8787', '#fa5252', '#e03131'], + teal: ['#e6fcf5', '#96f2d7', '#38d9a9', '#12b886', '#099268'], + }; + const ALIAS = { slate: 'gray', amber: 'orange', rose: 'red' }; + const pal = (tone) => OC[ALIAS[tone] || tone] || OC.gray; + + const INK = '#1e1e1e'; + const MUTED = '#6c757d'; + const PAPER = spec.background || (sketch ? '#fdfcf8' : '#ffffff'); + const EDGE = sketch ? '#868e96' : '#7a8394'; + const EDGE_EMPH = sketch ? INK : '#3b5bdb'; + + // typography per role + const T = (family, weight, size, lineH) => ({ font: `${weight} ${size}px ${family}`, family, weight, size, lineH }); + const hand = fontMode !== 'plain'; + const handNodes = fontMode === 'hand'; + const F = { + label: handNodes ? T(HAND, 400, 18, 22) : T(BODY, 600, 14, 20), + sub: handNodes ? T(HAND, 400, 15, 18) : T(BODY, 400, 12, 16), + group: hand ? T(HAND, 400, 20, 24) : T(BODY, 600, 13, 18), + edge: hand ? T(HAND, 400, 16, 20) : T(BODY, 400, 12, 16), + title: hand ? T(HAND, 400, 36, 42) : T(BODY, 700, 20, 28), + subtitle: hand ? T(HAND, 400, 19, 24) : T(BODY, 400, 13, 18), + legend: hand ? T(HAND, 400, 16, 20) : T(BODY, 400, 12, 16), + }; + const NODE_MAX_TEXT_W = 210; + const PAD_X = 20; + const PAD_Y = 14; + const MIN_NODE_W = 128; + const GROUP_PAD = 20; + + // ---- fonts ---- + const css = fonts.faces.map((f) => `@font-face{font-family:${f.family};font-weight:${f.weight};src:url(${f.src}) format('${f.format}');}`).join('\n'); + const styleEl = document.createElement('style'); + styleEl.textContent = css; + document.head.appendChild(styleEl); + await Promise.all(fonts.faces.map((f) => document.fonts.load(`${f.weight} 16px ${f.family}`))); + await document.fonts.ready; + + const ctx = document.createElement('canvas').getContext('2d'); + const measure = (text, f) => { + ctx.font = f.font; + return ctx.measureText(text).width; + }; + // Word-level wrapping only: never breaks inside a word, so "품질" can't become "품 / 질". + const wrap = (text, f, maxW) => { + const out = []; + for (const para of String(text).split('\n')) { + const words = para.split(/\s+/).filter(Boolean); + let line = ''; + for (const w of words) { + const cand = line ? `${line} ${w}` : w; + if (line && measure(cand, f) > maxW) { + out.push(line); + line = w; + } else line = cand; + } + out.push(line); + } + return out; + }; + const maxLineW = (lines, f) => Math.max(0, ...lines.map((l) => measure(l, f))); + const hash = (s) => { + let h = 2166136261; + for (const c of String(s)) h = Math.imul(h ^ c.codePointAt(0), 16777619); + return (h >>> 0) % 2147483647 || 1; + }; + + const groupInfo = {}; + for (const g of spec.groups || []) { + const titleLines = wrap(g.label, F.group, 10000); + groupInfo[g.id] = { ...g, titleLines, titleW: maxLineW(titleLines, F.group), titleH: titleLines.length * F.group.lineH }; + } + // Default node color is lavender (Mermaid's look, close to the WIGTN purple); actors and external systems stay neutral. + const toneOf = (n) => + n.tone || (n.shape === 'decision' ? 'yellow' : null) || (n.group && groupInfo[n.group].tone) || (['actor', 'external'].includes(n.shape) ? 'gray' : spec.tone || 'violet'); + + // ---- sizes ---- + const nodeInfo = {}; + for (const n of spec.nodes) { + const shape = n.shape || 'box'; + const labelLines = wrap(n.label, F.label, NODE_MAX_TEXT_W); + const subLines = n.sub ? wrap(n.sub, F.sub, NODE_MAX_TEXT_W) : []; + const textW = Math.max(maxLineW(labelLines, F.label), maxLineW(subLines, F.sub)); + const textH = labelLines.length * F.label.lineH + (subLines.length ? 4 + subLines.length * F.sub.lineH : 0); + const capExtra = shape === 'db' ? 14 : 0; + const padX = shape === 'actor' ? PAD_X + 10 : shape === 'queue' ? PAD_X + 8 : shape === 'decision' ? PAD_X + 20 : PAD_X; + const w = Math.ceil(Math.max(MIN_NODE_W, textW + padX * 2)); + const h = Math.ceil(textH + PAD_Y * 2 + capExtra); + nodeInfo[n.id] = { ...n, shape, labelLines, subLines, textH, capExtra, w, h, tone: toneOf(n) }; + } + + // ---- ELK graph ---- + const elkNode = {}; + const root = { + id: '__root', + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': direction, + 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', + 'elk.edgeRouting': 'ORTHOGONAL', + 'elk.layered.spacing.nodeNodeBetweenLayers': '72', + 'elk.spacing.nodeNode': '36', + 'elk.spacing.edgeNode': '26', + 'elk.spacing.edgeEdge': '16', + 'elk.layered.spacing.edgeNodeBetweenLayers': '26', + 'elk.spacing.edgeLabel': '8', + 'elk.spacing.componentComponent': '56', + 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', + 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', + // keep the spec's node order inside each layer, so a column reads top-to-bottom in the order it was written + 'elk.layered.crossingMinimization.forceNodeModelOrder': 'true', + 'elk.padding': '[top=0,left=0,bottom=0,right=0]', + }, + children: [], + edges: [], + }; + for (const g of spec.groups || []) { + const gi = groupInfo[g.id]; + elkNode[g.id] = { + id: g.id, + children: [], + layoutOptions: { + 'elk.padding': `[top=${Math.ceil(GROUP_PAD + gi.titleH + 14)},left=${GROUP_PAD},bottom=${GROUP_PAD},right=${GROUP_PAD}]`, + 'elk.nodeSize.constraints': 'MINIMUM_SIZE', + 'elk.nodeSize.minimum': `(${Math.ceil(gi.titleW + GROUP_PAD * 2)}, ${Math.ceil(gi.titleH + GROUP_PAD * 2 + 14)})`, + }, + }; + } + for (const n of spec.nodes) elkNode[n.id] = { id: n.id, width: nodeInfo[n.id].w, height: nodeInfo[n.id].h }; + // Insert each group at the position of its first node so the model order follows the spec's node order. + const placed = new Set(); + const place = (id, isGroup) => { + if (placed.has(id)) return; + const parentId = isGroup ? groupInfo[id].parent : nodeInfo[id].group; + if (parentId) place(parentId, true); + (parentId ? elkNode[parentId].children : root.children).push(elkNode[id]); + placed.add(id); + }; + for (const n of spec.nodes) place(n.id, false); + for (const g of spec.groups || []) place(g.id, true); + const edgeLabelLines = {}; + (spec.edges || []).forEach((e, i) => { + // back edges (retry / feedback loops) are laid out forward and drawn reversed, so a loop never flips the main flow + const edge = e.back ? { id: `e${i}`, sources: [e.to], targets: [e.from] } : { id: `e${i}`, sources: [e.from], targets: [e.to] }; + if (e.label) { + const lines = wrap(e.label, F.edge, 170); + edgeLabelLines[i] = lines; + edge.labels = [{ text: e.label, width: Math.ceil(maxLineW(lines, F.edge) + 14), height: lines.length * F.edge.lineH + 8 }]; + edge.layoutOptions = { 'elk.edgeLabels.placement': 'CENTER' }; + } + root.edges.push(edge); + }); + + const laid = await new ELK().layout(root); + + const abs = { __root: { x: 0, y: 0 } }; + const walk = (node, ox, oy) => { + for (const c of node.children || []) { + abs[c.id] = { x: ox + c.x, y: oy + c.y, w: c.width, h: c.height }; + walk(c, ox + c.x, oy + c.y); + } + }; + walk(laid, 0, 0); + const depth = (gid) => { + let d = 0; + let g = groupInfo[gid]; + while (g && g.parent) { + d++; + g = groupInfo[g.parent]; + } + return d; + }; + + // ---- SVG ---- + const svg = document.createElementNS(NS, 'svg'); + svg.setAttribute('xmlns', NS); + svg.setAttribute('font-family', BODY); + document.body.appendChild(svg); + const el = (tag, attrs, parent = svg) => { + const e = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) if (v !== undefined) e.setAttribute(k, v); + parent.appendChild(e); + return e; + }; + if (css) el('style', {}, el('defs', {})).textContent = css; // self-contained SVG: subset fonts travel with it + const layer = (name) => el('g', { 'data-layer': name }); + const bg = layer('background'); + const gGroups = layer('groups'); + const gEdges = layer('edges'); + const gNodes = layer('nodes'); + const gTitles = layer('group-titles'); + const gLabels = layer('edge-labels'); + const gTitle = layer('title'); + const rc = rough.svg(svg); + + // Hand-drawn recipe (Mermaid look:handDrawn / Excalidraw): shapes rougher than lines, dense hachure reads as textured pastel. + const drawPath = (parent, d, o) => { + if (!sketch) { + return el('path', { d, fill: o.fill || 'none', stroke: o.stroke, 'stroke-width': o.strokeWidth, 'stroke-dasharray': o.dash ? o.dash.join(' ') : undefined, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }, parent); + } + const g = rc.path(d, { + seed: o.seed, + roughness: o.roughness ?? 0.9, + bowing: o.bowing ?? 1, + stroke: o.stroke, + strokeWidth: o.strokeWidth, + fill: o.fill, + fillStyle: o.fillStyle || 'hachure', + hachureGap: o.hachureGap ?? 3.2, + fillWeight: o.fillWeight ?? 1.1, + hachureAngle: o.hachureAngle ?? -41, + strokeLineDash: o.dash, + disableMultiStroke: !!o.dash, + preserveVertices: true, + }); + parent.appendChild(g); + return g; + }; + const roundRect = (x, y, w, h, r) => + `M${x + r},${y} H${x + w - r} Q${x + w},${y} ${x + w},${y + r} V${y + h - r} Q${x + w},${y + h} ${x + w - r},${y + h} H${x + r} Q${x},${y + h} ${x},${y + h - r} V${y + r} Q${x},${y} ${x + r},${y} Z`; + const text = (parent, lines, f, cx, top, fill, anchor = 'middle') => { + const t = el('text', { 'font-family': f.family, 'font-size': f.size, 'font-weight': f.weight, fill, 'text-anchor': anchor }, parent); + lines.forEach((line, i) => { + el('tspan', { x: cx, y: top + f.lineH * i + f.lineH / 2, 'dominant-baseline': 'central' }, t).textContent = line; + }); + return t; + }; + const checks = []; + + // groups + for (const g of [...(spec.groups || [])].sort((a, b) => depth(a.id) - depth(b.id))) { + const gi = groupInfo[g.id]; + const a = abs[g.id]; + const p = pal(g.tone || 'gray'); + const wrapG = el('g', { 'data-group': g.id }, gGroups); + drawPath(wrapG, roundRect(a.x, a.y, a.w, a.h, 18), { + seed: hash(g.id), fill: p[0], fillStyle: 'solid', stroke: sketch ? p[2] : p[1], strokeWidth: sketch ? 1.4 : 1.25, roughness: 1.1, bowing: 1.2, + }); + const plate = el('rect', {}, gTitles); + const t = text(gTitles, gi.titleLines, F.group, a.x + GROUP_PAD, a.y + GROUP_PAD - 4, p[4], 'start'); + const tb = t.getBBox(); + for (const [k, v] of Object.entries({ x: tb.x - 6, y: tb.y - 2, width: tb.width + 12, height: tb.height + 4, rx: 4, fill: p[0] })) plate.setAttribute(k, v); + checks.push(['group-title', g.id, a, t]); + } + + // nodes + for (const n of spec.nodes) { + const ni = nodeInfo[n.id]; + const { x, y, w, h } = abs[n.id]; + const p = pal(ni.tone); + const emph = !!n.emphasis; + const external = ni.shape === 'external'; + const o = { + seed: hash(n.id), + stroke: external ? OC.gray[3] : p[4], + strokeWidth: emph ? (sketch ? 2.8 : 2.25) : 1.5, + fill: external ? (sketch ? undefined : '#fafafa') : sketch ? p[1] : emph ? p[0] : '#ffffff', + hachureGap: 3.4, + fillWeight: 1, + roughness: 0.8, + dash: external ? [8, 7] : undefined, + }; + if (!sketch) o.stroke = emph ? '#3b5bdb' : external ? '#9aa3b2' : ni.shape === 'decision' ? '#c98a1b' : '#9aa3b2'; + const wrapN = el('g', { 'data-node': n.id }, gNodes); + if (ni.shape === 'db') { + const ry = 7; + drawPath(wrapN, `M${x},${y + ry} C${x},${y - ry / 3} ${x + w},${y - ry / 3} ${x + w},${y + ry} V${y + h - ry} C${x + w},${y + h + ry / 3} ${x},${y + h + ry / 3} ${x},${y + h - ry} Z`, o); + drawPath(wrapN, `M${x},${y + ry} C${x},${y + ry * 2.3} ${x + w},${y + ry * 2.3} ${x + w},${y + ry}`, { ...o, fill: undefined, seed: o.seed + 1 }); + } else if (ni.shape === 'actor') { + drawPath(wrapN, roundRect(x, y, w, h, h / 2), o); + } else if (ni.shape === 'decision') { + const k = Math.min(20, h / 2); + drawPath(wrapN, `M${x + k},${y} H${x + w - k} L${x + w},${y + h / 2} L${x + w - k},${y + h} H${x + k} L${x},${y + h / 2} Z`, o); + } else if (ni.shape === 'queue') { + drawPath(wrapN, roundRect(x, y, w, h, 8), o); + drawPath(wrapN, `M${x + 11},${y + 7} V${y + h - 7} M${x + w - 11},${y + 7} V${y + h - 7}`, { ...o, fill: undefined, strokeWidth: 1.1, seed: o.seed + 1 }); + } else { + drawPath(wrapN, roundRect(x, y, w, h, 12), o); + } + const top = y + ni.capExtra + (h - ni.capExtra - ni.textH) / 2; + const t = el('g', {}, wrapN); + if (sketch && !external) { + t.setAttribute('stroke', p[0]); + t.setAttribute('stroke-width', 4); + t.setAttribute('stroke-linejoin', 'round'); + t.setAttribute('paint-order', 'stroke'); + } + text(t, ni.labelLines, F.label, x + w / 2, top, INK); + if (ni.subLines.length) text(t, ni.subLines, F.sub, x + w / 2, top + ni.labelLines.length * F.label.lineH + 4, sketch ? '#495057' : MUTED); + checks.push(['node', n.id, abs[n.id], t]); + } + + // edges + const edgeById = {}; + for (const e of laid.edges || []) edgeById[e.id] = e; + const routes = []; + (spec.edges || []).forEach((e, i) => { + const le = edgeById[`e${i}`]; + if (!le || !le.sections) return; + const off = abs[le.container || '__root'] || { x: 0, y: 0 }; + const color = e.emphasis ? EDGE_EMPH : EDGE; + const sw = e.emphasis ? (sketch ? 2.2 : 2.25) : sketch ? 1.4 : 1.5; + const dash = e.dashed ? (sketch ? [8, 8] : [6, 5]) : undefined; + le.sections.forEach((s, si) => { + const finalSection = si === le.sections.length - 1; + const pts = [s.startPoint, ...(s.bendPoints || []), s.endPoint].map((p) => ({ x: p.x + off.x, y: p.y + off.y })); + if (e.back) pts.reverse(); + // ELK leaves sub-pixel drift on straight runs (151.7 vs 152). Snap from the arrowhead backwards so every + // segment is exactly axis-aligned and the tip stays on the target's border; only the tail may shift <2px. + for (let k = pts.length - 1; k > 0; k--) { + if (Math.abs(pts[k].x - pts[k - 1].x) < 2) pts[k - 1].x = pts[k].x; + if (Math.abs(pts[k].y - pts[k - 1].y) < 2) pts[k - 1].y = pts[k].y; + } + for (let k = pts.length - 2; k > 0; k--) { + const a = pts[k - 1], b = pts[k], c = pts[k + 1]; + const same = (u, v) => u.x === v.x && u.y === v.y; + if (same(a, b) || same(b, c) || (a.x === b.x && b.x === c.x) || (a.y === b.y && b.y === c.y)) pts.splice(k, 1); + } + const last = pts[pts.length - 1]; + const prev = pts[pts.length - 2]; + const len = Math.hypot(last.x - prev.x, last.y - prev.y) || 1; + const ux = (last.x - prev.x) / len; + const uy = (last.y - prev.y) / len; + const AH = sketch ? 12 : 9; + const pull = finalSection ? (sketch ? 1.5 : AH * 0.8) : 0; + const shaft = pts.slice(0, -1).concat([{ x: last.x - ux * pull, y: last.y - uy * pull }]); + routes.push({ id: `${e.from}->${e.to}`, from: e.from, to: e.to, pts }); + // rounded orthogonal route; hand-drawn diagrams use generous corners + const R = sketch ? 16 : 6; + let d = `M${shaft[0].x},${shaft[0].y}`; + for (let k = 1; k < shaft.length - 1; k++) { + const p0 = shaft[k - 1], p1 = shaft[k], p2 = shaft[k + 1]; + const r = Math.min(R, Math.hypot(p1.x - p0.x, p1.y - p0.y) / 2, Math.hypot(p2.x - p1.x, p2.y - p1.y) / 2); + const a1 = { x: p1.x - Math.sign(p1.x - p0.x) * r, y: p1.y - Math.sign(p1.y - p0.y) * r }; + const a2 = { x: p1.x + Math.sign(p2.x - p1.x) * r, y: p1.y + Math.sign(p2.y - p1.y) * r }; + d += ` L${a1.x},${a1.y} Q${p1.x},${p1.y} ${a2.x},${a2.y}`; + } + d += ` L${shaft[shaft.length - 1].x},${shaft[shaft.length - 1].y}`; + const wrapE = el('g', { 'data-edge': `${e.from}->${e.to}` }, gEdges); + drawPath(wrapE, d, { seed: hash(`${e.from}>${e.to}`), stroke: color, strokeWidth: sw, roughness: 0.35, bowing: 0.6, dash }); + const px = -uy, py = ux; + if (!finalSection) return; + if (sketch) { + // open chevron head, like Excalidraw's default arrowhead + const ang = (25 * Math.PI) / 180; + const bx = last.x - ux * AH * Math.cos(ang), by = last.y - uy * AH * Math.cos(ang); + const s1 = { x: bx + px * AH * Math.sin(ang), y: by + py * AH * Math.sin(ang) }; + const s2 = { x: bx - px * AH * Math.sin(ang), y: by - py * AH * Math.sin(ang) }; + drawPath(wrapE, `M${s1.x},${s1.y} L${last.x},${last.y} L${s2.x},${s2.y}`, { seed: hash(`${e.from}>${e.to}h`), stroke: color, strokeWidth: sw, roughness: 0.4, bowing: 0.3 }); + } else { + el('path', { d: `M${last.x},${last.y} L${last.x - ux * AH + px * AH * 0.5},${last.y - uy * AH + py * AH * 0.5} L${last.x - ux * AH - px * AH * 0.5},${last.y - uy * AH - py * AH * 0.5} Z`, fill: color, stroke: color, 'stroke-width': 1, 'stroke-linejoin': 'round' }, wrapE); + } + }); + for (const lb of le.labels || []) { + const x = lb.x + off.x, y = lb.y + off.y; + const wrapL = el('g', { 'data-edge-label': `${e.from}->${e.to}` }, gLabels); + el('rect', { x, y, width: lb.width, height: lb.height, rx: 6, fill: PAPER, 'fill-opacity': 0.9 }, wrapL); + const t = text(wrapL, edgeLabelLines[i], F.edge, x + lb.width / 2, y + 4, e.emphasis ? INK : '#495057'); + checks.push(['edge-label', `${e.from}->${e.to}`, { x, y, w: lb.width, h: lb.height }, t]); + } + }); + + // title block above the diagram + const content = svg.getBBox(); + if (spec.title) { + const tTop = content.y - 28 - (spec.subtitle ? F.subtitle.lineH + 4 : 0) - F.title.lineH; + text(gTitle, [spec.title], F.title, content.x, tTop, INK, 'start'); + if (spec.subtitle) text(gTitle, [spec.subtitle], F.subtitle, content.x, tTop + F.title.lineH + 4, MUTED, 'start'); + } + + // legend below the diagram + if (spec.legend && spec.legend.length) { + const c2 = svg.getBBox(); + let lx = c2.x; + const ly = c2.y + c2.height + 28; + const gL = el('g', { 'data-layer': 'legend' }); + spec.legend.forEach((item, i) => { + const kind = item.kind || 'edge'; + const seed = 900 + i; + const mid = ly + F.legend.lineH / 2; + if (kind === 'emphasis') drawPath(gL, `M${lx},${mid} H${lx + 34}`, { seed, stroke: EDGE_EMPH, strokeWidth: sketch ? 2.2 : 2.25, roughness: 0.35 }); + else if (kind === 'dashed') drawPath(gL, `M${lx},${mid} H${lx + 34}`, { seed, stroke: EDGE, strokeWidth: 1.4, roughness: 0.35, dash: sketch ? [8, 8] : [6, 5] }); + else if (kind === 'external') drawPath(gL, roundRect(lx, mid - 9, 34, 18, 5), { seed, stroke: OC.gray[3], strokeWidth: 1.3, dash: [5, 4], roughness: 0.8 }); + else drawPath(gL, `M${lx},${mid} H${lx + 34}`, { seed, stroke: EDGE, strokeWidth: 1.4, roughness: 0.35 }); + const t = text(gL, [item.label], F.legend, lx + 44, ly, MUTED, 'start'); + lx += 44 + t.getBBox().width + 32; + }); + } + + // ---- canvas: fit to content so nothing can fall outside ---- + const M = 40; + const all = svg.getBBox(); + const vb = { x: Math.floor(all.x - M), y: Math.floor(all.y - M), w: Math.ceil(all.width + M * 2), h: Math.ceil(all.height + M * 2) }; + svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`); + svg.setAttribute('width', vb.w); + svg.setAttribute('height', vb.h); + el('rect', { x: vb.x, y: vb.y, width: vb.w, height: vb.h, fill: PAPER }, bg); + + // ---- layout checks (deterministic) ---- + const problems = []; + const TOL = 1; + const inside = (b, r) => b.x >= r.x - TOL && b.y >= r.y - TOL && b.x + b.width <= r.x + r.w + TOL && b.y + b.height <= r.y + r.h + TOL; + for (const [kind, id, rect, t] of checks) { + const b = t.getBBox(); + if (!inside(b, rect)) problems.push(`${kind} "${id}": text overflows its box (text ${Math.round(b.width)}x${Math.round(b.height)}, box ${Math.round(rect.w)}x${Math.round(rect.h)})`); + } + const nodeRects = spec.nodes.map((n) => ({ id: n.id, ...abs[n.id] })); + const overlap = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; + for (let i = 0; i < nodeRects.length; i++) + for (let j = i + 1; j < nodeRects.length; j++) if (overlap(nodeRects[i], nodeRects[j])) problems.push(`nodes "${nodeRects[i].id}" and "${nodeRects[j].id}" overlap`); + for (const [kind, id, rect] of checks) { + if (kind !== 'edge-label') continue; + for (const nr of nodeRects) if (overlap(rect, nr)) problems.push(`edge label "${id}" overlaps node "${nr.id}"`); + } + // Routes are axis-aligned polylines: flag any that pass through a node other than their own ends, + // or through another edge's label. Both read as a wrong connection. + const hits = (a, b, r, pad) => { + const x1 = Math.min(a.x, b.x), x2 = Math.max(a.x, b.x), y1 = Math.min(a.y, b.y), y2 = Math.max(a.y, b.y); + return x1 < r.x + r.w - pad && x2 > r.x + pad && y1 < r.y + r.h - pad && y2 > r.y + pad; + }; + const labelRects = checks.filter(([kind]) => kind === 'edge-label').map(([, id, rect]) => ({ id, ...rect })); + for (const rt of routes) { + const crossed = new Set(); + for (let k = 1; k < rt.pts.length; k++) { + for (const nr of nodeRects) if (nr.id !== rt.from && nr.id !== rt.to && hits(rt.pts[k - 1], rt.pts[k], nr, 3)) crossed.add(`node "${nr.id}"`); + for (const lr of labelRects) if (lr.id !== rt.id && hits(rt.pts[k - 1], rt.pts[k], lr, 2)) crossed.add(`label of "${lr.id}"`); + } + for (const c of crossed) problems.push(`edge "${rt.id}" runs through ${c}`); + } + + return { + debug: spec.__debug ? laid.edges : undefined, + svg: svg.outerHTML, + width: vb.w, + height: vb.h, + problems, + stats: { nodes: spec.nodes.length, edges: (spec.edges || []).length, groups: (spec.groups || []).length }, + }; +} + +(async () => { + const fonts = await loadFonts(); + if (fonts.missing.length) console.error(`render: fonts missing (${fonts.missing.join(', ')}) — using system fonts; the SVG will not be self-contained`); + const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] }); + try { + const page = await browser.newPage(); + await page.setContent(''); + await page.addScriptTag({ path: elkBundle }); + await page.addScriptTag({ path: roughBundle }); + const result = await page.evaluate(renderInPage, { ...spec, __debug: !!process.env.WIGTN_DIAGRAM_DEBUG }, fonts); + if (result.debug) console.error(JSON.stringify(result.debug, null, 1)); + + fs.writeFileSync(`${outBase}.svg`, `\n${result.svg}\n`); + await page.setViewport({ width: result.width, height: result.height, deviceScaleFactor: 2 }); + const handle = await page.$('svg'); + await handle.screenshot({ path: `${outBase}.png` }); + + const kb = Math.round(fs.statSync(`${outBase}.svg`).size / 1024); + console.log(`wrote ${path.resolve(outBase)}.svg (${kb} KB) and .png (${result.width}x${result.height}, ${result.stats.nodes} nodes, ${result.stats.edges} edges, ${result.stats.groups} groups)`); + if (result.problems.length) { + console.log(`LAYOUT CHECK FAILED (${result.problems.length}):`); + for (const p of result.problems) console.log(` - ${p}`); + process.exitCode = 1; + } else { + console.log('layout checks passed: no text overflow, no overlaps, no edge through a node or label'); + } + } finally { + await browser.close(); + } +})().catch((e) => { + console.error(`render: ${e.stack || e.message}`); + process.exit(2); +}); diff --git a/plugins/wigtn-plugins/skills/architecture-diagram/scripts/render.sh b/plugins/wigtn-plugins/skills/architecture-diagram/scripts/render.sh new file mode 100755 index 0000000..6d8d69b --- /dev/null +++ b/plugins/wigtn-plugins/skills/architecture-diagram/scripts/render.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Render a diagram spec (JSON) to SVG + PNG. +# Usage: render.sh [out-basename] (default out-basename: spec path without .json) +# Exit 0 = rendered and layout checks passed, 1 = rendered but checks failed, 2 = bad input / setup error. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +CACHE="${WIGTN_DIAGRAM_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/wigtn-diagram}" +DEPS="elkjs@0.12.0 roughjs@4.6.6 puppeteer@25.12.0 subset-font@2.9.0" + +setup_fail() { + echo "wigtn-diagram: $1" >&2 + exit 2 +} + +for tool in node npm curl; do + command -v "$tool" >/dev/null 2>&1 || setup_fail "'$tool' is required but not installed" +done + +# One setup at a time: concurrent first runs would corrupt the shared npm prefix. +mkdir -p "$CACHE" +LOCK="$CACHE/.setup.lock" +got_lock=0 +for _ in $(seq 1 180); do + if mkdir "$LOCK" 2>/dev/null; then got_lock=1; break; fi + sleep 1 +done +[ "$got_lock" = 1 ] || setup_fail "another setup has held $LOCK for 3 minutes (delete it if no render is running)" +trap 'rmdir "$LOCK" 2>/dev/null || true' EXIT + +if [ ! -f "$CACHE/.deps" ] || [ "$(cat "$CACHE/.deps")" != "$DEPS" ]; then + echo "wigtn-diagram: installing renderer deps into $CACHE (first run only)..." >&2 + # shellcheck disable=SC2086 + npm install --prefix "$CACHE" --no-audit --no-fund --loglevel=error $DEPS >&2 \ + || setup_fail "npm install failed (network or registry problem) — nothing was rendered" + printf '%s' "$DEPS" > "$CACHE/.deps" +fi + +# Fonts (all SIL OFL 1.1), pinned and hash-checked. Poor Story = Korean handwriting for titles +# and annotations (full Hangul), Pretendard = body text. Subsets are embedded into the SVG. +# A failed download is not fatal: the renderer falls back to system fonts and says so. +FONTS="$CACHE/fonts" +mkdir -p "$FONTS" +sha256() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi +} +fetch_font() { + local name="$1" url="$2" want="$3" part + if [ -s "$FONTS/$name" ] && [ "$(sha256 "$FONTS/$name")" = "$want" ]; then return 0; fi + part="$(mktemp "$FONTS/.$name.XXXXXX")" + if curl -fsSL --retry 2 -o "$part" "$url" && [ "$(sha256 "$part")" = "$want" ]; then + mv -f "$part" "$FONTS/$name" + else + rm -f "$part" + echo "wigtn-diagram: could not fetch a verified $name (system font fallback)" >&2 + fi +} +fetch_font PoorStory-Regular.ttf \ + https://raw.githubusercontent.com/google/fonts/1ef157d3939299d14563418b2f7271b20f0e9161/ofl/poorstory/PoorStory-Regular.ttf \ + 831ab87f7b5463f9cd83ac249bf386816f3a478f1d226427c88cac907adb7ee2 +fetch_font Pretendard-Regular.otf \ + https://cdn.jsdelivr.net/npm/pretendard@1.3.9/dist/public/static/Pretendard-Regular.otf \ + 3ffbacde6ab8411f1d2db54bb9b1f0b3ee2a738932033722cf0388c06aed1c93 +fetch_font Pretendard-SemiBold.otf \ + https://cdn.jsdelivr.net/npm/pretendard@1.3.9/dist/public/static/Pretendard-SemiBold.otf \ + c89bc43027dc7cde5726e96223376f8eec09302b2fc1f8147fd5b57cfc376118 + +rmdir "$LOCK" 2>/dev/null || true +trap - EXIT +WIGTN_DIAGRAM_FONTS="$FONTS" NODE_PATH="$CACHE/node_modules" exec node "$HERE/render.cjs" "$@" diff --git a/plugins/wigtn-plugins/skills/design-system-reference/SKILL.md b/plugins/wigtn-plugins/skills/design-system-reference/SKILL.md index 8a000b3..79095d3 100644 --- a/plugins/wigtn-plugins/skills/design-system-reference/SKILL.md +++ b/plugins/wigtn-plugins/skills/design-system-reference/SKILL.md @@ -91,6 +91,13 @@ Do NOT proceed to implementation without reading: - Generic hero sections with stock images - Overusing blur effects - Inconsistent spacing +- Cream / off-white page background +- Italic accent word inside a headline ("Build *faster*") +- Numbered "01 / 02 / 03" section labels +- Small uppercase monospace eyebrow labels above headings +- Pill-shaped buttons everywhere + +These are the model's current fallback defaults. "Avoid a generic look" alone just swaps one default for another, so name the pattern. If the chosen style genuinely calls for one of them (e.g. monospace in Terminal/Hacker, pill radius picked in discovery), it is a decision, not slop. When a first render still looks generic, identify which default it fell back to and add it to this list. #### ✅ 지향 - Make intentional design decisions with clear reasoning @@ -200,7 +207,7 @@ Spacing: Spacious Before completing the design, verify: - [ ] Does it reflect the key characteristics of the chosen style? -- [ ] Is it free from generic AI-generated aesthetics? +- [ ] Checked against every item in the AI Slop list above — none present unless the style explicitly calls for it? - [ ] Is the typography distinctive with clear hierarchy? - [ ] Is the color palette intentional and consistent? - [ ] Do animations match the specified level (none/minimal/moderate/rich)? diff --git a/plugins/wigtn-plugins/skills/handdrawn-diagram/SKILL.md b/plugins/wigtn-plugins/skills/handdrawn-diagram/SKILL.md deleted file mode 100644 index ef76b76..0000000 --- a/plugins/wigtn-plugins/skills/handdrawn-diagram/SKILL.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -name: handdrawn-diagram -description: >- - Generate a hand-drawn (sketch-style) architecture or flow diagram as a - committable image (SVG + PNG). Use when the user wants a sketch-aesthetic - diagram for a README, docs, or hackathon/Devpost submission that still has - correct, legible text and per-group color. Authors a Mermaid look:handDrawn - source and renders it with mermaid-cli — works on any viewer and on Devpost. - Triggers on: 'hand-drawn diagram', 'sketch diagram', '손그림 다이어그램', - '손그림 아키텍처', '스케치 도식', 'handDrawn', 'Devpost diagram', '손그림으로 그려줘', - '아키텍처 손그림', 'sketch-style architecture'. -allowed-tools: Read, Write, Edit, Bash, Glob ---- - -# Hand-drawn diagram (Mermaid look:handDrawn → SVG/PNG) - -Renders identically everywhere (README, GitHub/GitLab, Devpost, slides), unlike -inline Mermaid (depends on the viewer's Mermaid version) or AI image generators -(garbled text). - -## When to use - -- User asks for a "hand-drawn / sketch / 손그림" architecture or flow diagram. -- A diagram is needed as an image file (Devpost, slides, print), not just inline. - -## Method - -### 1. Write the Mermaid source (`.mmd`) - -``` ---- -config: - look: handDrawn - theme: default - themeVariables: - fontFamily: "Apple SD Gothic Neo, Malgun Gothic, Noto Sans KR, sans-serif" ---- -flowchart TD - A(["Trigger"]):::main --> B["main step"]:::main - B --> C{"decision?"}:::decision - C -->|Yes| D["highlighted path"]:::main - C -->|No| E["side step"]:::muted - D --> F[("datastore")]:::muted - E -.-> G["error / end"]:::alert - classDef main fill:#e8eaf6,stroke:#3949ab,stroke-width:2.5px,color:#1a237e; - classDef decision fill:#fff8e1,stroke:#f9a825,stroke-width:2px,color:#e65100; - classDef muted fill:#fff,stroke:#9e9e9e,color:#616161; - classDef alert fill:#ffebee,stroke:#e53935,color:#b71c1c; -``` - -Rules that avoid broken output: - -- **Quote every label** `["..."]` — `·—≥→/()` break unquoted. -- **No emojis in nodes** — headless Chromium renders them as tofu; use words. -- Shapes carry meaning — pick by role, not looks: - - `([..])` start/end terminal · `[..]` process step · `[(..)]` datastore/DB - - `{..}` **decision** (diamond) — every decision MUST have labeled branches: - `C{"logged in?"} -->|Yes| D` and `C -->|No| E`. Don't fake a branch with a - plain `[..]` step; a fork without a diamond + condition labels reads as a - straight line and loses the "when this / when that" meaning. -- `
` for line breaks; Korean renders fine with the fontFamily above. - -### Color — meaning, not decoration (60-30-10) - -Coloring every node flattens the hierarchy and nothing reads as important. Use -**2–3 colors that each carry meaning**, and let most nodes stay neutral: - -- **~60% `muted`** (neutral white/gray) — ordinary steps, datastores, side paths. -- **~30% `main`** (one accent, e.g. indigo) — the pipeline you want the eye to - follow (the happy path). Emphasize with `stroke-width`, not loud fills. -- **~10% semantic** — `decision` (amber) for diamonds, `alert` (red) for - error/terminal states. Reserve these so a color instantly signals a role. - -Rule of thumb: if a reader can't guess what a color *means*, drop it. One accent -is too plain (no role distinction); 5+ colors are noise (no hierarchy). 2–3 -semantic colors give both hierarchy and classification while staying calm. - -### 2. Render to SVG + PNG (no Chrome install needed) - -```bash -printf '%s\n' '{"args":["--no-sandbox","--disable-setuid-sandbox"]}' > /tmp/pptr.json -npx -y @mermaid-js/mermaid-cli -i d.mmd -o d.svg -p /tmp/pptr.json -b white -npx -y @mermaid-js/mermaid-cli -i d.mmd -o d.png -p /tmp/pptr.json -b white -s 2 -``` - -`-b white` = white bg · `-s 2` = 2× crisp PNG · `look:handDrawn` needs mermaid-cli v11+ (npx pulls latest). - -### 3. Verify → embed → commit - -- **Read/open the PNG** and confirm: text correct, no tofu, colors right. -- Embed the PNG: `![Title](path/d.png)`. -- Commit all three: `.mmd` (source) · `.svg` (vector master) · `.png` (embed). - -## Gotchas - -- macOS has no `timeout` — don't wrap npx in it. -- Never put a triple-backtick inside an unquoted `awk`/`grep` pattern (zsh runs backticks as command substitution). -- Label needs `&` → write `and`. -- **CJK clips in handDrawn nodes.** rough.js under-estimates Korean/CJK glyph - width, so a wide 한글 label gets cut at the node's right edge (e.g. "다이어그램" - → "다이어그"). Keep Korean node text short, break earlier with `
`, or pad - with a trailing space inside the quotes (`"손그림 다이어그램 "`). Always verify - in step 3 by reading the PNG. -- **Mixed Korean+Latin on one line clips the trailing Latin.** A line like - `"순차 BUILD"` loses its last char (→ "순차 BUIL"), but a pure-Latin line - (`"team BUILD"`) renders fine. So don't end a 한글 line with a Latin word: put - the Latin token on its own `
` line (`"순차
BUILD"`) or pad the line. - A single trailing space is often NOT enough — verify the PNG and add more. diff --git a/plugins/wigtn-plugins/skills/wigtn-ppt/SKILL.md b/plugins/wigtn-plugins/skills/wigtn-ppt/SKILL.md index 024b45e..5b3b2d2 100644 --- a/plugins/wigtn-plugins/skills/wigtn-ppt/SKILL.md +++ b/plugins/wigtn-plugins/skills/wigtn-ppt/SKILL.md @@ -20,7 +20,7 @@ WIGTN 브랜드 정체성을 담은 **단일 HTML 프레젠테이션**을 만드 1. **원칙 기반, 템플릿 무의존** — 고정 템플릿 파일에 의존하지 않는다. `references/`의 브랜드 토큰 + 레이아웃 원칙을 읽고 그때그때 생성한다. 2. **브랜드 충실도 (Brand Fidelity)** — 정확한 HEX만 사용한다. "비슷한 색" 금지. 잉크 `#1E1E28`, 시그니처 퍼플(Pantone 265) `#9B51E0`. -3. **시그니처 퍼플 점 반복** — `wigtn.` 의 마침표에서 온 퍼플 점(`.`)이 브랜드의 시그니처다. **모든 슬라이드**에 퍼플 점 모티프를 하나 이상 일관되게 반복한다 (페이지 번호 옆 점, 섹션 번호의 점, 코너 점, 진행 도트 등). +3. **시그니처 퍼플 점 반복** — 로고 `WIGTn.` 의 마침표에서 온 퍼플 점(`.`)이 브랜드의 시그니처다. **모든 슬라이드**에 퍼플 점 모티프를 하나 이상 일관되게 반복한다 (페이지 번호 옆 점, 섹션 번호의 점, 코너 점, 진행 도트 등). 4. **Light / Dark 듀얼 테마** — 발표 환경에 맞춰 둘 중 하나를 선택. 한 발표물 안에서 섞지 않는다. 5. **Viewport Fitting** — 모든 슬라이드는 정확히 `100vh`. 슬라이드 내부 스크롤 절대 금지. 6. **텍스트만의 슬라이드 금지** — 모든 슬라이드에 최소 하나의 시각 요소(도형·색 블록·점·라인·아이콘). @@ -60,7 +60,7 @@ WIGTN 브랜드 정체성을 담은 **단일 HTML 프레젠테이션**을 만드 ### Phase 2: 다이어그램 (필요 시) -흐름도·아키텍처가 필요하면 같은 플러그인의 **`handdrawn-diagram` 스킬**을 호출해 committable 다이어그램(SVG+PNG)을 만들고 슬라이드에 삽입한다. 손그림 톤이 부담되면 단순 CSS/SVG 도형으로 직접 그린다. +흐름도·아키텍처가 필요하면 같은 플러그인의 **`architecture-diagram` 스킬**을 호출해 committable 다이어그램(SVG+PNG)을 만들고 슬라이드에 삽입한다. 기본은 `clean` 스타일이고, 손그림 톤이 어울리는 발표면 `sketch`를 쓴다. ### Phase 3: HTML 생성 @@ -89,7 +89,7 @@ WIGTN 로고는 **팀 전용 에셋**이라 공개 플러그인에 커밋하지 1. `${CLAUDE_PLUGIN_ROOT}/skills/wigtn-ppt/assets/logo/` — 번들된 로고(팀 로컬, `*.png`는 gitignore라 공개 레포 클론엔 없음) 2. `/docs/images/` — 팀 로컬 브랜드 에셋 위치(역시 gitignore, 공개 레포엔 포함되지 않음) -3. **CSS/SVG 워드마크 폴백** — 위 두 경로에 PNG가 없으면(공개 클론·신규 환경의 기본 상태) `wigtn` + 퍼플 점을 폰트/SVG로 직접 렌더(brand.md에 스니펫). 폴백만으로도 항상 동작한다. +3. **CSS/SVG 워드마크 폴백** — 위 두 경로에 PNG가 없으면(공개 클론·신규 환경의 기본 상태) `WIGTn` + 퍼플 원을 폰트/SVG로 직접 렌더(brand.md에 스니펫). 폴백만으로도 항상 동작하지만 글꼴이 실제 로고와 다르므로, PNG가 있으면 항상 PNG를 쓴다. 로고를 쓸 때는 사용할 파일을 발표물 워크스페이스(예: `docs/workspace/`)로 **복사**한 뒤 HTML에서 상대경로로 참조한다. 테마별 변형은 brand.md의 매핑 표를 따른다(Light → NAVY 로고, Dark → WHITE 로고). @@ -106,12 +106,12 @@ WIGTN 로고는 **팀 전용 에셋**이라 공개 플러그인에 커밋하지 - **[references/brand.md](references/brand.md)** — WIGTN 브랜드 시스템: Light/Dark 팔레트, 로고 변형·배치, 퍼플 점 규칙, 타이포, CSS 워드마크 폴백 - **[references/design-guide.md](references/design-guide.md)** — 슬라이드 타입별 레이아웃 원칙(표지/목차/섹션/본문/마무리), viewport 규칙, 애니메이션, anti-pattern - **`design-system-reference` 스킬** — 레이아웃 밀도·타이포 위계 아이디어 재활용(Swiss Minimal, Editorial, Minimal Corporate가 WIGTN 톤에 가장 근접). 단, 색·점·로고는 **항상 WIGTN 토큰으로 덮어쓴다**. -- **`handdrawn-diagram` 스킬** — 다이어그램이 필요한 슬라이드 +- **`architecture-diagram` 스킬** — 다이어그램이 필요한 슬라이드 ## 체크리스트 - [ ] Phase 0: AskUserQuestion으로 테마(Light/Dark) + 콘텐츠 1회 수집 - [ ] Phase 1: 슬라이드 맵 + 밀도 제한 -- [ ] Phase 2: 다이어그램 필요 슬라이드 식별 (handdrawn-diagram) +- [ ] Phase 2: 다이어그램 필요 슬라이드 식별 (architecture-diagram) - [ ] Phase 3: brand.md + design-guide.md Read → 단일 HTML 생성 (viewport, 퍼플 점, 로고) - [ ] Phase 4: 스크린샷 검증 → 전달 diff --git a/plugins/wigtn-plugins/skills/wigtn-ppt/references/brand.md b/plugins/wigtn-plugins/skills/wigtn-ppt/references/brand.md index 0828c78..84cdaf7 100644 --- a/plugins/wigtn-plugins/skills/wigtn-ppt/references/brand.md +++ b/plugins/wigtn-plugins/skills/wigtn-ppt/references/brand.md @@ -4,7 +4,7 @@ WIGTN 발표자료의 브랜드 토대. **정확한 HEX만 사용한다. "비슷 ## 1. 브랜드 정체성 -- **워드마크**: `wigtn.` — 소문자, 기하학적 그로테스크 산세리프, 헤비 웨이트 +- **워드마크**: `WIGTn.` — W·I·G·T는 대문자, 마지막 n은 **소문자 모양을 대문자 높이로 키운 형태**(유니케이스: 로고 실측 n 284px = 대문자 283px). 기하학적 그로테스크 산세리프, 헤비 웨이트. 전부 소문자(`wigtn`), 전부 대문자(`WIGTN`), 작은 소문자 n으로 쓰지 않는다 - **시그니처**: 워드마크 끝의 **퍼플 마침표(`.`)**. 이것이 WIGTN의 단 하나뿐인 시그니처 요소다. - **톤**: 미니멀, 자신감, 테크. 잉크 네이비 위에 퍼플 한 점. @@ -103,7 +103,7 @@ PNG가 없어도 워드마크는 단순해서 직접 렌더할 수 있다. 폴 ```html -wigtn. +WIGTn ``` ```css .wigtn-wordmark{ @@ -111,10 +111,14 @@ PNG가 없어도 워드마크는 단순해서 직접 렌더할 수 있다. 폴 font-weight:700; letter-spacing:-.02em; color:var(--text-primary); font-size:clamp(1.4rem,2.4vw,2.2rem); } -.wigtn-dot-char{ color:var(--accent); } /* 마침표만 퍼플 */ +/* n은 대문자 높이까지 키운다: Space Grotesk 대문자/소문자 높이 비 700/486 = 1.44 */ +.wigtn-n{ font-size:1.44em; line-height:0; } +/* 점은 마침표 글리프가 아니라 원이다 — 지름 = 대문자 높이의 약 21.5%, 베이스라인 아래로 살짝 */ +.wigtn-dot{ display:inline-block; width:.16em; height:.16em; border-radius:50%; + background:var(--accent); margin-left:.13em; vertical-align:-.04em; } ``` -SVG가 필요하면 동일 원칙: 텍스트 `wigtn` + 퍼플 원(점). 점 지름은 글자 높이의 약 22%, 베이스라인에 정렬. +SVG가 필요하면 동일 원칙: `WIGT` + 대문자 높이로 키운 `n` + 퍼플 원(점). 점 지름은 대문자 높이의 약 21.5%, 아래쪽이 베이스라인보다 살짝 내려간다. 폰트가 Space Grotesk가 아니면 1.44 비율은 그 폰트의 대문자/소문자 높이 비로 바꾼다. ## 7. 타이포그래피 diff --git a/plugins/wigtn-plugins/skills/wigtn-ppt/references/design-guide.md b/plugins/wigtn-plugins/skills/wigtn-ppt/references/design-guide.md index aca3325..abd9df5 100644 --- a/plugins/wigtn-plugins/skills/wigtn-ppt/references/design-guide.md +++ b/plugins/wigtn-plugins/skills/wigtn-ppt/references/design-guide.md @@ -61,7 +61,7 @@ | Big Number / KPI | 지표 강조 | 초대형 숫자 + 라벨, 숫자 끝 퍼플 점 | | Quote | 인용/메시지 | 대형 인용문, 좌측 퍼플 세로바 | | Image + Text | 이미지 설명 | 좌 41% 텍스트 / 우 이미지(또는 반대) | -| Diagram | 흐름/구조 | handdrawn-diagram 또는 CSS/SVG 도형 | +| Diagram | 흐름/구조 | architecture-diagram 또는 CSS/SVG 도형 | | Timeline | 단계/로드맵 | 가로/세로 라인 + 노드(노드를 퍼플 점으로) | 공통: Slide Title 좌상단(`clamp(1.4rem,2.6vw,2rem)` Bold), 우하단 작은 마크 로고 + 페이지 번호.