CLAUDE.md · diff

git:20261022.9d2998e to git:20261023.d1cc008

2 added, 1 removed. Audit B to B.

# Projectops
완전 자동화된 GitHub 프로젝트 관리 템플릿
---
## ⚠️ 작업 브랜치 규칙 (agent 필독)
**이 프로젝트는 `develop` 브랜치에서 직접 작업하는 것을 기본값으로 한다. `main`은 프로덕션(default) — 직접 커밋·push 금지.**
- 별도 지시가 없으면 feature 브랜치를 만들지 말고 `develop`에서 작업·커밋·푸시한다.
- `develop` push 전에는 **항상 `git pull --rebase origin develop`** 으로 먼저 동기화한다 (릴리스 시 버전 확정 커밋이 develop에 추가되므로 로컬이 뒤처지기 쉽다).
- 릴리스(배포)는 develop→main PR로만 진행한다 (`/pro-changelog-deploy`). main 직접 push는 안전망(VERSION-CONTROL 가드)이 버전만 보전할 뿐 지원 경로가 아니다.
- `git push`는 **사용자가 명시적으로 요청한 경우에만** 실행한다.
- 사용자가 명시적으로 브랜치 작업을 요청한 경우에만 feature 브랜치를 사용한다.
- 커밋은 **`/pro-commit`을 사용한다.** 브랜치명에서 이슈 번호를 뽑아 컨벤션에 맞는 메시지를 만들고, 스테이징 대상도 함께 확인시켜 준다.
### 여러 에이전트가 동시에 작업한다 (agent 필독)
**여러 Claude Code 세션이 같은 `develop`에서 동시에 작업한다.** 각 세션은 서로의 존재나
작업 내용을 알지 못한다. 내가 만들지 않은 변경이 언제든 작업트리에 나타날 수 있다는
전제로 움직인다.
| 상황 | 대응 |
|---|---|
| 작업 시작 전 | `git pull --rebase origin develop`로 최신 상태를 먼저 받는다 |
| 커밋 전 | `git status`로 **내가 만들지 않은 변경**이 섞였는지 확인한다 |
| 스테이징 | `git add -A`보다 **내가 건드린 경로만** 명시해 담는다 |
| 브랜치가 바뀌어 있음 | 다른 세션이 옮긴 것일 수 있다. 임의로 되돌리지 말고 현재 브랜치를 존중한다 |
| 푸시 거부(non-fast-forward) | 강제 푸시하지 않는다. rebase로 통합한 뒤 다시 푸시한다 |
**금지**
- `git push --force` / `--force-with-lease` — 다른 세션의 커밋이 사라진다
- `git checkout .` · `git reset --hard` — 다른 세션의 미커밋 작업을 날린다
- 내 작업과 무관한 파일을 함께 커밋하는 것 — 원인 추적이 불가능해진다
의도를 모르는 변경이 작업트리에 있으면 **건드리지 말고 그대로 둔다.** 내 변경만 커밋하고
나머지는 그 세션이 처리하도록 남겨두는 것이 안전하다.
### 브랜치 3개념 (agent·마법사 필독 — #482)
혼동 주의: 프로젝트에는 브랜치가 **3개념**이 있고, version.yml 필드명이 직관과 어긋난다.
| 개념 | 뜻 | version.yml 필드 | 표준값 |
|------|-----|-----------------|--------|
| **기본(배포) 브랜치** | 레포 default·프로덕션. push되면 배포가 도는 곳 | `default_branch` | `main` |
| **개발(릴리스 소스) 브랜치** | 개발을 모아 기본 브랜치로 올리는 릴리스 PR의 head | `development_branch` (구 `deploy_branch`) | `develop` |
| 이슈 브랜치 | 작업 단위 (`YYYYMMDD_#번호_제목`) | — | — |
> ⚠️ **키 이름은 `development_branch`가 정본이다 (#915).** 구 이름 `deploy_branch`는 "배포 브랜치"로 오독되는 이름이라 정본에서 내렸고, 하위호환용으로만 남는다.
> - **읽기**: 모든 소비자가 `development_branch` → 없으면 `deploy_branch` → 없으면 `develop` 순으로 읽는다 (`src/core/version-yml.js` `readDevelopmentBranch`, `changelog_cli.py` `_parse_release_branches`).
> - **쓰기**: 생성기는 두 키를 **같은 값으로 함께** 쓴다 — 구 키만 읽는 구버전 스킬(플러그인은 레포와 따로 업데이트된다)이 조용히 `develop`로 폴백하지 않게 하기 위해서다. 업데이트(마법사 재실행)가 구 키만 있는 파일을 두 키로 다시 쓴다.
> - 워크플로우는 런타임에 이 키를 읽지 않는다 (설치 시 브랜치명을 치환해 넣는다).
> - 사용자 노출 문구에서 이 브랜치를 "배포 브랜치"라 부르지 말고 "개발(릴리스 소스) 브랜치"로 칭한다. CLI 플래그 `--deploy-branch`와 내부 변수명 `deployBranch`는 그대로다.
---
## ⚠️ 이슈 처리 규칙 (agent 필독)
**이 프로젝트는 이슈가 완료되면 완료 라벨을 붙이고 이슈를 닫는다(close).** (2026-10-10 소유자 결정으로 변경 — 이전엔 닫지 않고 라벨만 바꿨다.)
- 작업이 끝나면 이슈 상태 라벨을 **`status: done`**(기존 한글 레포는 `작업완료`)으로 바꾼 뒤 **이슈를 닫는다.**
`finish-issue {owner} {repo} {번호} [--outcome done|cancelled|in-progress]`로 한다 — 상태 라벨만 바꾸고(`type:`·`area:` 등 다른 라벨 유지) 닫는다.
`set-labels`는 라벨 **전체를 교체**하므로 `set-labels ... "status: done"`만 넘기면 다른 라벨이 전부 지워진다. `add-labels`만 쓰면 이전 상태 라벨이 남아 상태가 두 개가 된다.
- **완료 기준을 지킨다** — 구현이 main(또는 개발 브랜치)에 반영되고, 검증과 보고서(`/pro-report`)까지 끝난 것만 닫는다.
사용자가 특정 이슈를 **명시적으로 완료 처리하라고 하면** 이 기준을 다시 따지지 않고 처리한다.
남은 하위 작업이 있으면 닫지 않고 `status: in progress`로 둔다. 취소된 작업은 `status: cancelled`와 함께 닫는다.
- 라벨 변경과 close는 `/pro-github`(`github_cli.py finish-issue`)로 처리한다. 일부 단계만 실패하면 `failed_steps`의 단계만 `set-labels`(기존 라벨에서 상태만 바꾼 전체 목록) / `close-issue`로 다시 한다.
- **릴리스 워크플로우도 닫는다 (#771, `close_on_release`).** 완료 라벨이 붙은 이슈 중 릴리스 구간 커밋이 URL로 참조한 것을 닫는다. 에이전트가 먼저 닫아도 충돌하지 않는다(이미 닫힌 이슈는 건너뛴다). 이 저장소는 `version.yml`에서 켜져 있다.
- **업데이트는 사용자가 지운·제외한 워크플로우를 되살리지 않는다 (#810).** baseline에 있는데 없는 파일, `options.excluded_workflows`에 적힌 파일, 개발 브랜치가 원격에 없는데 `develop`에서만 도는 파일은 건너뛰고 `incoming/`에 사본을 남긴다. 새 조건부 설치 경로를 추가하면 `copy/workflows.js`의 `skipNew` 게이트를 거치게 한다. 회귀 방지: `test/skip-new-workflows.test.js`.
- **상태 라벨은 영문 표준(`status: todo` 등)과 기존 한글(`작업전` 등)을 둘 다 쓴다 (#776).** `label_style`이 표기를 정하고(신규 `en`, 기존 `ko` 유지), 스킬 공통 라벨 함수와 Projects 동기화는 두 표기를 서로 바꿔 인식한다.
---
## 프로젝트 개요
### 지원 프로젝트 타입
| 타입 | 설명 | 버전 동기화 파일 |
|------|------|-----------------|
| `spring` | Spring Boot | `build.gradle` |
| `flutter` | Flutter | `pubspec.yaml` |
| `react` | React.js / Next.js | `package.json` |
| `node` | Node.js | `package.json` |
| `python` | FastAPI/Django | `pyproject.toml` |
| `react-native` | React Native CLI | `Info.plist` + `build.gradle` |
| `react-native-expo` | Expo | `app.json` |
| `basic` | 범용 | `version.yml`만 |
> **멀티타입**: 단일 레포에 여러 타입 공존 시 `--type spring,react,python` csv로 지정. `version.yml`의 `project_types` 배열에 저장되며, 첫 항목이 primary 타입이다 (단수 `project_type` 키는 v4.1.0에서 제거됨 — SSOT).
>
> **모노레포 경로**: 타입별 프로젝트가 서브폴더에 있으면(예: `app/`, `client/`, `ai/`) `version.yml`의 `project_paths` 맵(타입 → 레포 루트 기준 상대경로)으로 지정한다. integrator가 통합 시 마커 파일(`pubspec.yaml`·`package.json`·`pyproject.toml`·`build.gradle` 등)을 자동 감지·확인하며, 키가 없으면 루트 기준(기존 동작 100% 유지). 비대화형은 `--paths "flutter=app,react=client"`(`.ps1`은 `-Paths`). `version_manager.sh`가 이 경로를 따라 서브폴더 버전 파일을 동기화하므로, `PROJECT-COMMON-VERSION-CONTROL` 워크플로우는 무수정으로 모노레포를 커버한다.
---
## 폴더 구조
```
projectops/
├── .github/
│ ├── workflows/
│ │ ├── PROJECT-TEMPLATE-INITIALIZER.yaml
│ │ ├── PROJECT-COMMON-*.yaml
│ │ └── project-types/
│ │ ├── common/ # 공통 원본 (+ secret-backup/ opt-in, deploy/vercel/ 배포타겟)
│ │ ├── flutter/ # Flutter 전용 (스토어 배포 워크플로우 루트 포함)
│ │ ├── spring/ # Spring 전용 (server-deploy/ + publish/{nexus,github-packages}/)
│ │ ├── python/ # Python 전용 (CI 루트 + server-deploy/)
│ │ ├── react/ # React/Next.js 공용 (next 타입은 v4.1.0에서 흡수됨)
│ │ └── node/ # Node 전용 (publish/npm/)
│ ├── scripts/
│ │ ├── version_manager.sh # + version_manager.py (실 로직, #448)
│ │ ├── changelog_manager.py
│ │ ├── truncate_release_notes.sh # + .py (실 로직, #448)
│ │ └── template_initializer.sh
│ ├── util/
│ │ ├── flutter/
│ │ │ ├── playstore-wizard/
│ │ │ ├── testflight-wizard/
│ │ │ ├── firebase-wizard/
│ │ │ └── oauth-wizard/ # Kakao·Google 로그인 (#156)
│ │ └── common/
│ │ ├── projects-sync-wizard/
│ │ ├── github-projects-sync-worker/
│ │ └── github-secrets-converter/
│ ├── ISSUE_TEMPLATE/
│ └── PULL_REQUEST_TEMPLATE.md
├── .claude-plugin/ # 플러그인 매니페스트
├── skills/ # 플러그인 Skills (마켓플레이스 전용)
├── scripts/ # 플러그인 Scripts (마켓플레이스 전용)
├── .cursor/skills/
├── docs/ # 상세 문서
├── version.yml
└── CHANGELOG.md / CHANGELOG.json
```
---
## 네이밍 컨벤션
### 워크플로우 파일
```
PROJECT-[TYPE]-[FEATURE]-[DETAIL].yaml
TYPE: TEMPLATE | COMMON | FLUTTER | SPRING | REACT
```
### 스크립트 파일
```
snake_case.sh / snake_case.py
```
---
## 핵심 워크플로우
### 공통 워크플로우
| 파일명 | 트리거 | 기능 |
|--------|--------|------|
| `PROJECT-TEMPLATE-INITIALIZER` | 저장소 생성 | 템플릿 초기화 (일회성) |
| `PROJECT-TEMPLATE-PLUGIN-VERSION-SYNC` | main 푸시 | 플러그인 매니페스트 버전 동기화 (**이 레포 전용**) |
| `PROJECT-TEMPLATE-CI` | develop·main 푸시/PR | 테스트 — 빠른 길/넓은 길 2갈래 (**이 레포 전용**, 아래 절) |
| `PROJECT-TEMPLATE-NPM-PUBLISH` | main 푸시 | `projectops` npm 패키지 배포 (**이 레포 전용**) |
| `PROJECT-COMMON-VERSION-CONTROL` | main 직접 푸시(안전망) | 릴리스 머지 외 push 시 patch 증가 |
| `PROJECT-COMMON-RELEASE-CHANGELOG` | main PR (develop→main) | 버전 확정(semver 승격 — #546) + AI 체인지로그 + automerge |
| `PROJECT-COMMON-README-VERSION-UPDATE` | main 푸시 | README 버전 동기화 |
| `PROJECT-COMMON-SUH-ISSUE-HELPER` | 이슈 생성 | 브랜치명/커밋 제안 (내부 py — `issue_helper.py`, #478 내재화) |
| `PROJECT-COMMON-QA-ISSUE-CREATION-BOT` | @projectops 멘션 | QA 이슈 자동 생성 |
| `PROJECT-COMMON-SYNC-ISSUE-LABELS` | 라벨 파일 변경 | GitHub 라벨 동기화 |
| `PROJECT-COMMON-TEMPLATE-UTIL-VERSION-SYNC` | version.json 변경 | Util HTML 버전 동기화 (util 모듈 보유 레포에만 복사 — #491) |
| `PROJECT-COMMON-PROJECTS-SYNC-MANAGER` | 이슈 라벨 변경 | Issue Label → Projects Status 동기화 (선택 — `common/projects-sync/`, `--projects-sync`, #716) |
| `PROJECT-COMMON-AI-PR-SUMMARY` | 작업 PR 생성·갱신 | 변경 요약 댓글 (선택 — `common/pr-summary/`, #566) |
### ⚠️ CI는 두 갈래다 — 무료 러너 가용량을 매 커밋에 쓰지 않는다 (#591 — agent 필독)
`PROJECT-TEMPLATE-CI`는 **이 저장소 자신의 코드**를 검사한다. 사용자 프로젝트로 복사되지 않으므로,
남의 레포가 아니라 우리 러너 가용량만 쓴다. 예전에는 **커밋 하나에 러너 9대**(3 OS × 2 Node + pytest 2 OS + 마법사)를 돌렸다.
| 갈래 | 언제 | 무엇을 | 러너 |
|---|---|---|---|
| **빠른 길** (`quick`) | develop 푸시·PR **매번** | node --test · pytest 전부 · 마법사 정합성 — **검사 항목은 하나도 안 뺀다** | ubuntu 1대 |
| **넓은 길** (`matrix-*`) | **main 푸시**·수동 실행 | 위를 3 OS × 2 Node 로 다시 | 8대 |
줄인 것은 "몇 개의 OS에서 돌리나"뿐이다. **OS 매트릭스를 지우지 마라** — 이 저장소는
macOS bash 3.2·BSD sed/grep(#415·#418)과 Windows `.cmd` 처리로 실제로 여러 번 깨졌다.
사용자에게 나가는 것은 main이므로 거기서 전부 본다.
### ⚠️ 로컬은 전부 돌리고, CI는 실제 도구가 필요한 것을 건너뛴다 (#591)
`skills/conftest.py`가 `local_only` 마커를 제공한다. 실제 브라우저·기기·서버가 있어야
의미 있는 테스트에 붙인다.
```python
@pytest.mark.local_only
def test_web_walks_real_browser():
...
```
| 환경 | 동작 |
|---|---|
| 로컬 | 도구가 있으면 **돈다**. 없으면 그 테스트만 건너뛴다 |
| CI (`CI=true`) | `local_only`는 **무조건** 건너뛴다 |
> **환경에 따라 건너뛰어지는 테스트는 "안 돌아간 것"과 구분이 안 된다 (#591).**
> `test_web_reports_missing_playwright_with_install_hint`가 그랬다 — Playwright가
> **깔려 있으면 skip**되도록 짜여 있어서 개발 기계에서는 영영 안 돌았고, 옛 키(`install`)를
> 보는 채로 CI에서만 터졌다. 초록불이 통과가 아니라 실행조차 안 된 것이었다.
>
> **도구가 진짜로 필요한 것만** `local_only`로 둔다. 문구·계약처럼 **순수 값 검사**는
> 없는 상태를 테스트가 직접 만들어(`playwright_hidden()`) 어디서나 항상 돌게 한다.
### ⚠️ 테스트 코드는 사용자에게 나가지 않는다 (#591)
`skills/` 는 Cursor·GitHub Copilot 설치 소스라 **통째로 `~/.cursor/skills/`·`~/.copilot/skills/` 로 복사된다** (#200).
스킬 CLI 가 import 하는 `scripts/`(common 등)도 옆 `~/.cursor/scripts/`·`~/.copilot/scripts/` 로 함께 복사된다 — 빠지면 CLI 가 import 에서 죽는다.
그래서 `skills/*/tests/`·`scripts/tests/`·`conftest.py`를 거르지 않으면 우리 테스트 900여 줄이 남의 컴퓨터에 쌓인다.
두 어댑터가 함께 쓰는 `src/core/ide/skills-copy.js`의 `SKIP_IN_COPY`·`TEST_PATH`가 막는다.
`scripts/` 의 관리자 전용 파일(`README.md`·`build_troubleshooting.py`·`sync_extras.py`)은 `SHARED_SCRIPTS_DEV_ONLY` 가 뺀다 (#988).
**`scripts/` 최상위에 관리자 전용 파일을 새로 두면 이 목록에 넣는다.** 런타임이 쓰는 항목을 넣으면 `test/ide.test.js`·`test/ide-home-install-e2e.test.js` 가 잡는다.
회귀 방지(설치본 CLI 실제 실행): `test/ide-home-install-e2e.test.js`.
**스킬에 새 보조 파일을 추가할 때 "이게 스킬을 쓰는 사람에게 필요한가"를 자문한다.**
회귀 방지: `test/ide.test.js`의 "테스트 코드는 복사하지 않는다".
### ⚠️ Flutter 마법사 4종은 공통 자산을 공유한다 (#521 — agent 필독)
`testflight` / `playstore` / `firebase` / `oauth`(#156) 마법사는 **`.github/util/flutter/_shared/`의 정본을 함께 쓴다.**
과거 각자 복사본을 들고 있다가 CSS 377줄 중복·같은 클래스명 반대 의미·iOS에만 개선 축적 같은 드리프트가 생겨 통합했다.
| 파일 | 역할 |
|------|------|
| `_shared/wizard.css` | 공통 컴포넌트 스타일 (툴팁·모달·토스트·코드블록·커스텀 Secret 등) |
| `_shared/wizard-common.js` | 공통 유틸 (이스케이프·클립보드·파일 변환·OS 감지·상태 저장·변경 이력 모달) |
| `_shared/wizard-i18n.js` | 언어 전환 엔진 (영문 기본·한국어 전환, #769). common 마법사도 로드한다. 한국어는 `<name>-wizard-i18n.js` 사전에 |
| `_shared/check-consistency.py` | 4종 정합성 검증 — **CI(`PROJECT-TEMPLATE-CI`)가 실행한다** (`check_i18n.py`로 사전 키 누락·한글 잔존도 검사) |
| `_shared/test_wizard_cli.py` | CLI 계약 · `--dry-run` 안전성 회귀 테스트 |
**마법사를 건드렸으면 반드시 통과시킬 것:**
```bash
python3 .github/util/flutter/_shared/check-consistency.py
python3 .github/util/flutter/_shared/test_wizard_cli.py
```
- **공통 유틸을 마법사 JS에서 재정의하지 않는다** (정본이 무력화된다 — 검증기가 잡는다).
- **로드 순서 고정**: `wizard.css` → `wizard-common.js` → `<name>-wizard.js`. 공통이 뒤로 가면 마법사 구현을 덮어쓴다.
- **`.step-indicator`는 "개별 단계 항목"이다** (전체 컨테이너 아님). firebase가 반대로 써서 충돌했던 자리다.
- **oauth 마법사는 단계가 동적이다** — 고른 제공자(Kakao·Google)의 단계만 `data-step` 순번을 받아, 공통 `showStep`·`prevStep`을 덮어쓰지 않고 쓴다. `.py`는 위치인자 이력이 없어 argparse이고, 공통 블록은 `쓰기 게이트`만 testflight·playstore와 글자 단위로 같게 둔다.
- **Python CLI 정본은 명명 플래그**(`--project-path` 등)이고, 구 위치인자 호출도 계속 받는다(마법사 HTML이 배포한 명령어가 그 형식).
- **`--dry-run`은 선언만 하면 안 된다.** 쓰기·복사·이동·삭제·디렉터리 생성·`keytool`이 전부 게이트 함수를 거쳐야 하고, "쓰고 다시 읽어 검증"하는 단계 때문에 메모리 오버레이로 실제 실행과 같은 경로를 타야 한다.
- `version.json`을 고쳤으면 해당 마법사의 `./version-sync.sh`를 실행해야 화면 변경 이력에 반영된다.
- `_` 로 시작하는 폴더는 모듈이 아니라 공유 자산이다 (`src/core/copy/util.js`의 모듈 카운트에서 제외됨).
상세: `.github/util/flutter/_shared/README.md`
### ⚠️ 워크플로가 부르는 fastlane lane 은 실재해야 한다 (#601 — agent 필독)
**Fastfile 은 마법사가 덮어쓴다.** lane 을 손으로 더하면 다시 깔 때 사라지므로,
**워크플로를 있는 lane 에 맞춘다.** 반대로 하면 안 된다.
| 템플릿 | 정의하는 lane |
|---|---|
| `testflight-wizard/templates/Fastfile.ios.template` | `deploy` |
| `playstore-wizard/templates/Fastfile.playstore.template` | `build_aab` · `deploy_internal` · `promote_internal_to_production` · `validate` |
실사고: iOS 테스트 빌드가 없는 `upload_testflight` 를 불러 **한 번도 TestFlight 에 올라간
적이 없었다.** IPA 는 정상 생성되고 **마지막 업로드 한 스텝에서만** 죽어서 오래 안 보였다.
배포 경로는 맞는 lane 을 불러 멀쩡했고 **테스트 경로만** 틀렸는데, 그 경로를 끝까지
밟아본 사람이 없었다. 같은 종류를 찾아보니 Android 쪽에도 둘 더 있었다(`fastlane build`).
직접 만든 Fastfile 에만 있는 lane 을 쓰려면 **있는지 확인하고 부른다.**
```bash
if [ -f "android/fastlane/Fastfile" ] \
&& grep -qE '^[[:space:]]*lane[[:space:]]*:build([[:space:]]|$)' android/fastlane/Fastfile; then
( cd android && bundle exec fastlane build --verbose ) # 서브셸 — cd 가 새면 뒤가 엉킨다
else
flutter build apk --release
fi
```
> **🔴 테스트 빌드는 `DEPLOY_MODE` 를 반드시 못 박는다.** iOS `deploy` lane 은 이 값에 따라
> **심사 자동 제출(`submit_for_review`)까지** 간다. 기본값에 기대고 있으면 Fastfile 기본값이
> 바뀌는 순간 테스트 빌드가 앱 심사에 올라간다 — 되돌릴 수 없는 종류의 사고다.
> 테스트 경로는 `export DEPLOY_MODE="store_only"` 를 명시한다.
회귀 방지: `.github/scripts/test/test_fastlane_lanes.py`
### ⚠️ 빌드 플래그는 두 층이 함께 가야 한다 (#603 — agent 필독)
앱의 개발 스위치는 **두 층**에 있고, **한 층만 맞아도 빌드는 통과한다.**
| 층 | 어디에 | 위조 |
|---|---|:-:|
| 런타임 | `.env` (dotenv) | 가능 (Secret 주입) |
| 컴파일타임 | `--dart-define` | **불가** (바이너리에 박힌다) |
실사고: `.env` 에 개발 플래그가 찍히고 로그는 `✅ 적용 완료` 를 냈는데, **설치해 보면
디버깅 도구가 없었다.** 릴리스 빌드라 컴파일타임 게이트가 닫혀 있었고, 레포 전체에
`dart-define` 이 한 군데도 없었다 — 즉 **한 번도 켜진 적이 없었다.** 로그만 봐서는 알 수 없다.
**`.github/scripts/apply_build_profile.py` 가 한 곳에서 두 층을 모두 정한다.**
```yaml
- name: Apply build profile (test)
id: build_profile
run: python3 .github/scripts/apply_build_profile.py test .env
- name: Build
# 🔴 여기에 dart-define 을 직접 적지 않는다. 적는 순간 다시 두 곳이 된다.
run: flutter build apk --release ${{ steps.build_profile.outputs.build_flags }}
```
- **설정(`.github/config/build-profile.json`)이 없으면 아무 일도 하지 않는다.**
남의 저장소에 설치되는 템플릿이므로 이것이 기본값이다. 예시는 `.example` 파일.
- `release` 라는 이름의 프로파일에는 **검증이 걸린다** — 개발 키가 켜진 채로, QA 토큰이
남은 채로, 테스트 전용 dart-define 이 섞인 채로 배포 빌드가 나가면 **빌드를 세운다.**
- **`.env` 생성과 빌드가 다른 job 이면** 프로파일 결과를 `outputs.build_flags` 로 넘긴다
(`IOS-TEST-TESTFLIGHT` · `IOS-TESTFLIGHT` 가 그렇다). 같은 job 이면 `steps.` 로 읽는다.
- **fastlane lane 으로 빌드하는 경로에는 플래그를 넘길 자리가 없다.** 플래그가 있는데
그 경로를 타면 **빌드를 세운다** — 조용히 빠뜨리면 이 스텝이 고치려던 사고가 다시 난다.
- 플레이버 이름은 **`APP_FLAVOR`** 처럼 앱 이름이 안 들어가는 중립적인 것을 쓴다.
Flutter 표준 `FLUTTER_APP_FLAVOR` 는 예약어라 `--dart-define` 으로 못 넣는다
(`--flavor` 전용이며, 그건 Gradle productFlavors·Xcode scheme 을 요구한다).
적용된 워크플로우: `ANDROID-TEST-APK`·`IOS-TEST-TESTFLIGHT`(test) ·
`ANDROID-PLAYSTORE-CICD`·`ANDROID-FIREBASE-CICD`·`ANDROID-SELFHOSTED-CICD`·`IOS-TESTFLIGHT`(release)
회귀 방지: `.github/scripts/test/test_build_profile.py`
### ⚠️ 워크플로우는 `permissions:` 를 반드시 선언한다 (#595 — agent 필독)
**선언하지 않으면 레포 기본값을 따르는데, 그 기본값은 레포마다 다르고 나중에 바뀐다.**
| 기본값 | 선언 안 했을 때 |
|---|---|
| `read` (GitHub 신규 레포 기본값) | 댓글을 다는 워크플로우가 **그 스텝에서 죽는다** |
| `write` | 읽기만 하면 되는 배포 워크플로우가 **과한 권한**을 갖는다 |
실사고: `@projectops apk build` 테스트 빌드가 항상 실패했다. 트리거 워크플로우에는 블록이
있어 **트리거까지는 성공**했고, 정작 빌드가 진행 상황 댓글을 다는 순간 막혔다.
겉보기에는 "빌드가 도는데 결과가 없다"로 보여 원인을 찾는 데 오래 걸렸다.
```
RequestError [HttpError]: Resource not accessible by integration
```
| 하는 일 | 적을 것 |
|---|---|
| 이슈·PR에 댓글·라벨 | `contents: read` + `issues: write` + `pull-requests: write` |
| PR에만 댓글 | `contents: read` + `pull-requests: write` |
| 체크아웃·빌드·배포만 | `contents: read` |
| 커밋을 푸시 | `contents: write` |
| 별도 PAT로만 API 호출 | `contents: read` (GITHUB_TOKEN 은 안 쓴다) |
위치는 최상위 `on:` 블록 **바로 뒤**다 (job 단위로 적어도 된다).
회귀 방지: `.github/scripts/test/test_workflow_permissions.py` 가 **모든** 워크플로우를 검사한다.
> **⚠️ `.yaml` 만 훑지 말 것.** 이 저장소에는 `.yml` 확장자 워크플로우도 5개 있다
> (`NEXUS-*` · `GITHUB-PACKAGES-PUBLISH` · `TEMPLATE-UTIL-VERSION-SYNC`).
> 실제로 `*.yaml` 만 본 첫 점검에서 한 건을 놓쳤고 테스트가 잡았다.
> **⚠️ `repository_dispatch` 는 기본 브랜치의 워크플로 정의로 돈다.** 이 트리거를 쓰는
> 워크플로우(`ANDROID-TEST-APK` · `IOS-TEST-TESTFLIGHT` · `APP-BUILD-TRIGGER`)를 develop
> 에서 고치고 테스트 빌드를 다시 걸면 **여전히 옛 정의가 실행된다.** 수정이 안 먹은 것처럼
> 보여 원인을 엉뚱한 데서 찾게 되므로, 반드시 기본 브랜치에 반영된 뒤에 확인한다.
### 옵션 정본은 한 곳이다 (agent 필독)
CLI 플래그와 `version.yml` 옵션의 이름·값·기본값은 **`src/core/options-schema.js`** 한 곳에 있고,
`npx projectops --mode options --json`이 그대로 출력한다. AI agent는 문서를 뒤지지 않고 이 명령으로 확인한다.
- 새 플래그(`src/cli/args.js`)나 `version.yml` 옵션(`parseTemplateOptions`)을 추가하면 **정본에도 반드시 추가한다.**
`test/options-schema.test.js`가 코드와 정본을 대조해 어긋나면 실패한다.
- 문서에 옵션 표를 새로 복제하지 않는다 — 복제본은 낡는다. 문서는 정본을 가리킨다.
- **옵션 값(플래그 > 저장값 > 기본값)은 `src/core/resolve-options.js` 한 곳에서 정한다 (#851).** `index.js`(비대화형)는 결과를 그대로 쓰고, `interactive.js`는 초기값으로 쓴 뒤 사용자가 답한 것만 덮어쓴다. 진입점에 기본값을 다시 적지 않는다 — 두 경로가 갈라져 대화형에서 PR 요약 선택이 버려진 적이 있다. 두 경로 결과 비교: `test/resolve-options.test.js`.
- 사용자 레포에는 `.github/.projectops/AGENT-GUIDE.md`가 설치된다(`src/core/agent-guide.js`). 옵션 값은 박지 않고 명령만 안내한다.
- 이 레포의 `AGENTS.md`·`GEMINI.md`·`llms.txt`는 템플릿 전용이라 `exclusions.js`와 `template_initializer.py` 양쪽에서 제외된다.
### 문서·데모 GIF는 읽는 언어를 따른다 — 기본은 영어 (#976)
| 대상 | 영어 (기본) | 한국어 |
|---|---|---|
| 문서 사이트 URL | `/projectops/<slug>` ← `docs/en/<slug>.md` | `/projectops/ko/<slug>` ← `docs/<SLUG>.md` |
| 데모 GIF | `docs/images/<이름>.gif` | `docs/images/<이름>.ko.gif` |
| 쓰는 곳 | `README.md`, ja·zh-CN README, `docs/en/` | `docs/i18n/README.ko.md`, `docs/*.md` |
- **파일은 옮기지 않고 URL만 바꾼다.** 규칙은 `site/.vitepress/locale-paths.mts` 한 곳(`rewrites`와 옛 URL 리다이렉트가 같이 쓴다). 두 언어 slug가 같아야 언어 전환이 같은 쪽으로 간다 — 한국어 문서를 추가하면 같은 slug의 영어 문서도 만든다.
- **GIF는 손으로 찍지 않는다.** `docs/recording/`의 스크립트로 언어별로 다시 찍는다 (`README.md` 참고). 시스템 언어가 새지 않게 `PROJECTOPS_LANG`/`--language`를 명시하고, npm 배포본이 아니라 작업 트리 CLI·템플릿으로 찍는다.
- 마법사 배포 환경값 문구(`.github/config/wizard-prompts.yml`)는 `label`=영어, `label_ko`=한국어다. 새 키는 두 언어를 함께 넣는다 (`test/wizard-labels.test.js`가 검사).
### 영문 표면의 한글 줄은 줄기만 한다 (#769)
- `scripts/tests/test_korean_line_budget.py`가 skills·src·워크플로·스크립트·마법사·README 등에서 **주석·docstring을 뺀 한글 줄 수**를 파일별로 세어 동결 장부 `scripts/tests/korean_line_budget.json`과 대조한다. 장부에 없는 파일은 0이어야 하고, 있는 파일은 장부 숫자를 넘으면 실패한다.
- 한글을 걷어 내 숫자가 줄면 테스트가 "장부를 낮춰라"로 실패한다 → `python3 scripts/tests/test_korean_line_budget.py --update` (줄어든 숫자만 반영, 절대 올리지 않음).
- 사용자에게 보이는 문구는 영어로 쓰고 한국어는 i18n ko 사전에 넣는다. 세는 파일에 한글을 새로 넣으려면 **코드가 대조하는 데이터**(한글 라벨 `작업전` 같은 것)처럼 강한 이유가 있어야 하고, 그때만 장부 숫자를 손으로 올리고 리뷰에서 이유를 밝힌다.
- 제외 범위(ko 사전·`docs/`·테스트·pro-launch troubleshooting 등)와 사유는 테스트 파일의 `EXCLUDED_PATTERNS`가 정본이다.
### 실행 기록 3계층 (#493·#494·#561 — agent 필독)
마법사(full/workflows)가 끝나면 **대상 레포**의 `.github/.projectops/logs/`에 실행 기록을 남긴다.
`docs/` 아래였던 위치를 #561에서 옮겼다 — 사람이 읽는 문서가 아니라 **Agent가 원인을 짚는 진단 자료**라
저장소 이력에 남을 이유가 없다.
| 계층 | 파일 | 내용 | 소스 모듈 |
|------|------|------|----------|
| 1 | `PROJECTOPS-MIGRATION-GUIDE.md` | 고정 헤더(AI 해석 가이드라인) + 실행 엔트리 append-only: 동적 체크리스트·통과한 breaking 전문·yaml 메타(`schema` 버저닝) | `src/core/migration-guide.js` |
| 2 | `{stamp}_v{from}_to_v{to}.jsonl` | 구조화 이벤트 (파일명 grep으로 인과 추적) | `src/core/run-trace.js` |
| 3 | `{stamp}_..._.log` | **서버 로그 형식** — 내부 이벤트 + 터미널 출력이 시간순 병합 | 〃 |
**건너뛴 워크플로의 새 템플릿은 `incoming/`에 남긴다 (#654).** 사용자가 수정한 워크플로는 덮어쓰지 않고
`skipped-conflict`로 유지하는데, 무엇이 다른지 알 방법이 없었다. 그래서 새 템플릿(env·브랜치 치환 적용본)을
`.github/.projectops/incoming/<파일명>`에 저장하고, 종료 화면·`skipped-conflict` 이벤트 detail(`incoming`,
`added`, `removed`)·MIGRATION-GUIDE 체크리스트에 비교 명령(`diff -u`, `git diff --no-index`)을 남긴다.
- **`logs/`와 같은 패턴**: 폴더가 자체 `.gitignore`(`*` + `!.gitignore`)를 들고 다니므로 루트 `.gitignore`는 건드리지 않는다.
- **`.github/workflows/`에는 절대 쓰지 않는다** — 거기 생긴 파일은 GitHub이 실제 워크플로로 실행한다.
- **실패해도 마법사를 죽이지 않는다** — 저장·diff 실패는 `incoming-failed` 이벤트와 경고 한 줄로 흡수한다. 종료 코드는 그대로다.
- 코드: `src/core/incoming.js`, 호출은 `copy/workflows.js`의 `applyDecision`. 회귀 방지: `test/incoming-merge-guide.test.js`.
**추적 제외는 폴더가 스스로 들고 다닌다.** `logs/.gitignore`(`*` + `!.gitignore`)를 매 실행마다 보장하므로
루트 `.gitignore`를 건드리지 않는다. 형제인 `.github/.projectops/baseline.json`(#557)은 팀원 공유
자산이라 **계속 추적된다** — 폴더를 나눈 이유가 이것이다.
**로그 레벨** (`levelOf`): `ERROR`(실패) / `WARN`(두면 실패하거나 판단 필요) / `INFO`(단계 경계·확정된 판단) /
`DEBUG`(건별 상세). **DEBUG도 파일에는 전부 남긴다** — 문제가 터진 뒤에 "그때 켰더라면"은 소용없다.
- 엔진(copy/env/legacy/orphan)은 `hooks.trace` / `ctx.trace`로 **null-safe emit** — trace 미주입 경로(기존 테스트)는 무영향.
- **단계는 `trace.step(name, fn)` / `stepAsync`로 감싼다** — 진입·종료·소요 ms·실패가 자동 기록된다.
개별 호출부에 start/done을 흩뿌리면 반드시 빠뜨리는 자리가 생긴다.
- 가이드의 워크플로우 목록·env 값은 trace events에서 **파생**한다 (단일 소스). 새 엔진 동작을 추가하면 이벤트 emit도 함께 추가할 것.
- **호출은 `trace.event(phase, action, target, detail)`이다.** `emit`은 존재하지 않는다 —
optional chaining(`?.`) 때문에 오타가 예외 없이 조용히 무시되어 기록이 통째로 사라진 적이 있다(#561).
- 민감값(PAT·token·secret·password·credential 키)은 `scrubDetail`이 이벤트에서 자동 제거 — **우회 금지**.
값이 비밀이 아닌데 키 이름이 걸리면(`token` → `placeholder`처럼) **키 이름을 바꾼다**.
- **어떤 경로로 끝나도 기록이 남아야 한다**: 정상 완주·중간 취소·예외는 `finally`의 `trace.finalize()`(멱등),
강제 종료(Ctrl+C)는 `trace.armSignals()`가 처리한다. 새 종료 경로를 만들면 이 계약을 확인할 것.
- **완료 화면 출력은 `try` 안에, `finalize()`는 `finally`에만 둔다 (#561).** `finally`는 try를 빠져나온
직후 돌기 때문에, 완료 화면을 try 밖에 두면 기록이 먼저 닫혀 **그 화면이 `.log`에서 통째로 빠진다.**
실제로 사용자 실행 로그가 `step/done install-full`에서 끊겨 "로그에 아무 안내가 없다"는 신고로 돌아왔다.
`finalize()`는 멱등이라 뒤늦게 다시 불러도 복구되지 않는다 — 순서가 곧 계약이다.
회귀 방지: `test/log-capture.test.js`.
### 타입별 워크플로우
#### Flutter
| 파일명 | 용도 | 위치 |
|--------|------|------|
| `PROJECT-FLUTTER-CI` | 코드 분석 + 빌드 검증 | 기본 |
| `PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD` | Play Store 배포 | 기본 |
| `PROJECT-FLUTTER-ANDROID-FIREBASE-CICD` | Firebase App Distribution | 기본 |
| `PROJECT-FLUTTER-ANDROID-TEST-APK` | 테스트 APK 빌드 | 기본 |
| `PROJECT-FLUTTER-IOS-TESTFLIGHT` | TestFlight 배포 | 기본 |
| `PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT` | 테스트 빌드 | 기본 |
| `PROJECT-FLUTTER-IOS-ASC-STATUS` | App Store Connect 버전 상태, 최근 빌드 번호 조회 (수동 실행, 조회 전용, #651) | 기본 |
| `PROJECT-FLUTTER-PROJECTOPS-APP-BUILD-TRIGGER` | 댓글 트리거 빌드 | 기본 |
| `PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD` | 자체 서버(SMB) APK 배포 | 기본 |
#### ⚠️ 배포/publish 타겟 축 (#439, v4.2.0 — agent 필독)
배포는 **두 개의 독립 축**으로 표현된다. 마법사가 타입 확정 직후 물어본다 (단 `basic` 단독은 스킵 — 아래).
| 축 | 의미 | 다중성 | 값 | version.yml 키 |
|----|------|--------|-----|---------------|
| **deploy** | 실행물(서버/앱)을 어디에 올리나 | **택1** | `docker-ssh`(기본)·`vercel`·`none` | `options.deploy` |
| **publish** | 라이브러리/패키지를 어느 레지스트리에 내나 | **0..n 공존** | `nexus`·`npm`·`github-packages` | `options.publish` 배열 |
##### 🧭 intent(프로젝트 성격) 우선 분기 (#485, v4.2.14 — agent 필독)
두 축을 낱개로 묻던 구조가 사용자에게 "이게 나한테 해당되나?" 부담을 줬다. 이제 **먼저 프로젝트 성격(intent)을 한 번 묻고, 그 답이 위 두 축을 유도**한다.
| intent | deploy 질문 | publish 질문 | version.yml 키 |
|--------|------------|-------------|---------------|
| `app` | 물음 | **스킵**(`publish=[]`) | `options.intent` |
| `library` | **스킵**(`deploy=none`) | 물음 | |
| `both` | 물음 | 물음 | |
| `none` | **스킵**(`none`) | **스킵**(`[]`) | |
| `manual` | 물음 | 물음(기존 순차 질문 그대로 — 탈출구) | |
- `metadata.template.options.intent`에 저장. 구 version.yml(intent 없음)은 `deploy`/`publish` 값에서 **역추론**(`inferIntent`, `src/core/version-yml.js`)해 하위호환. deploy≠none&publish=[]→app, deploy=none&publish≠[]→library, 둘 다→both, 둘 다 없음→none.
- 비대화형 `--intent app|library|both|none|manual`. 미지정 시 `--deploy`/`--publish`에서 역추론(기존 CLI 무수정 동작). intent 명시 + 해당 축 플래그 없으면 intent가 그 축을 유도(library→deploy=none 등, `src/index.js`).
- 수정 메뉴에 "프로젝트 성격" 항목(#485) + 개별 축 항목(#483) 병존. 성격을 바꾸면 축이 재유도되고, 개별 축 항목은 그 축만 세밀 조정(`scope`).
- **`basic` 단독 타입은 intent 질문도 스킵** — `none`으로 확정.
##### 🧩 타입별 축 적용성 (#498 — agent 필독)
deploy/publish 축은 **타입에 따라 적용 자체가 안 될 수 있다.** `src/core/options-ask.js`의 `TYPE_DEPLOY_TARGETS`/`TYPE_PUBLISH_TARGETS` 선언이 타입별 적용 가능 타겟을 정의하고, `applicableTargets(types)`가 선택 타입 집합의 합집합을 산출한다.
- **모바일 앱 타입(flutter/react-native/react-native-expo)은 두 축 모두 빈 배열** — 스토어 배포(Play Store/TestFlight/Firebase 등)는 타입 워크플로우가 항상 포함되므로 서버 deploy 축과 무관하고, 레지스트리 publish 개념도 없다. basic처럼 **intent 질문까지 통째로 스킵**하고 `none`/`[]`로 조용히 확정한다 (안내 문구도 안 띄움 — 보여줄 이유가 없다).
- **저장값/CLI값이 적용 불가면 경고 없이 정리** — 구버전에서 flutter 단독에 `deploy: docker-ssh`가 저장된 레포도 업데이트 시 조용히 `none`/교집합으로 정리된다 (복사 결과 동일 — SSOT). 비대화형(`src/index.js`)도 동일 규칙.
- 수정 메뉴(`editMenu`)도 적용 불가 축 항목("프로젝트 성격"·"배포 방식"·"publish 타겟")을 숨긴다. 타입을 바꾸면 다음 진입부터 다시 노출.
- 서버형 타입(spring/react/node/python)은 현행 선택지 전체 유지 (동작 무변경 — 타입별 세분화는 후속 과제). 질문 선택지는 `applicable.*`로 필터링되므로 이후 선언만 좁히면 선택지가 자동 반영된다.
- **새 타입을 추가하면 두 선언에 항목을 반드시 넣는다** — `test/options-ask.test.js`의 정합성 테스트가 `project-types` 폴더(`server-deploy/`·`publish/<target>/`)와의 모순을 자동 검증한다. 미선언 타입은 보수적으로 전체 허용(질문 유지).
- **`basic` 단독 타입은 배포/publish 질문을 건너뛴다** — 서버·라이브러리 개념이 없는 범용 타입이라 물어보면 사용자가 당황한다. `deploy=none`·`publish=[]`로 조용히 확정하고 분석 카드에만 표시. 타입을 바꾸면(수정 메뉴 → 프로젝트 타입) 그때 질문이 등장한다. (구 js `isBasicOnly` 특례는 #498의 두-축-빈-타입 일반 판정 `noAxes`로 흡수됨 — `.sh`/`.ps1`은 #458 EOF)
- 실타입 질문 앞에는 "**왜 묻는지**" 맥락 한 줄을 붙인다 (서버 올릴 계획 있으면 고르고 없으면 넘기라는 안내).
- 비대화형: `--deploy docker-ssh|vercel|none` + `--publish nexus,npm`(csv). `.ps1`은 `-Deploy`/`-Publish`.
- **구 플래그 `--nexus`/`--npm-publish`는 deprecated**(1 minor 유지) — `--publish nexus`/`--publish npm`으로 해석되며 경고를 출력한다. `--nexus`는 추가로 `--deploy none`을 함의(구 동작 보존).
- version.yml의 구 키(`nexus`/`npm_publish`)는 통합/업데이트 시 **자동으로 신 축으로 변환·기록**된다 (SSOT — 이중 표기 금지).
#### Spring
| 파일명 | 용도 | 위치 |
|--------|------|------|
| `PROJECT-SPRING-SIMPLE-CICD` | SSH+Docker 배포 (기본, 단일 컨테이너) | spring/server-deploy/ |
| `PROJECT-SPRING-NONSTOP-TRAEFIK-CICD` | 무중단 배포 (Traefik Blue-Green) | spring/server-deploy/ |
| `PROJECT-SPRING-NONSTOP-NGINX-CICD` | 무중단 배포 (Nginx Blue-Green) | spring/server-deploy/ |
| `PROJECT-SPRING-PR-PREVIEW` | PR 프리뷰 배포 | spring/server-deploy/ |
| `PROJECT-SPRING-NEXUS-CI` / `-NEXUS-PUBLISH` | Nexus 라이브러리 배포 | spring/publish/nexus/ |
| `PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH` | GitHub Packages 라이브러리 배포 | spring/publish/github-packages/ |
> 서버 배포 워크플로우(SIMPLE/NONSTOP-*/PR-PREVIEW)는 `server-deploy/`로 묶여 **`deploy=docker-ssh`일 때만 포함**된다(`vercel`/`none`이면 폴더째 제외). publish 워크플로우는 `<type>/publish/<target>/`에 있고 **선택된 publish 타겟 집합**으로 복사가 결정된다(타입은 파일 위치일 뿐 게이트 아님).
>
> **확장 규칙(agent 필독)**: 새 "서버 배포" 워크플로우는 **그 타입의 `<type>/server-deploy/`** 에 파일만 넣는다(deploy≠docker-ssh면 자동 제외). 현재 `server-deploy/`를 가진 타입은 `spring`·`python` 두 개다. **타입 폴더 직하위에 두면 `--deploy none`에서도 복사되므로 절대 그렇게 두지 않는다** (실제로 python이 이 함정에 빠져 #521에서 교정됨). 새 publish 타겟은 `<type>/publish/<target>/`에 넣고 마법사 질문 목록에 값을 추가한다. 타입 비종속 배포 타겟(Vercel 등)은 `common/deploy/<target>/`에 넣는다.
>
> **⚠️ 워크플로우를 리네임/삭제할 때 (agent 필독, #470)**: 구 이름을 `src/core/migrations/registry.js`에 반드시 추가한다 — 마법사 업데이트가 기존 통합 레포의 구 파일을 자동 무해화(.bak)하는 유일한 경로다(레거시 마이그레이션은 전부 이 레지스트리 한 곳에서 관리). tier는 `safe`(순수 리네임 — 공존 시 중복 실행 실해) / `confirm`(배포 파이프라인일 수 있음 — 자동 조치 없이 안내만) 중 실해 기준으로 고른다. `test/migrations.test.js`가 레지스트리와 현행 배포 세트의 충돌(살아있는 워크플로우 오살)을 자동 검증한다. 구 파일에 사용자 커스텀 설정이 들어있을 수 있으면 registry 항목에 `settingsExtractor`를 지정해 무해화 직전 version.yml로 자동 이관한다 (`rules/settings-extractors.js`, #478 이슈 헬퍼가 모범 사례).
> 참고: **타입 선택 해제로 남는 고아 워크플로우**는 registry가 아니라 `src/core/orphan-workflows.js`가 동적 감지한다(#487) — registry는 리네임·폐기 전용, 고아 정리는 템플릿 인벤토리 대조(정확한 파일명 일치) 방식이다. 대화형은 확인 후 .bak 무해화, 비대화형은 안내만 출력한다.
>
> **⚠️ `common/` 아래에 조건부 폴더를 새로 만들 때 (agent 필독, #567·#851)**: `src/core/workflow-groups.js`의 `commonOptionalGroups`에 한 줄을 넣는다. 복사 게이트(`copy/workflows.js`)와 고아 감지(`orphan-workflows.js`)가 **같은 표**를 읽는다. 예전에는 두 곳을 따로 고쳐야 했고, 켜는 쪽만 만들어 **끈 뒤에도 파일이 남아 계속 실행되는** 일이 `secret-backup`·`deploy/<target>`에서 실제로 있었다. 표에 빠진 폴더는 `test/workflow-groups.test.js`가 잡는다.
>
> **`.github/util/` 모듈 안의 파일을 리네임/폐기할 때도 동일하다 (#500)**: registry에 `category: "util-file"`(tier safe, 정확 경로)로 구 파일을 등록한다 — util 복사(`src/core/copy/util.js`)는 overlay라 구 파일을 지우지 않으므로 registry가 유일한 정리 경로다. `test/migrations.test.js`의 util-file 충돌 테스트가 현행 템플릿 파일 오살을 자동 검증한다.
>
> **⚠️ 브랜치 규칙(`YYYYMMDD_#번호_제목`)에 의존하는 워크플로우를 추가할 때 (agent 필독, #478)**:
> `.github/scripts/issue_helper.py`의 `GUIDE_LINES`에 (파일명, 안내 문구)를 추가하고,
> 워크플로우 헤더에 "⚠️ 브랜치 규칙 의존" 주석 블록을 넣는다. 상세: `docs/BRANCH-CONVENTION.md`
#### 공통 — 배포 타겟 / Secret 백업
| 파일명 | 기능 | 위치 | 조건 |
|--------|------|------|------|
| `PROJECT-COMMON-VERCEL-DEPLOY` | Vercel 프로덕션 배포 (React/Next 등) | common/deploy/vercel/ | `--deploy vercel` |
| `PROJECT-COMMON-SECRET-FILE-UPLOAD` | GitHub Secret → 서버(SSH) 업로드 | common/secret-backup/ | `--secret-backup` |
| `PROJECT-COMMON-AI-PR-SUMMARY` | 작업 PR에 변경 요약 댓글 | common/pr-summary/ | PR 댓글 질문에서 선택 |
> Vercel은 `VERCEL_TOKEN`·`VERCEL_ORG_ID`·`VERCEL_PROJECT_ID` secret이 필요하다.
#### Python (FastAPI/Django)
| 파일명 | 용도 | 위치 |
|--------|------|------|
| `PROJECT-PYTHON-CI` | 빌드/검증 | 기본 |
| `PROJECT-PYTHON-SIMPLE-CICD` | SSH+Docker 배포 (단일 컨테이너) | python/server-deploy/ |
| `PROJECT-PYTHON-PR-PREVIEW` | PR 프리뷰 배포 | python/server-deploy/ |
> Spring과 동일한 SSH+Docker 엔진을 쓴다. 배포 2종은 `server-deploy/`에 있어 `deploy=docker-ssh`일 때만 포함된다 (#521에서 타입 루트 → `server-deploy/`로 이동, 그전엔 `--deploy none`에도 복사되던 버그가 있었다).
#### React (Next.js 포함)
| 파일명 | 용도 |
|--------|------|
| `PROJECT-REACT-CI` | 빌드 검증 (.next/cache 캐싱 포함) |
| `PROJECT-REACT-CICD` | Docker 빌드 및 배포 (Next.js SSR 옵션 포함) |
> `next` 타입은 v4.1.0에서 `react`로 흡수되었습니다 (PROJECT-NEXT-* 워크플로우 삭제).
#### Node — npm publish (opt-in)
| 파일명 | 용도 | 위치 |
|--------|------|------|
| `PROJECT-NODE-NPM-PUBLISH` | main push 시 version.yml 버전으로 공개 npmjs 배포 (멱등, NPM_TOKEN secret) | node/publish/npm/ |
> `--publish npm`으로 포함한다(구 `--npm-publish`는 deprecated alias). 선택 값은 `version.yml`의 `metadata.template.options.publish` 배열에 저장된다.
---
## 핵심 스크립트
### version_manager (.py가 실 로직, .sh는 위임 shim — #448)
```bash
.github/scripts/version_manager.sh get # 또는: python .github/scripts/version_manager.py get
.github/scripts/version_manager.sh increment # patch +1 (기본)
.github/scripts/version_manager.sh increment --bump minor # 4.2.45 → 4.3.0
.github/scripts/version_manager.sh increment --bump major # 4.2.45 → 5.0.0
.github/scripts/version_manager.sh set 2.0.0
.github/scripts/version_manager.sh sync
.github/scripts/version_manager.sh get-code
.github/scripts/version_manager.sh increment-code
```
> v4.2부터 로직은 `version_manager.py`(stdlib 전용 — yq/jq 불필요)에 있고 `.sh`는 Python 위임 shim이다. Windows에서는 `python .github/scripts/version_manager.py get`을 직접 실행한다. **integrator 복사 목록에 `.sh`+`.py` 한 쌍이 모두 있어야 한다** (`truncate_release_notes.*`도 동일).
#### ⚠️ semver 자동 승격 축 (#546 — agent 필독)
릴리스 시 승격 폭은 `version.yml`의 `metadata.template.options.semver_auto`가 정한다.
| 값 | 동작 |
|---|---|
| **키 없음** (기존 통합 레포 전부) | 항상 `patch +1` — 구 동작 그대로 |
| `true` | 릴리스 구간 커밋 제목으로 major/minor/patch 결정 |
| `false` | 항상 `patch +1` |
- **키가 없으면 신규·기존 모두 `true`로 기록된다.** (구 정책: 기존 레포는 `false`로 남겼으나 소유자 결정으로
바꿨다.) 대신 예고 없이 버전이 튀지 않게 **업데이트가 처음 켠 때 완료 화면이 경고**한다
(`semverAutoNewlyOn`). 저장된 값은 명시적 `false`까지 그대로 존중한다.
- **마법사는 이 축을 묻지 않는다** (#485 질문 부담 축소 방향 유지). 켜려면 `version.yml`을 직접 고친다.
- 판정 규칙과 커밋 타입 대응은 "커밋 컨벤션 필수 규칙" 절 참조. `version_code`는 이 옵션과
무관하게 매 릴리스 +1이다.
- **`PROJECT-COMMON-VERSION-CONTROL`(main 직접 push)도 `semver_auto`를 따른다 (#805, #815).** 켜져 있으면
개발 브랜치 유무와 상관없이 push 구간 커밋 제목으로 major/minor/patch를 정하고, 명시적 `false`일 때만 patch다.
(예전엔 이 경로가 항상 patch 고정이라 옵션이 조용히 무시됐다. "커밋 컨벤션을 신뢰할 수 없다"는 이전 이유는 소유자 결정으로 폐기.)
- VERSION-CONTROL 가드는 `version.yml` **파일 이름이 아니라 `version:` 값 변경**으로 릴리스 머지를 판정한다 (#806).
설정만 바꾼 push는 릴리스가 아니므로 버전이 올라간다. 회귀 방지: `.github/scripts/test/test_main_push_guards.py`
### 빌드 번호와 iOS 업로드 판단 (#643, agent 필독)
앱 빌드 번호와 Apple 업로드 오류 해석은 **각각 한 곳에서만** 정한다. 워크플로 yaml에 번호 계산이나 오류 문구 grep을 다시 적지 않는다.
| 무엇 | 어디 | 규칙 |
|---|---|---|
| 빌드 번호 | `.github/scripts/build_number.py` | `max(2024-01-01 UTC부터 지난 초, ASC 최근 번호+1, 거부 메시지 요구값+1)`. iOS 테스트, iOS 릴리스, Android 테스트 공통 |
| Apple 업로드 오류 해석 | `.github/scripts/ios_release.py classify-error` | `duplicate` / `too_low` / `train_closed` / `other`로 분류. 재시도 여부도 이 분류로 정한다 |
- `version_code`는 **Android 릴리스 전용**이다 (Play Store, Firebase CICD). iOS와 테스트 빌드 번호에 쓰지 않는다.
- **새 Apple 오류 코드(ITMS-...)를 다루려면** `classify_upload_error`에 패턴을 넣고, `.github/scripts/test/test_ios_release.py`에 **실제 오류 문구 샘플**과 함께 케이스를 추가한다. 문구를 지어내지 않는다.
- iOS 릴리스는 닫힌 버전이면 버전 이름을 바꾸지 않고 실패한다. 테스트 빌드만 다음 패치로 전환한다.
### changelog_manager.py
```bash
python3 .github/scripts/changelog_manager.py update-from-summary
python3 .github/scripts/changelog_manager.py generate-md
python3 .github/scripts/changelog_manager.py export --version 1.2.3 --output release_notes.txt
python3 .github/scripts/changelog_manager.py classify-bump --commits-file commits.txt # major|minor|patch
```
### changelog_providers/ (릴리스 노트 생성 사다리 — 전부 .py, #455·#566)
`RELEASE-CHANGELOG`의 fallback-summary job이 `ladder.py`를 호출한다. **마법사는 provider를 묻지 않는다** (#566) — 사용자의 목적은 "릴리스 노트가 잘 나오는 것"이지 생성기 선택이 아니다.
**생성 사다리** (위에서부터 시도, 실패하면 아래로)
| 순서 | 경로 | 조건 | 비용 |
|---|---|---|---|
| ① | PR 본문에 이미 있으면 그대로 사용 | 배포 스킬·CodeRabbit·사람이 작성 | — |
| ② | `openai_compatible.py` | AI 키가 등록됐을 때만 | 무료 등급 가능 |
| ③ | `commit.py` — 커밋 분석 | 위가 안 될 때 | **무료·무제한** |
| (별도) | `copilot.py` — Copilot CLI | `provider: copilot`을 **명시**했을 때만 | **Premium Request 소모** |
> **⚠️ 기본 경로에 과금 요소를 두지 말 것 (#569).** Copilot은 요청 수로 과금되므로
> 기본 사다리에서 제외했다. 남의 저장소에 설치되는 템플릿이라 **설치한 사람이 모르는
> 사이에 비용이 발생해선 안 된다.** 기본값은 언제나 "돈이 들지 않는 것"이다.
**AI 키는 서비스별 전용 이름으로 등록한다** — `MODEL_API_KEY` 하나로는 무슨 서비스인지
사람도 코드도 알 수 없었다.
| Secret 이름 | 서비스 | 무료 |
|---|---|---|
| `GEMINI_API_KEY` | Google Gemini | ✅ 일 500회 |
| `GROQ_API_KEY` | Groq | ✅ |
| `MISTRAL_API_KEY` | Mistral | ✅ |
| `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` | OpenAI / Anthropic | ❌ |
| `MODEL_API_KEY` | 구 이름 — 키 접두사로 서비스 추정 | 하위호환 |
**④는 AI·네트워크 무의존이라 항상 완주한다.** 어느 경로든 마지막이 `commit`이므로 "릴리스 노트가 비는" 상황은 생기지 않는다.
| provider 저장값 | 동작 |
|---|---|
| 미설정 | 레포 루트에 `.coderabbit.yaml`이 있으면 `coderabbit`, 없으면 `commit`. 어느 쪽이든 키가 있으면 사다리가 외부 AI를 자동으로 집는다 (#821) |
| `copilot` | Copilot → (키 있으면 Gemini) → commit |
| `openai`/`gemini`/`claude`/`groq`/`mistral`/`ollama` | 해당 provider → commit (`MODEL_API_KEY` 필요, ollama는 `changelog.base_url`) |
| `github-ai` | **서비스 종료(2026-07-30)** — 호출하지 않고 조용히 흡수, 업데이트 시 `commit`으로 이전 |
| `coderabbit` | 기다리지 않는다. 본문에 요약이 있으면 ①에서 존중될 뿐 |
> 미설정 판정(명시값 → `.coderabbit.yaml` → `commit`)은 워크플로우 "Read version info" 스텝과 `changelog_cli.py` `_read_release_branches` **두 곳이 같은 규칙**이어야 한다. 회귀 방지: `.github/scripts/test/test_changelog_providers.py`, `test_changelog_deploy_provider.py`.
> **⚠️ 모델명에 버전을 박지 말 것 (#566).** `gemini-1.5-flash`가 404로 죽어 있었다. `openai_compatible.py`의 preset은 `-latest` 별칭을 쓴다. gemini는 **`gemini-flash-lite-latest`** 가 기본인데, 무료 등급에서 상위 Flash는 RPD 20인 반면 Lite는 500이라 자동화에 쓸 수 있는 쪽이 Lite뿐이기 때문이다(실측).
>
> **⚠️ 외부 서비스를 기본값으로 두지 말 것.** 기본 경로(①·②·④)에는 외부 API 키가 필요 없다. 남의 레포에 설치되는 템플릿이므로, 설치한 사람이 모르는 사이에 키를 요구하거나 과금이 발생해선 안 된다.
**생성 결과 안내** (`changelog_notice.py`): 어떤 경로로 만들어졌는지 Job Summary에 매번 남기고, **AI를 하나도 쓰지 못했을 때만** PR에 댓글로 대안을 안내한다. 댓글은 마커로 갱신하므로 이메일 알림은 처음 한 번만 간다 — 매 릴리스 알림은 소음이 되고, 소음이 되면 아무도 읽지 않는다.
테스트: `python -m pytest .github/scripts/test/` (providers·commit 파싱·안내·입력 경로). npx 복사 엔진(`src/core/copy/simple.js`)이 provider .py들을 사용자 프로젝트에 복사한다 — **복사 목록과 실제 파일의 정합성은 `test/copy-simple.test.js`가 자동 검증**하므로 새 provider를 추가하면 목록에도 넣어야 테스트가 통과한다.
### issue_helper.py (이슈 브랜치/커밋 댓글 — #478에서 내재화)
이슈 생성/제목 수정 시 `PROJECT-COMMON-SUH-ISSUE-HELPER.yaml`이 실행. 외부 액션 의존 없음 (stdlib 전용).
설정: `version.yml` `metadata.template.options.issue_helper` (branch_prefix/commit_template/commit_type_map/timezone/show_guide 등 — 없으면 기본값).
브랜치 코어(`YYYYMMDD_#번호_제목`)와 댓글의 `### 브랜치` 코드블록은 불변 계약 — 소비자 목록은 `docs/BRANCH-CONVENTION.md`.
서명 문구는 `guide_signature` 설정(기본 `Guide by ProjectOps`)이고, 구버전 소비자가 찾는 옛 문구 `Guide by SUH-LAB`는 숨김 주석으로 항상 남긴다 (#749).
구 MODULE 워크플로우의 커스텀 설정은 마이그레이션이 자동 이관한다 (`rules/settings-extractors.js`).
테스트: `python3 -m pytest .github/scripts/test/test_issue_helper.py`
### template_integrator.sh / .ps1 — 제거됨 (EOF #458 → 삭제 #800)
**두 스크립트는 v4.3.0에서 안내용 shim으로 바뀌었고, #800에서 파일 자체를 지웠다.** 옛 `curl .../template_integrator.sh | bash`는 이제 404다. `src/` 주석에 지운 스크립트의 줄번호·함수명(`.sh L1234 등가`)을 적지 않는다 — 가리키는 파일이 없다. 이유는 현재형으로 적는다 (#916).
통합/업데이트/스킬 설치는 전부 **`npx projectops`** 한 경로다. 배포/publish 축·secret 백업 등 모든 옵션은 npx 마법사가 질문하며, 선택 값은 `version.yml`의 `metadata.template.options.*`에 동일하게 저장된다.
**초기화/통합 시 복사되지 않는 템플릿 전용 파일**:
```
CLAUDE.md, CONTRIBUTING.md, LICENSE
CHANGELOG.md, CHANGELOG.json
docs/, .github/scripts/test/
.claude-plugin/, .codex-plugin/, .agents/, .cursor/, skills/, scripts/
package.json, harness/ # pi 패키지 매니페스트 + Persona Harness
plugins/ # extras 선택 플러그인 (projectops-extras)
hooks/ # Claude Code 플러그인 훅 (세션 종료 시 pro-launch 기기 반납 #953 · raw 기기 명령 차단 #954)
```
#### ⚠️ 레포 루트에 "마켓플레이스/템플릿 전용" 파일·폴더를 추가할 때 (agent 필독)
이 레포는 **두 정체성**을 동시에 가진다 — 이 점이 일반 레포와 다른 핵심이다.
1. **템플릿 레포**: GitHub "Use this template"로 새 프로젝트를 만들면 `.github/scripts/template_initializer.py`(.sh는 위임 shim)가 마켓플레이스/템플릿 전용 파일을 **삭제**한다.
2. **마법사 배포원**: `npx projectops`(src/)가 기존 프로젝트에 템플릿을 통합할 때 그 파일들을 **복사 대상에서 제외**한다 (`src/core/exclusions.js`).
따라서 레포 루트에 새 파일/폴더를 추가했는데 그게 **이 레포에서만 의미 있고 사용자 프로젝트로 흘러가면 안 되는 것**(플러그인 매니페스트, skill, pi 패키지 파일, 내부 문서 등)이라면, **아래 2곳(+필요 시 3번째)을 반드시 함께 수정**한다. 한 곳만 고치면 새 프로젝트가 오염되거나 마법사가 불필요한 파일을 복사한다 — 실제로 자주 빠뜨리는 함정이다.
| # | 파일 | 수정할 위치 | 동작 |
|---|------|------------|------|
| 1 | `.github/scripts/template_initializer.py` | 삭제 목록 `CLEANUP_TARGETS` (`CHANGELOG.json` 항목 근처) | `("파일명", "설명")` 항목 추가 (**삭제**) |
| 2 | `src/core/exclusions.js` | `DOCS_TO_REMOVE` 등 제외 배열 | 파일/폴더명 추가 (**복사 제외**) |
| 3 | `.github/workflows/PROJECT-TEMPLATE-PLUGIN-VERSION-SYNC.yaml` | 버전 동기화 step + `git add` | 버전 필드가 있는 매니페스트(`package.json` 등)면 동기화 step 추가 |
> (구 `template_integrator.sh`/`.ps1`의 `plugin_items_to_remove` 배열은 #458 EOF로 사라졌다 — 이제 npx `exclusions.js`가 유일한 복사 제외 지점이다.)
>
> **체크 순서**: 새 루트 파일 추가 → "이게 사용자 프로젝트에도 필요한가?" 자문 → **아니오면 위 1·2를 모두 수정**. 버전을 `version.yml`과 맞춰야 하는 매니페스트면 3번도 추가. 수정 후 `python -m py_compile .github/scripts/template_initializer.py`와 `npm test`로 확인한다.
>
> **반대로**, 추가한 게 사용자 프로젝트에도 같이 가야 하는 공통 자산(워크플로우·스크립트·설정)이라면 위 목록에 넣지 않는다 — 제외하면 통합 대상 프로젝트가 그 기능을 못 받는다.
#### ⚠️ macOS는 bash 3.2 + BSD 도구다 — `.sh` 작성·검증 시 절대 잊지 말 것 (agent 필독, 실측)
> **핵심: 윈도우(`.ps1`·Git Bash)에서 잘 돌아도 macOS에서 깨질 수 있다.** macOS 기본 `/bin/bash`는 라이선스 문제로 **3.2.57(2007년)에 박제**돼 있고, 기본 grep/sed도 **BSD 계열**이다. `.sh`는 항상 **`/bin/bash`(3.2) + BSD 도구**로 검증한다. "윈도우에서만 테스트했다"가 실제 사용자(macOS) 버그를 통째로 놓치게 한 주원인이다. (실측: 이슈 #415·#418 — 윈도우 정상, macOS만 깨짐.)
**bash 3.2에서 못 쓰는 것 (4+ 전용 → macOS에서 조용히 오작동)**
- **연관배열 `declare -A` 금지.** bash 3.2는 미지원 → 모든 문자열 키가 **인덱스 0으로 뭉개져** "키가 1개만 남는" 버그가 된다(실측 #418: @wizard env 키가 6개인데 1개만 수집). 동적 key-value가 필요하면 eval 동적변수 + 16진 키 인코딩 헬퍼(구 integrator의 `_kv_*` 패턴, 3.2/4 공용)를 쓴다.
- **`declare -g` 금지.** bash 3.2에서 `invalid option`. 최상위 레벨이면 어차피 전역이니 `-g` 없이 선언한다.
- `mapfile`/`readarray`, `${var,,}`/`${var^^}`(대소문자), `&>>`도 4+ 전용 → 사용 금지.
**BSD 도구 함정 (macOS 기본 grep/sed ≠ GNU)**
- **`grep -P`/`-oP`/`\K` 금지** (PCRE는 GNU 전용). `grep -E`로 라인 잡고 `sed -E`로 추출한다. (실측 #415: `grep: invalid option -- P`로 버전 감지 실패.)
- **`grep`이 매치 0건이면 `exit 1`** → `set -e`에서 `var=$(grep ...)` 단독 대입이 스크립트를 죽인다. `|| true`로 흡수하거나 `| head`/`| sed` 파이프로 끝낸다(pipefail 없으면 파이프 마지막 명령 코드만 봄). (실측 #415.)
- `sed -i`는 BSD가 `sed -i ''` / `sed -i.bak` 형태로 인자가 다르다. `readlink -f`·`date -d`·`xargs -r`도 BSD 미지원.
**`set -e` + 함수 끝 종료코드 (메뉴 아닌 일반 함수도 해당)**
- 위 "var=$(menu_fn)" 함정의 일반화: **함수의 마지막 명령이 비-0이면**, 그 함수가 호출부의 마지막 명령일 때 `set -e`가 스크립트를 통째로 죽인다. `[ -d x ] || return`(폴더 없으면 1 전파), `[ cond ] && cmd`(조건 거짓이면 1), `command -v foo && ...`(미설치면 1), `cp ... && ...`(실패면 1) 모두 위험. → early return은 `return 0` 명시, 끝줄 `조건 && 명령`은 `{ ...; } || true`로 감싼다. (실측 #415: Nexus/Secret 메뉴·interactive_mode·codex 제거·config cp 등 5곳.)
**검증 방법**: `bash -n`(문법)만으론 부족하다. **반드시 `/bin/bash`(3.2)로 실제 실행**한다. 함수만 떼어 `source` 후 호출해 실제 입력으로 **종료코드 0 완주**를 확인한다. `which bash`가 brew bash(`/opt/homebrew/bin/bash`, 4+)를 가리키면 `/bin/bash`로 명시 실행해 3.2를 강제한다.
---
## ⚠️ 워크플로우 YAML 검증 — 로컬 파서를 GitHub 실제 동작으로 착각하지 말 것 (agent 필독)
> **핵심 원칙: 로컬 YAML 검증 도구(`actionlint`·Ruby `psych`·Python `pyyaml`)가 빨갛게 떠도, 그 워크플로우가 GitHub에서 실제로 깨진다는 뜻이 아니다.** 도구가 못 읽는 것과 GitHub이 못 돌리는 것은 **다르다.** 멀쩡히 돌던 워크플로우를 "검증 도구가 오류라고 했으니" 멋대로 고치지 마라 — 이건 실측으로 확인된 함정이다.
### 왜 이런 일이 생기나 (실측 사례)
`run: |` 블록 안에서 heredoc을 **들여쓰기 0칸 본문**으로 쓰는 패턴이 대표적이다:
```yaml
run: |
cat > android/key.properties << EOF
storeFile=keystore/key.jks # ← 들여쓰기 0칸
EOF
```
- `actionlint`·`psych`·`pyyaml`은 이걸 `could not find expected ':'`로 **파싱 실패** 처리한다 (블록 스칼라 경계를 들여쓰기로만 판단하는 엄격한 go-yaml/libyaml 계열).
- 하지만 **GitHub Actions의 실제 YAML 파서는 이 heredoc을 정상 처리해서 워크플로우가 success한다.** 즉 도구의 한계지 진짜 버그가 아니다.
### 진짜 깨졌는지 확인하는 올바른 순서 (추측 금지, 실측만)
1. **GitHub 실행 이력을 먼저 본다 (가장 강력한 증거).** 같은 파일/패턴이 실제 `success`한 run이 있으면 → **멀쩡한 코드 확정.** 절대 손대지 마라.
```bash
PAT=$(python3 -c "import json;print(json.load(open('$HOME/.projectops/config/config.json'))['github']['global_pat'])")
curl -s -H "Authorization: token $PAT" \
"https://api.github.com/repos/<owner>/<repo>/actions/workflows/<file>.yaml/runs?per_page=20" \
| python3 -c "import json,sys;from collections import Counter;d=json.load(sys.stdin);print(Counter(r['conclusion'] for r in d.get('workflow_runs',[])))"
```
2. **"잘 작동하는 기준 레포"와 대조한다.** 이 템플릿의 검증 기준 레포는 **`TEAM-ROMROM/RomRom-FE`(Flutter)·`TEAM-ROMROM/RomRom-BE`(Spring)** 다 — 실제 운영 중이고 빌드가 success한다. `passQL`은 **이 템플릿을 테스트하는 실험 프로젝트**라 (Flutter init도 미완) **신뢰 기준이 아니다.** passQL의 failure를 근거로 템플릿이 깨졌다고 결론짓지 마라.
3. **YAML 파싱 자체가 깨졌는지는 run annotations로 확인한다.** GitHub이 파싱에 실패하면 `syntax error` annotation을 남긴다. annotation이 없고 job이 빌드 중간 step에서 실패했으면 → **YAML은 정상, 원인은 빌드 로직(secret 누락 등)이다.**
```bash
curl -s -H "Authorization: token $PAT" "https://api.github.com/repos/<owner>/<repo>/check-runs/<job_id>/annotations"
```
### 검증 도구를 쓸 때의 자세
- `actionlint`/`psych`/`pyyaml`은 **참고용 신호**다. 빨간불 = "확인해봐라"지 "고쳐라"가 아니다.
- 특히 **이미 운영 중인 워크플로우**(GitHub에 success 이력 있음)는 도구가 뭐라 하든 **건드리지 않는 것이 기본값**이다.
- 정 고쳐야 한다면, "잘 작동하는 RomRom이 같은 자리를 어떻게 쓰는지" 먼저 받아 대조하라. (예: key.properties는 RomRom-FE가 `echo "k=v" >> file` 방식으로 쓰며 success — 0칸 heredoc을 안 쓴다.)
- 내가 토큰화·치환 같은 **env 값만 바꾸는 작업**을 할 때, `run:`/`uses:`/`with:`/`steps:` 등 **실행 로직은 한 줄도 건드리지 않았는지** `git diff`로 자가검증하라:
```bash
git diff <files> | grep "^+" | grep -v "^+++" | grep -vE "내가_의도한_변경_패턴" # 결과 비면 실행로직 무손상
```
---
## 트리거 키워드
### 댓글 기반
| 키워드 | 워크플로우 | 기능 |
|--------|-----------|------|
| `@projectops create qa` | QA-ISSUE-CREATION-BOT | QA 이슈 자동 생성 |
| `@projectops build app` | PROJECTOPS-APP-BUILD-TRIGGER | Android + iOS 빌드 |
| `@projectops apk build` | PROJECTOPS-APP-BUILD-TRIGGER | Android만 빌드 |
| `@projectops ios build` | PROJECTOPS-APP-BUILD-TRIGGER | iOS만 빌드 |
### 브랜치 기반
| 브랜치 | 트리거 | 워크플로우 |
|--------|--------|-----------|
| `develop` | push | CI (버전 증가 없음) |
| `main` | PR (develop→main) | CHANGELOG-CONTROL (버전 확정 + automerge) |
| `main` | push (릴리스 머지) | README-UPDATE, PLUGIN-SYNC, NPM-PUBLISH, CICD |
| `main` | push (직접) | VERSION-CONTROL 안전망 (+CICD — 비권장 경로) |
---
## 이슈/PR 템플릿
**이슈 템플릿**: `bug_report.md` / `feature_request.md` / `design_request.md` / `qa_request.md`
**이슈 라벨**: 영문 표준 `priority: urgent, documentation, status: todo, status: in progress, status: needs review, status: feedback, status: done, status: on hold, status: cancelled` (기존 한글: `긴급, 문서, 작업전, 작업중, 담당자확인, 피드백, 작업완료, 보류, 취소`)
---
## Skills (Claude Code 플러그인)
**플러그인명**: `projectops`
```bash
claude plugin marketplace add Cassiiopeia/projectops
claude plugin install projectops@projectops-marketplace --scope user
```
- > 설계·계획·구현은 이 플러그인이 아니라 `superpowers`(brainstorming → writing-plans → executing-plans)가 맡는다. 아래 표(코어 15종 + extras 2종)에는 분석·계획·구현 스킬이 없다.
+ > 설계·계획·구현은 이 플러그인이 아니라 `superpowers`(brainstorming → writing-plans → executing-plans)가 맡는다. 아래 표(코어 15종 + extras 3종)에는 분석·계획·구현 스킬이 없다.
| 명령어 | 용도 |
|--------|------|
| `review` | 코드 리뷰 |
| `testcase` | QA 테스트케이스 |
| `init-worktree` | Git worktree 생성 |
| `commit` | 이슈 기반 커밋 자동화 |
| `github` | GitHub 전반: 이슈 생성/조회/수정/댓글/라벨/담당자, PR 생성/머지/조회, 레포 탐색, Actions 로그, Secret 관리, **이미지 첨부**(이슈 본문·댓글·PR) |
| `report` | 구현 보고서 생성 |
| `changelog-deploy` | develop push → main으로 릴리스 PR(deploy PR) → 버전 확정 + automerge / automerge 실패 시 재트리거 |
| `synology-expose` | 시놀로지 서비스 외부 노출 가이드 (**extras 선택 플러그인** `projectops-extras` — `plugins/projectops-extras/`, 공유 문서 사본은 `scripts/sync_extras.py`로 동기화) |
| `code-consult` | **AI가 짠 코드 관리** (**extras**) — 코딩 규칙 기준 점검·한 항목씩 리팩터링(전후 실행 대조)·변경분 검사(낡은 주석·중복)·위반 동결 목록(`docs/architecture/rules/`, 줄을 지울 수만 있음). CLI `arch_cli.py`는 extras가 자기 폴더만 설치되므로 `scripts/common`을 쓰지 않는다 (#867) |
+ | `skill-bug` | **projectops 스킬 버그 보고** (**extras**) — 어느 레포 세션에서든 의심되면 불러, 세션 기록에서 명령·결과·흐름·로그·스크린샷을 모아 개인정보를 지우고 `Cassiiopeia/projectops`에 이슈(같은 이슈 있으면 댓글)로 묻지 않고 올린 뒤 하던 일로 돌아간다. 판정·수정은 하지 않는다 (CLI `bug_cli.py`) |
| `ssh` | 원격 서버 SSH 접속 및 명령 실행 (AWS EC2, 시놀로지 NAS, Linux 서버 등) |
| `skill-creator` | skill 생성/리뷰/개선 (CREATE·REVIEW·IMPROVE 3모드) |
| `agent-test` | **앱·웹·서버를 실제로 실행해 밟는다** (E2E·증적) — 문서만 쓰는 `testcase`와 다르다 |
| `figma` | **시안 → 코드 → 대조** 한 묶음. 덤프 항목을 세어 작업 목록으로 삼고, 옮긴 뒤 픽셀로 맞댄다 (#619에서 옮기기·대조를 `figma-verify`로 합쳤고, #854에서 이름을 `figma`로 되돌렸다) |
| `launch` | **능력 스킬** — 앱·웹·서버를 띄우고 조작하고 찍는다. 다른 스킬이 `launch_cli.py` 를 스크립트로 부른다 (#629) |
| `desktop-control` | **이 컴퓨터 화면의 프로그램 조작** (웹 페이지·에뮬레이터가 아닌 설치 마법사·OS 대화상자·데스크톱 앱). 요소를 읽고, 마우스를 움직이지 않고 누르고, 설치 전에는 묻고, 보안 확인 창은 사용자에게 넘긴다. `launch`의 `desktop` 명령 묶음 위에 얹은 절차 스킬이라 자체 CLI가 없다. 지금은 Windows만 (macOS는 #985) |
| `design-brief` | **시안보다 먼저 만든 화면의 디자인 요청서** — 상태별 캡처 · 대안 · 문구 · 꼭 지킬 것을 보드로 조립해 이슈·HTML·md 로 넘긴다 (#634) |
| `oss-consult` | **오픈소스 컨설팅** — 레포 성격을 판별하고 6축(첫인상·가치 증명·커뮤니티·코드 확장성·법적 안전·장기 운영)으로 깐깐하게 채점, 성숙도 단계와 선행 조건을 짚고 승인받은 것을 고친다. 단일 레포·전체 일괄 (#644) |
---
## Skills 개발 가이드
### 폴더 구조
```
skills/
├── {skill-name}/SKILL.md
├── config.json.example # 전체 config 구조 예시 (모든 skill_id 섹션 포함)
└── references/
├── common-rules.md # 절대 규칙, 커밋 컨벤션, 작업 시작 프로토콜
├── personas.md # 5 전문가 페르소나 + 6 마인드셋 (harness/PERSONA.md single source) — 로드하는 스킬 없음 (harness 사본)
├── approval-and-questions.md # 산출물 skill 8종의 승인 게이트 + 시작 전 핵심 질문 표준 (#526)
├── config-rules.md # config 경로·스키마·읽기/쓰기 표준
├── mcp-subcommand-rules.md # skill CLI(<scope>_cli.py) 서브커맨드 MCP-style 설계 표준 (JSON+next, 코드 템플릿)
├── doc-output-path.md
├── project-detection.md
├── code-style-detection.md
├── tech-flutter.md
├── tech-react.md
└── tech-spring.md
```
### 핵심 원칙
1. **config는 agent가 Read/Write tool로 직접 처리** — `config-get` CLI 호출 금지
2. **config 경로·스키마는 `references/config-rules.md` 참조** — skill 내 직접 기술 금지
3. **GitHub API는 각 skill의 `<scope>_cli.py` 서브커맨드로 호출** (`scripts/common/gh_client` 경유) — `gh` CLI 금지
4. **OS 호환성**: Python 실행 시 `PYTHON=$(command -v python3 2>/dev/null || command -v python 2>/dev/null)` 패턴 사용
5. **skill 시작 시 필독**: `references/common-rules.md` → (config 필요 시) `references/config-rules.md` → (기술별) `tech-*.md`
5.5. **산출물(md)을 만드는 skill이면** `references/approval-and-questions.md`를 따른다 — 시작 전 핵심 질문, 저장 전 승인 게이트, 첫 실행 1회 자동화 제안. 저장 경로는 각 skill CLI의 `get-output-path`로 받고 직접 조립하지 않는다 (#525·#526)
6. **Python 행동 로직은 재사용 스크립트 파일 + MCP-style 표준** — 아래 "Python 행동 스크립트 표준" 절을 따른다. SKILL.md에 긴 Python heredoc 인라인 금지.
### Python 행동 스크립트 표준 (필수)
스킬이 Python으로 외부 시스템(GitHub API, SSH 등)을 호출할 때 반드시 이 패턴을 따른다.
이 표준은 Windows Git Bash + macOS 양쪽에서 깨지지 않도록 실측 검증된 것이다.
> **skill CLI(`skills/pro-*/scripts/<scope>_cli.py`)에 새 서브커맨드를 추가할 때는 `skills/references/mcp-subcommand-rules.md`를 먼저 읽는다.** 입력 계약·JSON 스키마(`ok`/`verdict`/`summary`/`next`)·gh_client와 command 레이어 분리·테스트 패턴을 코드 템플릿과 체크리스트로 정리해 둔 구체적 구현 레퍼런스다. 모범 사례는 `actions`·`deploy-status` 서브커맨드.
#### 1. 로직은 재사용 스크립트 파일에 둔다
- **진입 파일은 얇게, 서브커맨드는 패키지로, 파일당 450줄.** 진입 `<scope>_cli.py`는 파서·등록만 두고 서브커맨드 구현은 옆 패키지에 둔다 (모범: `skills/pro-launch/scripts/pro_launch/`). 상한은 `scripts/tests/architecture/test_python_file_size.py`가 지킨다 — 넘는 기존 파일은 그 안의 동결 목록에만 둔다.
- `skills/{skill-name}/scripts/{name}.py` 에 행동 로직을 고정 파일로 저장한다.
- SKILL.md는 **호출법만** 기술한다 (서브커맨드·인자·환경변수). 긴 Python 코드를 SKILL.md에 인라인하지 않는다.
- 이유: SKILL.md는 LLM이 매번 재입력하는 문서다. redirect strip 같은 핵심 로직을 인라인하면 재입력 시 누락·오타 위험.
#### 2. MCP-style 서브커맨드 — 입력 해석은 agent, 실행은 .py
- .py는 `argparse` 서브커맨드로 **명확한 입력 계약**을 갖는다 (예: `show-run RUN_ID`, `joblog JOB_ID`, `resolve-pr PR_NUM`).
- .py는 URL 파싱·PR→run 추적 같은 **해석을 하지 않는다**. agent가 사용자 입력(URL/PR/브랜치/빈입력)을 해석해 정확한 서브커맨드·인자를 넘긴다.
- SKILL.md에 "이런 입력 → 이런 서브커맨드" 라우팅 규칙을 명시해 agent가 제대로 판단하게 한다.
- 효과: .py는 단독 실행·테스트 가능(MCP tool처럼 예측 가능), agent는 유연하게 입력 해석.
#### 3. 인자는 환경변수로 전달 — heredoc·/tmp·stdin pipe 금지
- 민감값(PAT 등)과 인자는 **환경변수**로 넘긴다. heredoc 본문 보간·`/tmp` 임시파일·`curl | python` stdin pipe 전부 금지.
- 이유 (실측):
- `/tmp` 경로 → Windows Git Bash에서 깨짐.
- `curl | python3` → Windows에서 Exit code 49.
- heredoc `{변수}` 보간 → 한글·특수문자 이스케이프 깨짐.
```bash
PYTHON=$(for _py in python3 python; do _path=$(command -v "$_py" 2>/dev/null) || continue; "$_path" -c "import sys; sys.exit(0)" 2>/dev/null && echo "$_path" && break; done)
GH_PAT="..." GH_OWNER="..." GH_REPO="..." \
PYTHONIOENCODING=utf-8 "$PYTHON" "$PROJECT_ROOT/skills/{skill}/scripts/{name}.py" show-run 12345
```
#### 4. 출력은 언제나 JSON — 내부에 `next` 힌트 필드
- 모든 서브커맨드는 **항상 JSON**을 stdout으로 출력한다 (plain text 모드 없음, `--json` 옵션도 두지 않는다).
- JSON에 `ok`(성공 여부), 데이터 필드, 그리고 **`next`**(agent가 이어서 호출할 다음 서브커맨드 힌트)를 담는다.
- agent는 단일 JSON 형식만 파싱 → 다음 행동을 정확히 판단.
```json
{"ok": true, "run_id": 12345, "conclusion": "failure",
"failed_jobs": [{"job_id": 678, "name": "build", "failed_steps": ["Flutter build"]}],
"next": "joblog 678"}
```
#### 5. 표준 라이브러리 우선 — 진짜 목표는 mac/Windows 양쪽 동작 + 내부망 대응
- "의존성 0"이 목표가 아니다. **진짜 목표는 mac·Windows 어디서든 깨지지 않고, 내부망(폐쇄망)에서 `pip install` 불가해도 돌아가는 것**이다.
- 따라서 가능하면 표준 라이브러리(`urllib.request`/`json`/`argparse`)로 해결한다 — 추가 설치 없이 양쪽 OS·내부망에서 바로 동작하기 때문.
- 표준 라이브러리로 안 되는 일이면 **외부 패키지를 당연히 쓴다**. 다만 스크립트 내에서 설치 시도(`pip install ... -q`) + 실패 시 수동 설치 안내를 둬서 내부망에서도 우아하게 처리한다. (예: secret 암호화 PyNaCl)
#### 6. redirect 시 Authorization 헤더 strip (필수 보안·동작)
- GitHub job logs 등 일부 엔드포인트는 Azure Blob(SAS URL)로 302 redirect된다.
- urllib 기본 동작은 `Authorization` 헤더를 redirect 대상까지 전달 → Azure가 `403 AuthenticationFailed`.
- redirect 시 `Authorization` 헤더를 제거하는 핸들러를 반드시 둔다:
```python
class StripAuthRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
new = super().redirect_request(req, fp, code, msg, headers, newurl)
if new is not None:
new.headers.pop("Authorization", None)
new.unredirected_hdrs.pop("Authorization", None)
return new
opener = urllib.request.build_opener(StripAuthRedirect)
```
### config 구조
모든 스킬의 config는 **단일 파일** `~/.projectops/config/config.json` 하나로 관리한다.
skill_id를 키로 각 스킬의 설정을 네임스페이스로 분리한다.
| 스킬 | config 섹션 키 | 비고 |
|------|--------------|------|
| `commit`, `github`, `changelog-deploy`, `report` | `github` | PAT + repos 공유 (이슈 생성 승인/담당자 설정 `issue.*` 키 포함) |
| `synology-expose` | `synology-expose` | NAS 인스턴스 정보 |
| `ssh` | `ssh` | SSH 서버 접속 정보 |
### 새 스킬에 config 추가하는 방법
1. `skill_id`(스킬 폴더명)를 키로 `config.json`에 섹션 추가
2. `skills/references/config-rules.md` §7에 스키마 문서화
3. `skills/config.json.example`에 예시 추가
4. SKILL.md에 `references/config-rules.md §2~3` 참조 명시
**별도 config 파일(`skill-name.config.json` 등)을 새로 만들지 않는다.**
### skill별 CLI 커맨드 (3-layer 아키텍처)
각 skill이 자체 `scripts/<scope>_cli.py`를 보유한다. 서브커맨드가 많은 CLI는 **진입 파일 + 같은 폴더의 패키지**로 나뉜다 — 진입 파일은 파서와 등록만, 서브커맨드 구현은 패키지(`pro_github/`·`pro_agent_test/`·`pro_design_brief/`·`figma_tools/`·`oss_consult/`·`pro_launch/`, 워크트리는 `scripts/git_worktree/`). 호출 패턴 = self-contained 5줄 (`skills/references/common-rules.md` §"skill별 py 분산 호출" 참조).
| skill | cli 파일 | 주요 서브커맨드 |
|---|---|---|
| github | `skills/pro-github/scripts/github_cli.py` | create-issue, get-issue, get-issues, list-issues, update-issue, close-issue, reopen-issue, search-issues, add-comment, list-comments, edit-comment, delete-comment, list-labels, add-labels, remove-label, set-labels, add-assignees, remove-assignees, create-pr, list-prs, update-pr, get-pr, add-pr-comment, close-pr, reopen-pr, merge-pr, normalize-title, create-branch-name, get-commit-template, explore, secrets, actions, upload-image, delete-image, get-output-path |
| commit | `skills/pro-commit/scripts/commit_cli.py` | get-issue-number, get-issue, normalize-title, sanitize-message, get-commit-template, resolve-style |
| report | `skills/pro-report/scripts/report_cli.py` | get-output-path, add-comment |
| review | `skills/pro-review/scripts/review_cli.py` | get-output-path |
| changelog-deploy | `skills/pro-changelog-deploy/scripts/changelog_cli.py` | actions, deploy-status, list-prs, update-pr, create-pr, detect-release-context (`release_language`: 노트 언어는 `version.yml` `language` — 워크플로우와 같은 규칙, 스토어 언어·고정 문구 — #977) |
| testcase | `skills/pro-testcase/scripts/testcase_cli.py` | get-output-path (#525·#623) |
| init-worktree | `skills/pro-init-worktree/scripts/worktree_cli.py` | recall, record (지난번 복사한 로컬 파일 세트를 기억 — #839) |
| figma | `skills/pro-figma/scripts/figma_cli.py` | get-output-path, coverage, assets, conform, diff |
| agent-test | `skills/pro-agent-test/scripts/e2e_cli.py` | detect, scenario, note, api, other (실행·캡처 명령은 pro-launch 로 넘겨준다 — #631) |
| launch | `skills/pro-launch/scripts/launch_cli.py` | doctor, detect, devices, device, app, web, desktop (PC 화면의 창, #985), render, http, access, db, logs, cred, ssh, local (이 맥 sudo, #784), shrink, recall, learn, forget, kb (공식 트러블슈팅 search·show·list·propose, #889), get-output-path |
| desktop-control | (자체 CLI 없음) `skills/pro-launch/scripts/launch_cli.py` | `desktop` 그룹을 그대로 쓴다: doctor, setup, session, windows, tree, find, click, type, press, focus, wait, shot |
| design-brief | `skills/pro-design-brief/scripts/design_brief_cli.py` | config, get-output-path, board, copy-lint, ascii, rewrite-images (업로드 결과로 본문 이미지 경로 치환 — #974) |
| oss-consult | `skills/pro-oss-consult/scripts/oss_cli.py` | collect, local-facts, list-repos, get-output-path (**사실만 모은다 — 판단은 에이전트**, #644), repo-update, label (레포를 바꾸는 유일한 경로 — `--dry-run`·`before` 기록, **삭제·가시성 변경은 구현하지 않는다**, #660) |
공유 도메인 로직은 `scripts/common/`에 있다 (gh_client, config, paths, title, issue_number, gh_branch, manifest, emit, bootstrap).
> **⚠️ 산출물 경로를 SKILL.md에 박아 쓰지 않는다 (#623 — agent 필독).** 산출물 루트는
> 설정(`output.root`)으로 바뀐다. `docs/projectops/<스킬>/...`를 직접 조립하면 루트를 옮긴
> 팀에서 **조용히 틀린다** — 실제로 `testcase`는 엉뚱한 곳에 쓰고, 읽는 스킬은 빈 폴더를
> 뒤져 "산출물 없음"으로 판단했다. 산출물 경로는 항상 CLI의 `get-output-path`로 받는다. 회귀 방지: `scripts/tests/test_output_tracking.py`가 **산문 언급이 아니라 실제
> 호출 줄**을 요구하고, 문서가 부르는 서브커맨드가 CLI에 실재하는지까지 본다.
>
> **증거물(스크린샷·덤프·렌더)과 문서(보고서·계획)는 다르다 (#621).** 어느 쪽인지는
> `common/paths.py`의 `EVIDENCE_SKILLS`/`DOCUMENT_SKILLS`가 단독으로 알고, 증거 스킬이면
> `resolve_output_path`가 폴더에 `.gitignore`를 자동으로 심는다 (루트 `.gitignore`는 건드리지
> 않는다). **새 산출물 스킬은 두 집합 중 하나에 반드시 넣는다** — 안 넣으면 테스트가 막는다.
> **⚠️ 선택 위치 인자를 여러 개 줄줄이 두지 않는다 (#622 — agent 필독).** argparse는 값을
> **왼쪽부터** 채우므로, `run_id`/`job_id`/`pr_number`/`branch`처럼 서브커맨드마다 다른 값을
> 각각의 칸으로 두면 **첫 칸에만 들어간다**. `github_cli.py`의 `actions`가 그랬고
> `joblog`·`resolve-pr`·`resolve-branch` 셋이 **문서에 적힌 그대로 불러도 깨졌다**
> (`show-run`·`list-failed`만 우연히 맞아서 오래 안 보였다). 위치 인자는 **하나**로 두고
> 서브커맨드가 해석한다 (`changelog_cli.py`가 처음부터 그 방식이다).
> GitHub API 호출은 각 skill의 `<scope>_cli.py` 서브커맨드 우선. 새 동작이 필요하면 `skills/references/mcp-subcommand-rules.md` 기준으로 `common/gh_client` 헬퍼 + cli 서브커맨드 + 테스트를 추가한다. 신규 skill에 py 필요하면 `skills/pro-skill-creator/templates/python_cli_script.py` 골격을 복사.
### Agent 주의사항
| 상황 | 처리 |
|------|------|
| config 없음 | 대화형 수집 — 억지 추론 금지 |
| repo owner/repo 불명확 | `git remote get-url origin` 추출 → 실패 시 config `github_repos` 참조 |
| GitHub API 401 | PAT 만료 안내 + `/pro-github`에서 재등록 유도 (`references/config-rules.md §2~5`) |
| `gh` CLI 사용 시도 | 금지 — curl로 대체 |
| 공통 워크플로우 수정 | `project-types/common/`과 `.github/workflows/` 루트 **두 곳 동일하게** 유지 |
| GitHub 댓글에 마크다운 표 | `array.join('\n')` 패턴 사용 (template literal 들여쓰기 시 표 깨짐) |
---
## 기여 가이드라인 핵심
> 상세 내용: `CONTRIBUTING.md`, `docs/WORKFLOW-COMMENT-GUIDELINES.md`
### 워크플로우 추가 시
- 공통 워크플로우: `project-types/common/` (원본) + `.github/workflows/` 루트 (복사본) **동일 유지**
- 타입별 워크플로우: `project-types/[type]/`만
- 필수 요소: `workflow_dispatch`, `concurrency`, `[skip ci]`
### Breaking Changes
호환성 문제 변경 시 `.github/config/breaking-changes.json`에 등록:
```json
{
"버전": {
"severity": "critical | warning",
"title": "English title",
"message": "English details and action steps",
"title_ko": "제목 (선택)",
"message_ko": "상세 설명 및 조치 방법 (선택)"
}
}
```
> **`title`/`message`는 영어가 정본이고 필수다 (#988).** 구버전 CLI는 `main`의 이 파일을 받아 이 두 키만 읽으므로
> 키 이름·형식을 바꾸지 않는다. 한국어는 선택 키 `title_ko`/`message_ko`에 넣고, CLI 화면 언어(`--lang`·`resolveLang`)가
> `ko`일 때만 보인다. 없으면 영어로 폴백한다 (`src/core/breaking.js` `localizeBreakingEntry`).
> 마이그레이션 가이드(실행 기록)에는 영어 원문이 남는다. 검사: `test/breaking.test.js`
> (모든 항목에 영어 `title`/`message`), 한글 줄 감시는 `*_ko` 키 줄만 세지 않는다.
---
## Skill routing
이 프로젝트에서 사용 가능한 스킬 호출 규칙:
| 요청 유형 | 호출 스킬 |
|----------|----------|
| **이슈 만들어줘, 이슈 등록, 버그 리포트, PR 생성, PR 올려줘, PR 머지, 이슈 댓글, 댓글 달아줘/수정/삭제, 이슈 확인, 이슈 닫기, 라벨 추가/제거, 담당자 추가, PR 조회, GitHub API** | **`pro-github` ← 최우선 트리거** |
| 뭔가 안 됨, 버그, 장애, 원인 파악 | `superpowers:systematic-debugging` |
| 코드 리뷰 | `pro-review` |
| QA 테스트케이스 | `pro-testcase` |
| 이슈 작성 / 이슈 생성 | `pro-github` (이슈 생성 워크플로우 흡수) |
| 커밋 | `pro-commit` |
| 배포 / automerge 실패 재트리거 | `pro-changelog-deploy` |
| 보고서 | `pro-report` |
| 원격 서버 SSH 접속, 로그/상태 확인 | `pro-ssh` |
| **실제로 테스트해줘, 에뮬레이터·시뮬레이터·브라우저로 확인, E2E, 끝까지 밟아줘** | `pro-agent-test` |
| **에뮬레이터 띄워서 찍어줘, 시뮬레이터 스크린샷, 브라우저로 캡처, 모바일 폭으로·빈 목록·500 화면 찍어줘** | `pro-launch` |
| **디자인 요청해줘, 시안 요청 올려줘, 디자이너한테 보낼 거 만들어줘, 빈 화면·에러 화면도 그려 달라고 해줘** | `pro-design-brief` |
| **오픈소스 점검, 레포 컨설팅, trending 가려면, 오픈소스답게 만들어줘, 내 레포 전부 점검, README·라벨·라이선스 괜찮아?** | `pro-oss-consult` |
| **figma 디자인 구현, 시안대로 만들어줘, 시안이랑 같은지 확인, 디자인대로 됐는지, 그림자 빠진 거 없나** | `pro-figma` |
| **설계·기획 (무엇을 왜)** | **`superpowers:brainstorming`** |
| **구현 계획 (어떻게)** | **`superpowers:writing-plans`** |
| **계획 실행** | **`superpowers:executing-plans`** |
> 설계·계획·구현 흐름은 위 superpowers 3종이 담당한다. (옛 분석·계획·구현 스킬 3종은 #849에서 삭제되어 superpowers로 이관됐다.)
## 커밋 컨벤션 필수 규칙
커밋 메시지 앞에 이모지·태그(`🚀[기능개선]`, `⚙️[기능추가]` 등) **절대 포함 금지**.
이슈 제목에서 이모지+태그를 제거한 순수 내용만 사용한다.
- 올바른 예: `RELEASE-CHANGELOG PR 본문 초기화 보호 로직 추가 : feat : ... https://...`
- 잘못된 예: `🚀[기능개선][ChangeLog] RELEASE-CHANGELOG : feat : ...`
커밋은 `pro-commit`이 만든다. `pro-report`는 커밋을 직접 실행하지 않는다(SKILL.md에서 금지) — 커밋 메시지를 제안하는 스킬도 이 규칙을 따른다.
### 커밋 타입이 릴리스 버전을 결정한다 (#546 — agent 필독)
`semver_auto`가 켜진 레포(이 레포 포함)는 **릴리스 구간 커밋 제목으로 버전 승격 폭이 정해진다.**
커밋 타입을 아무거나 고르면 버전이 잘못 나간다.
| 커밋 타입 | 릴리스 결과 |
|---|---|
| `제목 : feat! : 내용` (또는 `feat!:`) | **major** — 4.2.45 → 5.0.0 |
| `제목 : feat : 내용` | **minor** — 4.2.45 → 4.3.0 |
| `fix` / `docs` / `chore` / `refactor` / `test` | patch — 4.2.45 → 4.2.46 |
- **`!`는 호환성이 깨질 때만 붙인다** — 기존 사용자의 설정·API·CLI 인자가 더 이상 동작하지
않게 되는 변경. 확신이 없으면 붙이지 말고 사용자에게 묻는다.
- 한 릴리스 구간에 섞이면 가장 높은 것이 이긴다 (major > minor > patch).
- `[skip ci]` 포함 커밋과 `Merge`로 시작하는 커밋은 판정에서 제외된다.
- 판정 로직: `.github/scripts/changelog_manager.py` `classify_bump_level()`.
테스트: `python3 -m pytest .github/scripts/test/test_classify_bump.py`
## 기능 구현 워크플로우
새 기능 구현 시 순서:
1. `superpowers:brainstorming` — 설계 및 스펙 확정
2. `superpowers:writing-plans` — 상세 구현 계획
3. `superpowers:executing-plans` — 실제 구현
4. `superpowers:requesting-code-review` — 코드 리뷰 요청
---
## 알려진 스킬 동작 문제 및 해결 가이드
### 1. `pro-github` 스킬 자동 트리거 실패
**문제**: 사용자가 "PR 올려줘", "댓글 달아줘" 등을 요청해도 `pro-github` 스킬이 자동으로 트리거되지 않고, 다른 스킬(brainstorming 등)이 먼저 실행됨.
**원인**: `superpowers:using-superpowers` 규칙에서 어떤 스킬이든 1% 가능성이면 먼저 호출하도록 강제되는데, `brainstorming` 등 범용 스킬이 더 넓은 설명을 가지고 있어 우선 매칭됨. `pro-github` description의 트리거 키워드("PR 만들어줘", "댓글 달아줘")가 있어도 다른 스킬보다 낮은 우선순위로 처리됨.
**해결 방법 (사용자 관점)**:
- GitHub 작업 시 명시적으로 `/pro-github` 슬래시 커맨드를 입력
- 또는 메시지 앞에 "github:" 접두어 사용 ("github: PR 올려줘")
**해결 방법 (스킬 개발 관점)**:
- `skills/pro-github/SKILL.md`의 description에 더 구체적인 트리거 키워드 추가 필요
- 또는 `pro-github`를 Skill routing 표에 더 명확한 패턴으로 등록
- PR 생성, 이슈 댓글, GitHub API 작업은 **반드시 `/pro-github` 명시 호출** 원칙을 CLAUDE.md에 명시
### 2. `pro-github` 스킬 실행 시 Repo 자동 감지 실패 (워크트리 환경)
**문제**: 스킬이 `git remote get-url origin`으로 현재 디렉토리의 repo를 감지하는데, Claude Code가 **projectops** 레포 컨텍스트에서 실행 중이면 `TEAM-ROMROM/RomRom-BE`가 아닌 `Cassiiopeia/projectops`를 origin으로 잡음.
**원인**: 작업 대상 레포(RomRom-BE)가 별도 워크트리에 있거나, 현재 Claude Code 세션의 primary working directory가 다른 레포인 경우 발생.
**해결 방법**:
- 스킬 호출 시 대상 레포를 명시: `/pro-github TEAM-ROMROM/RomRom-BE 이슈 #653 PR 생성`
- config의 `repos` 목록에 등록된 레포는 이름으로 지정 가능: "RomRom-BE PR 올려줘"
### 3. Windows 환경 Python `urllib` 에러 (Exit code 49)
**문제**: `curl ... | python3 -c "..."` 파이프라인에서 `Exit code 49` 오류 발생. Python이 정상 설치되어 있어도 bash 파이프에서 python3 경로 인식 실패.
**원인**: Windows Git Bash 환경에서 `python3` 명령이 Windows Store python stub을 가리키거나, 파이프 stdin 처리 방식 차이.
**해결 방법 (스킬 개선 필요)**:
- Windows 환경에서 GitHub API JSON 파싱은 `curl | python3 -c` 대신 **PowerShell `Invoke-RestMethod`** 사용
- 또는 curl 응답을 파일로 저장 후 파싱: `curl ... -o /tmp/out.json && python3 /tmp/out.json`
- `skills/pro-github/SKILL.md`에 Windows 대응 PowerShell 코드블록 추가 필요
**임시 해결**: Claude가 PowerShell tool을 직접 사용하여 `Invoke-RestMethod`로 GitHub API 호출
### 4. PR head 브랜치명 422 오류 (한글 브랜치명)
**문제**: 브랜치명에 한글이 포함된 경우(`20260420_#653_FCM_푸시_페이로드에_라우팅용_데이터_포함_필요`) GitHub API PR 생성 시 `422 Validation Failed: head invalid` 오류.
**원인**: PowerShell `ConvertTo-Json`이 한글 포함 문자열을 올바르게 인코딩하지 못하거나, GitHub API가 URL-encoded 브랜치명을 다르게 처리.
**해결 방법**:
- PR 생성 전 실제 remote 브랜치 존재 여부를 먼저 확인: `git ls-remote origin "브랜치명"`
- 브랜치명이 push되어 있는지 확인 후 API 호출
- `head`에 `owner:branch` 형식 사용: `"TEAM-ROMROM:브랜치명"`