LogoSkills

/cc-dev:run — 작업 한 줄이면 끝까지 굴려주는 "개발 자동 컨베이어"

이슈 생성부터 머지까지 전체 개발 사이클을 자동화합니다.

/cc-dev:run — 작업 한 줄이면 끝까지 굴려주는 "개발 자동 컨베이어"#

항목내용
실행 명령/cc-dev:run
분류개발
난이도●●● 높음
MCP 서버zenhub, figma

파이프라인의 개발 실행 단계. "무엇을 만들지" 한 줄만 주면 이슈 등록부터 코드 작성, 테스트, 검수, 머지까지 13단계를 자동으로 진행합니다.

한마디로#

"이런 거 만들어줘" 한 줄을 넣으면, 할 일 등록 → 코드 작성 → 품질 검사 → 최종 합치기까지 알아서 진행되는 자동 컨베이어 벨트입니다. 공장 라인에 원자재(작업 내용)를 올려두면 완성품(합쳐진 코드)이 나오는 것과 같아요. 단, 마지막 "합쳐도 될까요?"는 사람이 직접 승인합니다.

누가·언제 쓰나요#

  • 새 기능 추가, 버그 수정, 코드 정리(리팩토링)를 처음부터 끝까지 한 번에 진행하고 싶을 때
  • "무슨 작업을 할지" 설명만 있고, 그걸 이슈로 만들고 코드까지 이어가고 싶을 때
  • 이미 등록된 작업 번호(이슈)가 있어서, 그 번호로 바로 개발을 시작하고 싶을 때
  • Jira에 이미 올라온 티켓(예: UB-123)이 있어서, 그 내용을 ZenHub 이슈로 가져와 시작하고 싶을 때 — 제목·라벨에 Jira 출처가 자동으로 남습니다(Jira는 읽기 전용으로만 조회, 절대 쓰지 않습니다)

👉 한 줄 설명으로 시작하면 작업 번호(이슈)부터 새로 만들어 주고, 이미 번호가 있으면 그 번호로 곧장 이어가며, Jira 티켓 키로 시작하면 그 내용을 가져와 새 ZenHub 이슈로 만들어 줍니다.

무엇을 해주나요#

작업 하나가 다음과 같은 결과물로 정리됩니다. (괄호 안은 실제로 남는 산출물)

  • 작업 등록표(ZenHub 이슈) — PM 관점에서 요구사항·완료 기준·스코프를 한 번 다듬은 뒤(피그마 링크가 있으면 디자인 내용도 반영), 작업 종류·범위·예상 규모가 자동으로 매겨진 채로 등록. Jira 티켓에서 시작한 경우 제목 앞에 Jira 키(예: UB-123)가, 라벨에 JIRA-UB처럼 출처+프로젝트 키가 자동으로 붙어 나중에도 어느 Jira 티켓에서 왔는지 추적할 수 있음
  • 전용 작업 공간(브랜치){종류}/{이슈번호}-{이름} 형식의 격리된 코드 작업 공간
  • 겹치지 않는 착수 — 시작하기 전에 "이 일감을 지금 다른 세션이 잡고 있는지"를 먼저 확인합니다. 잡혀 있으면 손대지 않고 그대로 물러납니다(상대 작업이 끝나면 그때 다시 실행하면 됩니다). 반대로 세션이 죽어 방치된 일감은 일정 시간(기본 4시간)이 지나면 자동으로 이어받습니다 — 아무도 못 건드리는 상태로 굳지 않습니다
  • 보드가 항상 사실과 일치 — 착수·검수·완료뿐 아니라 막히거나 중단된 경우에도 보드 칸을 옮깁니다. "진행 중"에 남아 있는 일감은 실제로 누군가 진행 중인 것뿐입니다
  • 시나리오 명세(.feature 파일) — 화면 작업이면 "어떻게 동작해야 하는지" 테스트 시나리오 자동 작성
  • 디자인 대조 증빙(스크린샷+비교 리포트) — 화면 작업이면 실기기·시뮬레이터에 띄운 화면을 Figma 시안과 대조한 사진과 리포트
  • 검증된 코드와 테스트 — 단위/위젯/통합 테스트가 모두 통과한 상태의 커밋들
  • 합치기 요청서(PR) — 변경 요약·테스트 결과·코드 리뷰 결과가 담긴 Pull Request
  • 작업내역 페이지(아티팩트) + PR 댓글 — PR 을 올린 뒤 서버 검사(CI)가 끝나기를 기다리는 몇 분 동안, "무엇을 왜 바꿨는지 · 어디부터 보면 되는지"를 정리한 읽기 좋은 페이지를 만들어 PR 에 댓글로 링크를 남깁니다. 기다리는 시간이 늘지는 않고, 검사 결과가 나오면 같은 링크가 갱신됩니다 (🔒 발행 시점엔 비공개 — 팀 열람은 claude.ai 에서 공유를 켜야 합니다)
  • 자동 정리 — 합쳐지면 이슈가 자동으로 "완료(Closed)" 처리되고, 다음 작업을 위해 기준 코드가 최신 상태로 갱신

어떻게 쓰나요#

# 가장 기본 — 작업 내용 한 줄로 처음부터 끝까지
/cc-dev:run  " 저자 목록 화면 추가 " 

 # 이미 등록된 작업 번호(이슈)로 시작
/cc-dev:run 1810

# Jira 티켓 키로 시작 — 제목에  " UB-123: " , 라벨에  " JIRA-UB "   자동 부여
/cc-dev:run UB-123

# 작업 종류를 직접 지정 (feat=기능 / fix=수정 / refactor=정리)
/cc-dev:run --type feat  " Add author list screen " 

 # 이번 스프린트에 배정
/cc-dev:run --sprint current  " Add author list screen " 

 # 합치기(머지)까지는 기다리지 않고 PR만 생성
/cc-dev:run --no-merge  " Add author list screen " 

 # 리뷰 건너뛰기 (긴급 핫픽스)
/cc-dev:run --skip-review  " Urgent fix " 

 # 테스트 건너뛰기 (긴급)
/cc-dev:run --skip-tests  " Urgent fix "

참고: 기획 명세(docs/seed-spec-*.md, 확정/LOCKED)가 있는 저장소에서 여러 이슈를 한 번에 도는 /cc-dev:batch 는 시작·마무리 시점에 "작업이 원래 기획 목표와 같은 방향인가"를 의미 기반으로 한 번씩 대조합니다(읽기 전용, /cc-spec:status 재사용). 단일 이슈용 /cc-dev:run 에는 이 점검 대신, 이슈 생성 직전(Step 1.5)에 pm-spec-agent가 한 줄 요청을 FR/AC·스코프·Story Point로 정제합니다 — LOCKED seed-spec이 있으면 그걸 우선 참고하고, 없어도 매번 동작합니다(--skip-pm으로 생략 가능).

옵션을 따로 주지 않으면 작업 종류·범위·규모·BDD 필요 여부 등을 내용에서 알아서 추론하고, 이슈를 만들기 전에 PM 관점의 요구사항 정리(Step 1.5)까지 자동으로 거칩니다. 이 밖에도 --scope(범위 지정), --point(규모 지정), --base(기준 브랜치 지정), --bdd/--skip-bdd(시나리오 강제/생략), --skip-pm(PM 요구사항 정리 생략), --skip-local-integration(로컬 통합 검증 생략), --skip-design-verify(화면 디자인 검증 생략, 확인 필요), --design-target/--design-tolerance(검증 기기·허용오차 지정), --device/--skip-device(버그 이슈의 실기기 재현 대상 지정·생략) 같은 세부 옵션이 있습니다. 정확한 동작은 아래 상세 명세를 참고하세요.

안에서 무슨 일이 벌어지나요#

크게 "등록 → 작업 공간 준비 → 만들기 → 여러 겹의 품질 검사 → 합치기" 순서로 13단계가 차례로 진행됩니다.

  1. 작업 내용 분석 + PM 요구사항 정리 — 글에서 작업 종류(기능/수정/정리), 범위, 화면 종류, 예상 규모를 자동 판별한 뒤, PM 관점에서 요구사항·완료 기준·스코프(포함/제외)를 정리합니다(피그마 링크가 있으면 디자인 내용 반영, --skip-pm으로 생략 가능). 애매한 부분은 되묻지 않고 "가정"으로 남긴 채 진행합니다.
  2. 이슈 등록 (ZenHub) — 정리된 내용으로 작업 등록표를 만들고 타입·라벨·예상치를 붙입니다.
  3. 대기열로 이동 — 등록된 작업을 "할 일(Product Backlog)" 칸으로 옮깁니다.
  4. 작업 공간(브랜치) 생성 — 다른 코드와 섞이지 않도록 전용 브랜치를 만듭니다. (메인 코드에 직접 손대지 않도록 막는 안전장치 포함)
  5. 진행 중으로 표시 — 작업을 "진행 중(In Progress)" 칸으로 옮기고, 이 작업의 상위 작업(Epic 등)이 아직 손대지 않은 칸에 남아 있다면 그것도 함께 "진행 중"으로 옮겨 보드에 실제 진행 상황이 바로 보이게 합니다.
  6. 시나리오 작성 — 화면 작업이면 "이렇게 동작해야 한다"는 BDD 시나리오를 자동으로 씁니다.
  7. 실제 구현 + 화면 디자인 검증 — 이슈 내용대로 코드를 작성하고 조금씩 저장(커밋)합니다. 화면 작업이면 시뮬레이터·실기기에 실제로 앱을 띄워 Figma 시안과 사진으로 대조하고, 다르면 똑같아질 때까지 자동으로 고칩니다(도구/기기가 없거나 그래도 안 맞으면 사람에게 확인을 요청 — 무음으로 건너뛰지 않습니다).
  8. 백엔드 코드 생성·로컬 통합 검증 — 서버 쪽 변경이 있으면 코드를 생성하고, 로컬에서 서버+DB+앱을 띄워 실제로 잘 맞물리는지 확인합니다.
  9. 여러 겹의 품질 검사(게이트) — 테스트 통과, 시나리오 커버리지 100%, 코드 서식·정적 분석 0건, 코드 리뷰의 치명적 문제 0건을 차례로 통과해야만 다음으로 넘어갑니다. 하나라도 막히면 합치기 요청을 만들지 않습니다.
  10. 합치기 요청(PR) 생성 — 검사를 모두 통과하면 변경 요약·테스트 결과를 담은 PR을 만들고 검수(Review/QA) 칸으로 옮깁니다.
  11. 추가 리뷰 반영 — 남은 리뷰 의견을 반영하고 완료 체크리스트를 돌립니다.
  12. 합치기 승인 대기 → 합치기 — 사람에게 "합쳐도 될까요?"를 묻고, 승인하면 합치기 직전에 서버 검사(CI) 결과를 한 번 더 읽어 전부 통과했을 때만 하나로 합칩니다(합치기 = 작업 완료). 검사가 통과하지 않았거나 검사가 아예 0건이면 합치지 않고 멈춥니다. "수정 필요"를 고르면 7번으로 돌아가며, 그때 이미 통과 처리된 품질 검사들이 다시 "안 한 상태"로 되돌아갑니다(되돌리지 않으면 재검사가 한 건도 일어나지 않기 때문입니다) — 되돌아가는 횟수는 2번으로 제한됩니다.
  13. 마무리 확정·동기화 — 이슈가 정말 "완료"로 닫혔는지 확인하고, 다음 작업을 위해 기준 코드를 최신 상태로 끌어옵니다.

⚙️ 상세 옵션·실행 명세 (개발자 / AI 에이전트용)

Triggers#

  • When starting new feature/bug/refactoring work
  • When full cycle execution is needed from work content alone
  • When batch processing issue creation → implementation → PR → review → merge

Usage#

Basic Usage#

# Start full cycle with work content
/cc-dev:run  " 저자 목록 화면 추가 "

Start with Existing Issue (from Step 4)#

/cc-dev:run 1810

Start from a Jira Ticket (import into ZenHub/GitHub)#

# Jira issue key — title gets prefixed  " UB-123: " , label  " JIRA-UB "   is added
/cc-dev:run UB-123

# A Jira ticket URL also works — the key is extracted from the path
/cc-dev:run https://yourorg.atlassian.net/browse/UB-123

Fetches the Jira ticket read-only (getJiraIssue) and uses its summary/description as work_content for Steps 1–2. Never creates or edits anything in Jira — see Issue Tracker Policy and Jira Import Naming Convention.


Parameters#

ParameterRequiredDescriptionExample
work_content✅*Description of the work"저자 목록 화면 추가"
issue_number✅*Existing issue number1810
jira_ref✅*Jira issue key or ticket URL to import — read-only fetch, never written toUB-123

*Exactly one of work content, issue number, or a Jira reference is required


Options#

Auto-Decision Options (defaults)#

When options are not specified, they are determined automatically:

OptionDefaultAuto-Decision Rule
--typeAuto-inferredKeyword analysis: "add/create"→feat, "fix/repair"→fix, "improve"→refactor
--scopeAuto-inferredExtract Entity/Feature name from work content, ask if unclear
--pointAuto-estimatedComplexity analysis: single file(1), multiple files(3), multiple layers(5), with BDD(8)
--bddOn screen detectionList/detail/form keyword detection → auto BDD scenario writing
--baseAuto-detectedAuto-detect parent issue branch (development if none)
--sprintcurrent (active sprint)current→활성 스프린트(getSprint, id 없음=활성), next→다음 스프린트(getUpcomingSprint), 숫자/이름→listRecentSprints 매칭. 생성 이슈를 해당 스프린트에 넣어 로드맵/번다운에 노출
--no-sprintoff스프린트 배정 생략(이 경우 저수준 이슈는 로드맵 타임라인에서 보이지 않을 수 있음)
--mergeask (Step 12에서 승인 질문)pre-authorized → Step 12 머지 승인 질문을 생략하고 즉시 스쿼시 머지(+ Step 12.5 Close 검증). /cc-dev:go//cc-dev:batch가 전파하는 값이지만, 이 커맨드를 직접 호출하는 어떤 오케스트레이터에서도 유효하다 — 품질 하드 게이트(테스트/lint/DCM/리뷰 Critical)와 Step 0 조기 중단은 그대로 유효하며, 사람 승인 클릭만 생략된다
--unattendedoff이 실행에 답할 사람이 없다는 선언(/cc-dev:go --auto//cc-dev:batch --unattended가 전파하는 값이거나, Orca 워크트리·CI 등 헤드리스 디스패치에서 직접 지정). AskUserQuestion 자리마다 정지 대신 선언된 무인 기본값으로 대체되며, 그 전수 목록과 값은 바로 아래 "Unattended & Approval Contract"가 유일한 정의다. --merge=pre-authorized 와는 별개 축이다 — --unattended 만으로는 머지 승인을 대신하지 못하고(Step 12는 그대로 정지), --merge=pre-authorized 만으로는 이 아래 다른 자리들(디자인 에스컬레이션·중복 작업 충돌 등)의 무인 기본값이 켜지지 않는다
--skip-pmoff (PM 정제 항상 실행)Step 1.5 PM 요구사항 정제(pm-spec-agent)를 건너뛰고 Step 1의 키워드 추론 결과를 그대로 이슈 본문에 사용 (긴급/사소한 한 줄 작업용). 서술(narrative)이 없으므로 아티팩트도 발행되지 않는다 — 종전과 동일한 전량 마크다운 본문
--skip-dup-checkoff (Step 0.5 항상 실행)Step 0.5 중복 착수 preflight(열린 PR·원격 브랜치 조회)를 건너뜀. 이미 알고 이어받거나 의도적으로 경쟁 구현할 때만 사용. ⛔ Step 0.4 점유 가드는 끄지 않는다 — 중복(같은 작업이 이미 있는가)과 점유(지금 누가 잡고 있는가)는 다른 질문이다
--force-claimoff (Step 0.4 항상 실행)Step 0.4 점유 가드를 무시하고 착수한다 — 살아 있는 남의 점유(other-live)까지 덮는다. 상대 세션이 죽은 것을 사람이 확인했을 때만 쓴다. 사용 시 점유 대장 note: 에 강제 사유가 남는다
--skip-design-verifyoff (요구 시 확인 필수)screen feature + Figma 소스 존재 시 Step 7.3 기본 on. 도구/기기 부재 시 AskUserQuestion 기본 답만 "스킵"으로 바꿀 뿐, 확인 자체는 생략 불가
--design-targetautopixel-loop/visual-verify로 전달할 대상 기기 (ios/android/macos/auto)
--design-tolerancepixel=2,color_delta_e=3 (create) / pixel=1,color_delta_e=2 (repair)pixel-loop 자체 기본값과 동일한 원칙 — create(최초 작성)는 다소 완화, repair(재작업)는 엄격
--deviceauto (실기기 → 시뮬레이터 순)Step 7.0 버그 재현에 쓸 기기 id/name. 지정한 기기가 목록에 없으면 다른 기기로 대체하지 않고 중단한다 (bugfix.md Step 3.5)
--skip-deviceoff (버그 이슈면 Step 7.0 기본 실행)Step 7.0 런타임 재현·진단 생략. 무음 스킵이 아니라 AskUserQuestion 확인 + PR body 기록(waivedGates: runtime-repro, 원인은 '추정')이며, --skip-tests 로는 면제되지 않는다

Explicit Options#

# Specify type
/cc-dev:run --type feat  " Add author list screen " 

 # Specify scope
/cc-dev:run --scope console-author  " Add author list screen " 

 # Specify point
/cc-dev:run --point 5  " Add author list screen " 

 # Sprint assignment
/cc-dev:run --sprint current  " Add author list screen " 

 # PR only (no merge wait)
/cc-dev:run --no-merge  " Add author list screen " 

 # Explicit base branch (manual parent branch setting)
/cc-dev:run --base epic/1810-author-feature 1812

# Skip review (urgent hotfix)
/cc-dev:run --skip-review  " Urgent fix " 

 # Force BDD scenarios (even for non-screen features)
/cc-dev:run --bdd  " API client refactoring " 

 # Skip BDD scenarios and BDD Coverage Gate (even for screen features)
/cc-dev:run --skip-bdd  " Simple list modification " 

 # Skip tests (urgent)
/cc-dev:run --skip-tests  " Urgent fix " 

 # Skip local full-stack integration verification (Step 7.7, default on)
# 로컬 풀스택(serverpod start, 내장 Postgres) 통합 검증을 건너뜀
/cc-dev:run --skip-local-integration  " Backend-only config tweak " 

 # Skip PM requirements refinement (Step 1.5, default on)
# 이슈를 만들기 전 PM 요구사항 정제 단계를 건너뛰고 Step 1의 키워드 추론만 사용
/cc-dev:run --skip-pm  " Urgent one-liner " 

 # Skip design verification (Step 7.3, default on for screen features) — still requires AskUserQuestion confirmation
/cc-dev:run --skip-design-verify  " Urgent hotfix without a simulator handy " 

 # Explicit design-verification target/tolerance
/cc-dev:run --design-target=android --design-tolerance pixel=1,color_delta_e=2  " 저자 상세 화면 추가 "

Unattended & Approval Contract#

⚠️ 이 커맨드는 /cc-dev:go·/cc-dev:batch 전용이 아니다. ZenHub 이슈 하나를 Orca 워크트리·CI· 다른 자동화가 헤드리스로 디스패치하는 자리라면 어디서든 직접 호출될 수 있다. AskUserQuestion답할 사람이 없는 세션 안에서는 무효다(rules/orchestration-graph.md §2) — 그런데 아래 표의 자리들에서 사람의 응답 없이 그대로 멈추면, 디스패치한 쪽에는 "왜 안 끝나는지"를 알 방법이 없다 (그 세션에는 콘솔을 볼 사람이 없다). --unattended 를 지정하지 않고 헤드리스로 이 커맨드를 호출하는 것은 이 계약 밖의 미정의 동작이다 — 호출자가 이 플래그를 붙이는 것이 전제조건이다.

아래가 이 커맨드 안에서 도달 가능한 AskUserQuestion 자리의 전수 목록이며 유일한 정의다 (/cc-dev:batch의 "Unattended & Approval Contract" ⑦번 행이 "commands/run.md 가 SoT — 여기서 재정의하지 않는다"라고 참조하는 바로 그 표):

#자리 (Step)무엇을 묻나무인(--unattended) 기본값남겨야 하는 내구 기록
Step 0.5 — 열린 PR 이 이 이슈를 참조(경쟁 작업)채택/병합/별도진행 중 택1중단 — 완성된 경쟁 작업을 사람 판단 없이 덮어쓰지 않는다호출자에게 [INCOMPLETE: duplicate_work_conflict] 보고, PR·브랜치 모두 그대로
Step 7 진입 전 — Agent Teams 팀 구성 계획 승인병렬 모드로 진입해도 되나순차로 강등(skills/agent-teams/SKILL.md Fallback Principle — 기능 차이 없음, 소요 시간만 다름)무승인 스폰 없음(하드 실패 아님) — 강등 사실만 로그
Step 7 — --skip-tests 지정 시 확인테스트 없이 진행할지스킵 확인으로 자동 채택 — 사용자가 --skip-tests 로 이미 의도를 명시했으므로 재확인은 승인이 아니라 UX 안전장치일 뿐이다prBodyExtras.testsSkipped(기존과 동일하게 PR body에)
Step 7.1 — 디자인 에스컬레이션 4종(브랜드 정체성·법적/정책·비가역 변경·R0 충돌)권고안 1개를 확정해도 되나확정하지 않고 이 이슈만 정지 — 다른 이슈/자식은 계속(go.md D-3·Error Handling과 동일 원칙)호출자에게 [INCOMPLETE: design_escalation_pending] + 권고안·근거를 이슈 코멘트에 기록
Step 7.3 — 디자인 검증 도구/기기 부재 또는 --skip-design-verify검증 없이 진행할지스킵 확인으로 자동 채택(이미 문서화된 "기본 답 = 스킵"을 무인 시 실제로 적용)prBodyExtras.designVerification = { status: "skipped", ... }
Step 7.3 — repair 후에도 diff 잔존Step 7 복귀 vs 그대로 진행Step 7 복귀(enterRework("S7R")) — "그대로 진행"은 결함을 숨기는 쪽이라 안전한 기본값이 아니다. 이미 rework pass 라면 기존 예산 소진 처리(BLOCKED('design_verify_exhausted'))가 그대로 적용되고 이 자리에 도달하지 않는다enterRework 진입 로그, 또는 예산 소진 시 BLOCKED('design_verify_exhausted')
Step 8.3 — --skip-bdd 지정 시 확인BDD 게이트 없이 진행할지스킵 확인으로 자동 채택 — ③과 동일 원칙(사용자가 이미 플래그로 의도를 명시)prBodyExtras.bddSkipped(기존과 동일하게 PR body에)
Step 12 — 최종 머지 승인PR 을 머지해도 되나--merge=pre-authorized함께 지정된 경우에만 머지. 아니면 PR 을 열어 둔 채 정지(질문을 던지지 않는다 — 답할 사람이 없다는 선언과 승인 클릭을 생략하는 선언은 서로 다른 축이다)PR·브랜치 그대로 + 호출자에게 [INCOMPLETE: merge_approval_unavailable]
Step 7.0 — 버그 이슈인데 재현 기기 부재 또는 --skip-device (순서상 ③ 앞)기기 재현 없이 정적 분석만으로 진행할지스킵 확인으로 자동 채택 — 헤드리스 디스패치에는 기기가 붙어 있지 않다. 다만 원인은 '추정'으로만 기록되고 확정으로 승격되지 않는다prBodyExtras.runtimeDiagnosis = { status: "waived", ... } + waivedGates: "runtime-repro" → PR body ## Runtime Diagnosis
  • ③⑤⑦⑨는 이미 명시적 플래그로 의도를 밝힌 스킵이라 무인 기본값이 "그대로 진행"이다 — 사람이 없어서 못 받는 승인이 아니라, 플래그 자체가 승인이다. ①④⑥⑧은 그 자리에서 처음 발생하는 판단이라 안전한 쪽(정지 또는 보수적 선택)으로 기운다. 무엇을 스킵할지 미리 선언 가능한 자리와 그 순간에만 드러나는 판단을 같은 기본값으로 묶지 않는다.
  • ②는 스폰 승인이고 ⑧은 머지 승인이다 — 서로 다른 대상이므로 --merge=pre-authorized 가 ②를 대신 면제하지 않는다(rules/orchestration-graph.md §4 매트릭스와 동일 원칙, commands/batch.md 의 자기 "Unattended & Approval Contract" 표 ④/⑥ 구분과 동일).
  • /cc-dev:go//cc-dev:batch 가 이 커맨드를 호출할 때는 이미 자기 레벨에서 확보한 --unattended/--merge=pre-authorized 를 그대로 전파한다 — 이 표를 다시 판정하지 않는다. 다른 오케스트레이터(직접 만든 Orca 코디네이터 등)가 이 커맨드를 헤드리스로 디스패치할 때도 동일하게 두 플래그를 전달해야 한다 — 그러지 않으면 위 여덟 자리 중 하나가 답할 사람 없는 질문에서 조용히 멈춘다.

Execution Flow (13 Steps)#

규범 선언 — Steps 7–12 흐름 블록 ⚠️#

표기법·루프 계약 7필드·게이트 tri-state·기질(substrate) 매트릭스·well-formedness 체크리스트는 ../rules/orchestration-graph.md 가 SoT다. 여기서 다시 정의하지 않는다.

이 흐름의 규범 선언은 아래 ```flow 블록 하나다. 이 문서의 Step-by-Step Prerequisites 표, ASCII 박스 다이어그램, TodoWrite 시드, 다른 문서의 요약은 전부 그 블록의 파생 뷰(non-normative) 이며, 어긋나면 블록이 이긴다(SoT §6). 개별 루프의 (반복 횟수·타임아웃·무엇을 무효화하는지)은 각 루프의 자기 자리에 있다 — L-7.3→Step 7.3 · L-8.5→Step 8.5 · L-8.7→Step 8.7 · L-10.5/L-10.6→Step 10.5 · L-11→Step 11 · L-7R/L-6R→바로 아래 "재작업 루프 계약".

S5     ACT   브랜치 + In Progress(부모 cascade)    extern:true  writes:zenhub:pipeline,claim:acquire
S6     ACT   BDD 시나리오 작성                     extern:true
S7     ACT   구현 (implementation-agent)           own:lead  tier:standard
S7f    FORK  구현 병렬 (Agent Teams)               width:2  tier:standard  own:T1=kobic_server/lib/**|T2=kobic/lib/**  degrade:width=1
S7j    JOIN  Lead 인터페이스 정합 확인              mode:barrier  because:프론트↔백엔드 인터페이스는 교차 항목
S7.1   ACT   design reference + DDR                writes:.claude/docs/{scope}/design-decisions.md
S7.2   GATE  화면 PR diff 에 CoUI 소스 없음          verdict:git diff --name-only | grep coui  undet:fail  fail:S7R
S7.3   LOOP  design verification (Figma↔runtime)   contract:L-7.3
S7.5   ACT   backend codegen (pod:generate)        writes:**/*.g.dart  own:lead   # 생성물은 Lead 전용
S7.7   GATE  local full-stack integration          verdict:dart test -t integration exit  undet:fail  fail:S7R
S8w    FORK  테스트 코드 작성만 병렬               width:3  tier:standard  own:T1=kobic/test/{unit,bloc}/**|T2=kobic_server/test/**|T3=kobic/integration_test/**  degrade:width=1
S8j    JOIN  테스트 실행 직렬화                     mode:barrier  serialize:1  because:한 워크트리·한 포트에서 flutter test 를 동시 실행할 수 없다
S8     GATE  pr:preflight (unit/widget/integ)      verdict:/cc-dev:pr:preflight exit  undet:fail  fail:S7R
S8.3   GATE  BDD coverage 100% + assertions        verdict:stepCoverage==1  & &   !assertionless  undet:fail  fail:S6R
S8.5   LOOP  runPrePushGate                        contract:L-8.5
S8.7   LOOP  code review gate                      contract:L-8.7
S9.0   GATE  중복 착수 재확인 (--state all+baseRefName) verdict:경쟁 PR 0건 또는 겹침 없음 근거  undet:fail  fail:HALT
S9     GATE  pre-PR 7종                            verdict:mayClose(openChildrenStatus)  undet:fail  fail:HALT
S9x    ACT   gh pr create                          writes:github:pr
S10    ACT   moveIssueToPipeline(Review/QA)        writes:zenhub:pipeline
S10.5  LOOP  CI 대기 ∥ 작업내역 발행                 contract:L-10.5
S10.6  LOOP  CI 실패 수정 → re-push                 contract:L-10.6
S11    LOOP  리뷰 피드백 반영                       contract:L-11
S11.9  GATE  openChildrenStatus === none           verdict:mayClose(...)  undet:fail  fail:HALT
S12    ASK   머지 승인                              options:승인|수정필요|취소  pre:--merge=pre-authorized  unattended:pre 없으면 정지([INCOMPLETE: merge_approval_unavailable])
S12b   GATE  머지 직전 PR base 재해석 + retarget      verdict:baseRefName==findExistingHierarchyBranch(parent)(조회만)  & &   merge-base --is-ancestor origin/{base} origin/{PR head}  & &   rev-list --count origin/{base}..origin/{PR head} > 0  undet:fail  fail:HALT  skip:스택모드
S12g   GATE  머지 직전 CI 재조회                     verdict:gh pr checks --json name,state,bucket 전부 bucket=pass|skipping  undet:fail  fail:S10.6
S12m   ACT   gh pr merge --squash                  writes:github:pr,github:issue
S12.5  ACT   close 검증 + 자식 불변식 복구           writes:github:issue,zenhub:state,git:base,claim:release
S12.6  ACT   Orca 워크트리 반납 표시 (자기 제거 금지)  writes:orca:worktree-card  because:회수는 만든 쪽(부모 batch)의 몫이다
S7R    LOOP  구현 재작업 (rework)                   contract:L-7R
S6R    LOOP  BDD 재작업                             contract:L-6R

S7 -- >   S7f -- >   S7j -- >   S7.2 -- >   S7.3 -- >   S7.5 -- >   S7.7 -- >   S8w -- >   S8j -- >   S8
S8 -- >   S8.3 -- >   S8.5 -- >   S8.7 -- >   S9.0 -- >   S9 -- >   S9x -- >   S10 -- >   S10.5 -- >   S11
S11 -- >   S11.9 -- >   S12 -- >   S12b -- >   S12g -- >   S12m -- >   S12.5 -- >   S12.6
S5    -- >   S7                         # hard prereq 는 S5 (Steps 1–3 을 건너뛰는 경로에서도 S5 는 실행)
S7R   -- >   S7                         # 재작업 루프의 몸통 = Step 7 재실행
S6R   -- >   S6
S10.6 -- >   S8.5                       # 수정 후 re-push 직전 runPrePushGate() 재실행
S7    .. >   S7.1                       # 비차단 참고 — 절대 게이트하지 않는다(전제조건에서도 제외)
S6    .. >   S7                         # advisory: S6 도 S5 를 전제로 하는 형제다
S7.3 ~~ >   S7.5  on:!screenFeature||!figmaSource  record:log+prBodyExtras.designVerification=null(not-applicable)
S7.3 ~~ >   S7.5  on:toolingMissing                record:ASK+prBodyExtras.designVerification(status:skipped)  unattended:스킵 확인 자동 채택
S8   ~~ >   S8.3  on:--skip-tests  & &   ASK== " 스킵 확인 "    record:ASK+prBodyExtras.testsSkipped → PR body            unattended:스킵 확인 자동 채택(플래그가 이미 승인)
S8.3 ~~ >   S8.5  on:--skip-bdd  & &   ASK== " 스킵 확인 "      record:ASK+prBodyExtras.bddSkipped → PR body              unattended:스킵 확인 자동 채택(플래그가 이미 승인)
S8.7 ~~ >   S9.0  on:--skip-review                  record:prBodyExtras.reviewSkipped → PR body
S12  ~~ >   SKIP  on:approval== " 취소 "                   record:PR·브랜치 그대로 유지(되돌리지 않음) + TodoWrite in_progress
S12.6 ~~ >   SKIP on:!orca||워크트리 밖 실행           record:log(no-op — 표시할 카드가 없다)
S7.2 == >   S7R   on:couiSourceInDiff          bound:2  invalidates:S7.2,S7.3,S7.5,S7.7,S8,S8.3,S8.5,S8.7,S9.0,S11.9
S7.3 == >   S7R   on:verify.fail_after_repair  bound:1  invalidates:S7.2,S7.3,S7.5,S7.7,S8,S8.3,S8.5,S8.7,S9.0,S11.9
S7.7 == >   S7R   on:integration.fail          bound:2  invalidates:S7.2,S7.3,S7.5,S7.7,S8,S8.3,S8.5,S8.7,S9.0,S11.9
S8   == >   S7R   on:test.fail                 bound:2  invalidates:S7.2,S7.3,S7.5,S7.7,S8,S8.3,S8.5,S8.7,S9.0,S11.9
S12  == >   S7R   on:approval== " 수정 필요 "         bound:2  invalidates:S7.2,S7.3,S7.5,S7.7,S8,S8.3,S8.5,S8.7,S9.0,S11.9
S8.3 == >   S6R   on:coverage < 1                bound:2  invalidates:S6,S8.3,S8.5,S8.7,S9.0,S11.9
S10.5== >   S10.6 on:ci.fail                   bound:3  invalidates:S8.5,S10.5
S11  == >   S10.6 on:feedback.commit           bound:3  invalidates:S8.5,S10.5
S12g == >   S10.6 on:checks.notSuccess         bound:3  invalidates:S8.5,S10.5

읽는 법 (블록이 고정한 것 네 가지)

  1. 되돌림은 무효화를 동반한다. S12 ==> S7R 는 열 개 노드를 거슬러 오르므로, 진입 시 invalidates: 목록의 TodoWrite 항목을 pending 으로 되돌린다 — validateStepPrerequisites()completed/skipped 를 통과시키므로, 되돌리지 않으면 재실행되는 게이트가 하나도 없다. S7.2·S7.7·S8·S12 의 fail 은 모두 아래 enterRework("S7R") 한 곳으로 들어간다.
  2. CI 순환은 3 라운드에서 끝난다. S10.5·S11·S12g 가 서로를 먹이던 무제한 순환은 S10.6(L-10.6) 하나로 모아졌고, 실패 check 집합이 라운드마다 진부분집합으로 축소되지 않으면 예산을 더 쓰지 않는다.
  3. 팬아웃은 넓어지지 않았다. S7f(2)·S8w(3)는 이미 있던 Agent Teams 모드에 own: 글롭과 tier: 를 붙인 것이며, 테스트 실행S8j 배리어 뒤로 serialize:1 로 좁혀졌다(작성만 병렬). 생성물(pod:generate, **/*.g.dart)은 own:lead — teammate 가 쓰지 않는다.
  4. 스킵은 전부 내구 기록이다. ~~> 형제(7.3·8·8.3·8.7)가 모두 prBodyExtras → PR body 로 남는다. 콘솔 경고만 남기고 사라지는 스킵은 없다.

재작업 루프 계약 (L-7R · L-6R) — 되돌림 진입점

L-7RL-6R 은 여러 단계에 걸쳐 있어 자기 Step 절이 없다. 계약과 유일한 진입 함수를 여기 둔다 (사이트마다 되돌림을 재구현하면 그중 하나가 반드시 되돌리지 않는다).

L-7R (구현 재작업)
inv:      진입 시 invalidates: 목록을 pending 으로 되돌린 뒤에만 Step 7 을 재실행한다
prog:     되돌린 게이트 중 재실패로 남은 수, 매 라운드 강한 감소
          no-prog: 같은 게이트가 같은 이유로 두 번 실패하면 Rung 2(`/cc-dev:unstuck`)로 종류를 바꾼다
term:     S7.2·S7.3·S7.7·S8·S8.3·S8.5·S8.7 전부 재통과
budget:   2 rounds (S7.2/S7.7/S8/S12 진입 합산). S7.3 자체 repair 는 별도 bound:1 (L-7.3 소관)
exhaust:  blockIssue(issue,  ' rework_exhausted ' ) — holding 이동 + 사유 코멘트 + 점유 해제.
          PR 은 그대로 두고 중단 (승인 재질문으로 예산을 늘리지 않는다)
          정의: ../rules/zenhub-conventions.md → Pipeline State Contract / blockIssue()
resume:   TodoWrite 상태 + 실패 게이트의 마지막 verdict 재실측 (대화 카운터는 예산이 아니다)
log:       " 7R #1/2: 되돌림 10항목 · 재실패 3→1 "   + 탈락시킨 게이트와 이유

L-6R (BDD 재작업)
inv:      .feature 와 step 함수는 같은 커밋에 들어간다(둘 중 하나만 되돌아가지 않게)
prog:     missingSteps + assertion 없는 step 파일 수, 매 라운드 강한 감소
          no-prog: 동일 잔여면 `/cc-flutter:bdd:generate {scope} --only-steps true` 로 종류를 바꾼다
term:     stepCoverage === 1.0  & &   filesWithoutAssertions.length === 0
budget:   2 rounds
exhaust:  blockIssue(issue,  ' bdd_coverage_exhausted ' ) 후 중단. `--skip-bdd` 로 자동 우회 금지
resume:   .feature ↔ step/ 재스캔 (Step 8.3 판정 재실측)
log:       " 6R #1/2: missing=7→2 assertionless=1→0 "
// L-7R / L-6R 진입 — 되돌림·예산·복귀 지점이 이 함수 하나에만 있다
// (reworkRound·bddReworkRound·isReworkPass 는 Step 0.1 에서 사이클 전역으로 초기화된다)
async function enterRework(node:  " S7R "   |  " S6R " ) {
  const R = {
    S7R: { steps: [ " 7.2 " , " 7.3 " , " 7.5 " , " 7.7 " , " 8 " , " 8.3 " , " 8.5 " , " 8.7 " , " 9.0 " , " 11.9 " ], to:  " 7 " ,
           next: () = >   ++reworkRound,    bound: 2, blocked:  " rework_exhausted "   },
    S6R: { steps: [ " 6 " , " 8.3 " , " 8.5 " , " 8.7 " , " 9.0 " , " 11.9 " ],                        to:  " 6 " ,
           next: () = >   ++bddReworkRound, bound: 2, blocked:  " bdd_coverage_exhausted "   },
  }[node];

  const round = R.next();
  if (round  >   R.bound) {
    // exhaust: 침묵 금지 ·  " 경고 후 계속 "   금지
    // ⚠️ throw 만 하면 이슈는 In Progress 에 남는다 — 보드 반영은 **문구가 아니라 호출**이다.
    //    blockIssue() 가 holding 이동 + 사유 코멘트 + 점유 해제를 한 묶음으로 수행한다
    //    (SoT: ../rules/zenhub-conventions.md → Pipeline State Contract).
    await blockIssue(issue, R.blocked, { detail: `${node} 재작업 예산 ${R.bound} 라운드 소진` });
    throw new Error(
      `⛔ ${node} 재작업 예산 소진(bound:${R.bound})BLOCKED( ' ${R.blocked} ' ) 보드 반영 완료, 중단한다. ` +
      `PR/브랜치는 그대로 두고 사람 판단을 기다린다`
    );
  }
  // ⚠️ 여기서 되돌리지 않으면 아무 게이트도 재실행되지 않는다 (completed 는 그대로 통과한다)
  await resetStepsToPending(R.steps);   // TodoWrite 로 해당 항목 status →  " pending "   (아래 TodoWrite Integration)
  if (node ===  " S7R " ) isReworkPass = true;   // Step 7.3 의 mode: " repair "   신호
  console.warn(`♻️ ${node} 라운드 ${round}/${R.bound}Step ${R.to} 복귀. 되돌림: ${R.steps.join( " · " )}`);
  return await runFromStep(R.to);   // 단계 id 는 STEP_ORDER 와 같은 **문자열**이다 (8.5 가 8 로 접히지 않게)
}

Step 0: Tooling Preflight & Degradation Contract ⚠️ (GD-01)#

13단계 진입 전에 외부 도구 가용성을 한 번에 점검하고 capability 맵을 만든다. 이후 각 게이트는 이 맵을 참조한다 — 설치 안 된 도구를 bare 호출해 throw 시키지 않는다.

# loop/단계 진입 전 1회 — 결과를 capability 맵으로 보관
for tool in melos serverpod dart flutter gh; do
  command -v  " $tool "   > /dev/null 2 > & 1  & &   echo  " $tool: ok "   || echo  " $tool: MISSING " 
 done

# 디자인 검증 도구(Step 7.3 전용) — 아래  " 예외 "   참고, melos/serverpod 등과 취급이 다르다
claude mcp list 2 > /dev/null | grep -Eq  " figma.*connected "   & &   echo  " figma-mcp: ok "   || echo  " figma-mcp: MISSING " 
 claude mcp list 2 > /dev/null | grep -Eq  " marionette.*connected "   & &   echo  " marionette-mcp: ok "   || echo  " marionette-mcp: MISSING " 
 claude mcp list 2 > /dev/null | grep -Eq  " dart.*connected "   & &   echo  " dart-mcp: ok "   || echo  " dart-mcp: MISSING " 
 flutter devices 2 > /dev/null | grep -Eq  " • "   & &   echo  " device: ok "   || echo  " device: MISSING "

Degradation contract (반드시 준수):

  • 안전(품질) 위반 → 차단(throw): 테스트 실패 / lint 이슈 / 리뷰 Critical 등은 hard fail.
  • 도구 부재 → degrade + 경고(throw 아님): 빌드 도구 미설치는 안전 위반이 아니다. 우회하고 경고만 남긴다.
    • melos 없으면 → flutter test / dart analyze / dart format직접 호출(= preflight.md 폴백과 동일), melos run backend:pod:generate 는 skip + 경고.
    • serverpod 없으면 → Step 7.7(로컬 풀스택 통합 검증) skip + 경고 (--skip-local-integration 과 동일 취급).
    • gh 없음/미인증 → PR 단계(Step 9~) 진입 전에 조기 안내 후 중단(PR이 핵심 산출물이라 진행 의미 없음; 작업을 다 한 뒤 Step 9에서 throw 하지 말 것).
  • 금지: 설치 안 된 도구를 가드 없이 bare await Bash(...) 로 호출해 throw 시키는 것. 위 맵을 먼저 확인하고 분기하라.
  • ⚠️ 예외 (figma-mcp/marionette-mcp/dart-mcp/device): 이 4개는 위 "도구 부재 → 무음 degrade" 계약을 따르지 않는다. Step 7.3이 실제로 트리거되는 screen feature에서 이 중 하나라도 없으면, 무음으로 건너뛰지 말고 반드시 AskUserQuestion으로 명시 확인을 받은 뒤(--skip-tests와 동일한 UX), PR body에 스킵 사실을 영구 기록한다. 무음 스킵이 "화면이 매번 디자인과 달라지는" 문제가 지금까지 방치된 원인이었기 때문이다.

배경: 과거 /cc-dev:run 은 melos/serverpod/dart 명령을 존재 가드 없이 bare 호출해, 도구 1개 부재로 전체가 하드페일했다(GD-01). scripts/check_skill_drift.py 의 FAILOPEN warn 이 이 패턴을 감지한다.

Step 0.1: 진입 경로 판별 (startedFrom) — Steps 1–3 의 실행 조건#

⚠️ Step 1 이전에 판별한다. 아래 startedFrom 은 Step 3(Product Backlog 이동)·Step 2.5(스프린트 배정)의 실행 조건인데, Step 1.0 안에서 선언하면 그 값을 필요로 하는 경로(issue_number)에서 정의되지 않는다 — Steps 1–3 을 건너뛰는 경로이기 때문이다.

// resolveJiraRef 정의는 Step 1.0 참조 (Jira 키/URL → 키 문자열, 아니면 null)
const startedFrom =
  /^\d+$/.test(rawInput.trim()) ?  " issue_number "   :   // 기존 이슈 번호 — Steps 1–3 생략
  resolveJiraRef(rawInput)      ?  " jira "   :           // Jira 티켓 가져오기 — Steps 1–3 실행
   " work_content " ;                                    // 한 줄 설명 — Steps 1–3 실행

// ── 사이클 전역 재작업 상태 (L-7R / L-6R / L-10.6 의 예산 카운터) ──
// ⚠️ Step 7·Step 6 **재진입이 초기화하지 않는다**. 여기(사이클 진입 1회)에서만 0/false 로 둔다 —
//    되돌아간 자리에서 카운터를 다시 0 으로 만들면 bound: 가 예산이 아니게 되고 순환이 무제한이 된다.
let reworkRound = 0;       // S7.2/S7.7/S8/S12 == >   S7R 진입 횟수 (bound:2)
let bddReworkRound = 0;    // S8.3 == >   S6R 진입 횟수 (bound:2)
let ciFixRound = 0;        // S10.5/S11/S12g == >   S10.6 진입 횟수 (bound:3)
let isReworkPass = false;  // Step 7.3 의 mode: " repair "   신호 (S7R 진입 시 true)
startedFromSteps 1–3 (분석·PM 정제·이슈 생성·Product Backlog·스프린트)Step 4·5·10 (브랜치·In Progress+cascade·Review/QA)
work_content✅ 실행✅ 실행
jira✅ 실행✅ 실행
issue_number⏭️ 생략 (이슈가 이미 존재)실행 — 생략 불가

"Steps 1–3 생략"이 Step 5 까지 삼키지 않게 한다. /cc-dev:batch 는 leaf 를 항상 /cc-dev:run {number} 로 위임하므로, 이 경로에서 Step 5 를 함께 건너뛰면 배치 전체가 보드에 아무 흔적도 남기지 않는다 — 자식은 Product Backlog, Epic 도 Product Backlog 인 채로 개발이 진행되는 상태가 된다.

Step 3 을 생략한 경로에서는 TodoWrite 의 Step 3 항목을 skipped 로 표시한다 — validateStepPrerequisites()completed/skipped 만 통과시키므로, pending 으로 남기면 Step 4 진입이 막힌다.

Step 0.4: Work Claim Guard ⛔ (진행 중인 이슈는 집어들지 않는다)#

지금 다른 세션이 이 이슈를 잡고 있는지 착수 전에 확인한다. 판정 로직의 SoT 는 ../rules/zenhub-conventions.md → Work Claim Contract 이며, 여기서는 언제 부르고 결과로 무엇을 하는지만 정한다(판정 재구현 금지).

Step 0.5 와 다른 질문이다. Step 0.5 는 "같은 작업이 이미 어딘가에 있는가"(중복), 이 절은 "지금 이 이슈를 누가 잡고 있는가"(점유)를 묻는다. 중복은 완성된 경쟁 산출물을 찾는 일이고 점유는 진행 중인 세션을 찾는 일이라, 신호도 처방도 다르다 — 표를 섞지 않는다.

// 식별자는 Step 0 에서 1회 계산해 사이클 내내 고정 (owner = host:worktree, run = 실행 식별자)
const claim = await claimStatus(issue.number, me);   // mine | other-live | stale | none | unknown
판정행동무인(--unattended) 기본값
mine재개 — 대장을 새로 만들지 않고 heartbeat 만 갱신하고 계속동일
other-live착수하지 않는다[INCOMPLETE: issue_occupied] 로 호출자에 보고하고 종료. 브랜치를 만들지 않고, 보드도 건드리지 않는다(남의 점유다)동일하게 중단 — 무인이라고 완화하지 않는다
stale인수 — 대장을 내 소유로 덮고 note: 에 직전 소유자·마지막 하트비트를 남긴 뒤 진행동일
none통과 (Step 5 에서 acquire)동일
unknown대장을 못 읽었다 → hasRecentActivity() 로 강등 판정. 활동 있으면 other-live 취급, 없으면 경고 + claim_check_unavailable 기록 후 진행동일
  • --force-claim 으로만 우회한다. ⛔ --skip-dup-check 는 이 가드를 끄지 않는다 — 다른 질문이기 때문이다.
  • other-live 로 멈춘 것은 실패가 아니라 미착수다. /cc-dev:batch 는 그 child 를 SKIPPED-OCCUPIED 로 큐에서 빼고 형제를 계속 진행하며, /cc-dev:go 는 다음 항목으로 넘어간다. 회로 차단(go D-2.7)의 연속 실패 카운트에도 포함하지 않는다.
  • 재개 명령은 상대 세션이 끝난 뒤의 /cc-dev:run {n} 이다. 원인이 "내 쪽 결함" 이 아니므로 고칠 것이 없다.

In Progress 만 보고 판정하지 않는가cascadeStartToParents() 가 부모를 In Progress 로 올리므로 컬럼만 보면 모든 Epic 이 점유 상태로 보인다(그 아래 자식 작업이 전부 막힌다). 크래시로 잔류한 In Progress 는 영구 잠금이 되고, 사람이 보드에서 직접 옮긴 이슈는 "해라"는 신호인데 거부당한다. 세 경우를 가르는 것은 소유자와 시각을 든 대장뿐이다.

Step 0.5: Duplicate Work Preflight ⚠️ (GD-03)#

같은 작업이 이미 어딘가에 완성돼 있는지 착수 전에 확인한다. 병렬 세션(터미널 탭 여러 개 · 여러 worktree)이 같은 계정으로 동작하면 assignee 로는 구분이 안 되고, 이슈 트래커 파이프라인은 캐시 지연으로 늦게 반영된다. 가장 신뢰할 수 있는 신호는 GitHub 원본 — 열린 PR 과 원격 브랜치이며, 둘 다 즉시 조회된다. 조회는 내 이슈 번호만이 아니라 부모(Epic/Project) 번호까지 세 갈래로 돈다.

점유(누가 지금 잡고 있는가)는 Step 0.4 가 이미 판정했다. 이 절은 중복(같은 작업이 이미 있는가)만 본다. 파이프라인을 여기서 신호로 쓰지 않는 이유(캐시 지연)는 그대로지만, 점유 판정은 파이프라인 단독이 아니라 소유자·시각을 든 점유 대장으로 하므로 이 한계에 걸리지 않는다.

# 이슈 번호로 호출된 경우 — 정밀 조회
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2 > /dev/null)
gh pr list --repo  " $REPO "   --state open --search  " $ISSUE "   \
  --json number,title,headRefName,author 2 > /dev/null

# 원격 브랜치에 이슈 번호가 박힌 것이 있는지 (숫자 경계로 944094401 오탐 차단)
git ls-remote --heads origin 2 > /dev/null | grep -E  " [^0-9]${ISSUE}([^0-9]|$) " 

 # ⭐ 부모(Epic/Project) 번호로도 조회한다 — 자식 번호로는 부모 줄기가 안 보인다
OWNER=${REPO%%/*}; NAME=${REPO##*/}
PARENT=$(gh api graphql -f query= " query { repository(owner:\ " $OWNER\ " , name:\ " $NAME\ " ) {
  issue(number: ${ISSUE}) { parent { number } } } } "   \
  --jq  ' .data.repository.issue.parent.number '   2 > /dev/null)
[ -n  " $PARENT "   ]  & &   git ls-remote --heads origin 2 > /dev/null | grep -E  " (epic|project)/${PARENT}- "

⚠️ 세 번째 조회가 없으면 부모 줄기를 못 본다. 실사고: 착수 시점에 epic/{부모}-*이미 존재했고 그 위에 형제 story 브랜치들이 쌓이는 중이었는데, 조회가 자식 번호 하나만 grep 해서 아무것도 잡지 못했다. 두 세션이 같은 이슈를 각자 완주했고, 폐기한 쪽이 5커밋 44파일이었다. 부모 브랜치를 발견하면 그건 중복 신호가 아니라 내 base 가 그것이어야 한다는 신호다 (resolveBaseBranch() 와 같은 결론 — 아래 판정표).

⚠️ 이 조회에는 수명이 있다. 계층이 승격을 마치면 epic/*·project/* 는 삭제되므로 다시 무매치가 된다 — 즉 계층이 살아 있는 동안만 유효한 조기 신호이고, 시점과 무관하게 답을 주는 것은 Step 9.0 의 --state all + baseRefName PR 조회뿐이다. 무매치를 "부모가 없다"로 읽지 않는다.

⚠️ "아직 안 됐다"를 development 기준으로 판정하지 않는다. 계층 작업에서 머지된 story PR 은 epic 브랜치에만 있고 default 브랜치에는 없다. 그래서 default 브랜치에서 파일 존재를 확인하면 이미 구현·머지된 산출물이 전부 "없음"으로 보고된다 — 같은 이유로 git log origin/developmentgit merge-base --is-ancestor <머지커밋> origin/development 도 "아직"이라고 답한다. 판정은 PR 의 base 를 직접 본다:

gh pr list --repo  " $REPO "   --state all --search  " $ISSUE "   \
  --json number,state,baseRefName,headRefName
# state=MERGED 면 baseRefName 이 epic/*·project/* 여도 그 작업은 끝난 것이다

⚠️ 이슈를 새로 만드는 경로가 사각지대다 — 번호 조회로는 원리적으로 못 잡는다.

위 조회는 전부 이슈 번호 기반이라, 두 세션이 같은 증상을 각자 발견해 각자 새 이슈를 만들면 서로를 찾지 못한다. 실사고: 두 세션이 12분 간격으로 같은 CI 실패에 대해 각각 이슈를 만들고 둘 다 머지해, 중복 가드를 정리하는 세 번째 이슈가 추가로 필요했다. 두 번째 세션이 규칙을 따랐더라도 조회 대상이 자기 번호라 상대를 찾을 수 없었다.

그래서 Step 2에서 이슈를 생성하기 전에 증상으로 먼저 훑는다 (work_content 모드, 즉 이슈 번호 없이 시작한 경우 필수):

KEYWORD= " patrol nightly "       # 증상에서 뽑은 2~3 단어
gh issue list --repo  " $REPO "   --state open --search  " $KEYWORD "   --json number,title,createdAt
gh pr list    --repo  " $REPO "   --state open --search  " $KEYWORD "   --json number,title
  • 실패한 CI run ID에러 문자열도 좋은 검색어다 — 같은 증상을 본 세션은 그 문자열을 이슈 본문에 적었을 확률이 높다.
  • 최근 1시간 내 생성된 이슈를 특히 볼 것. 병렬 세션은 대개 몇 분 차이로 같은 것을 발견한다.

Jira 등 외부 트래커가 원본이면 GitHub 신호가 갈릴 수 있다.

세션마다 GitHub 이슈를 새로 만들면 이슈 번호·브랜치명이 갈려 GitHub 쪽 조회가 전부 무력해진다. 실사고(UB-409)에서 한 Jira 티켓이 GitHub 이슈 2개로 갈려 착수 전 확인이 정상 통과했는데도 세 세션이 얽혔다.

  • 한 외부 티켓 = 한 GitHub 이슈를 지킨다. 이관 전에 기존 이관 이슈를 먼저 찾는다: gh issue list --repo "$REPO" --state all --search "UB-409"
  • 검색·grep 은 외부 키와 GitHub 번호를 둘 다 시도한다 (UB-409 / 9586 / 9600).
  • 다른 세션의 흔적은 원본 티켓 코멘트에 남는다 — 원본이 갈리지 않는 유일한 지점이다 (읽기 전용 getJiraIssue(include: "comments")).

⚠️ 형제 브랜치가 같은 파일을 이미 열어 뒀을 수 있다 — 이슈 조회로는 안 잡힌다.

위 조회는 전부 이슈 번호 기반이라, 한 이슈가 story 브랜치 여럿을 낳는 계층 작업에서 무력하다. 실사고: 같은 이슈에서 나온 story 브랜치 2개가 파일 7개를 공유했는데, 이슈 번호가 같아 조회로는 구분되지 않았다. 충돌이 없었던 유일한 이유는 하나가 머지된 뒤 다른 하나가 시작된 우연한 직렬화였다 — 병렬로 돌렸으면 7파일이 부딪혔다.

착수 직전에 열려 있는 형제 브랜치와의 파일 교집합을 본다(이미 머지된 남의 수정은 parallel-session-collision 스킬의 파일 이력 조회가 담당한다 — 이쪽은 아직 안 머지된 축이다):

PARENT=$(gh pr view --json baseRefName -q .baseRefName 2 > /dev/null || echo development)
MINE=$(git diff --name-only  " origin/$PARENT...HEAD "   2 > /dev/null | sort)

git ls-remote --heads origin  ' refs/heads/story/* '   2 > /dev/null \
  | awk  ' {print $2} '   | sed  ' s|refs/heads/|| '   \
  | while read -r BR; do
      N=$(git diff --name-only  " origin/$PARENT...origin/$BR "   2 > /dev/null | sort \
            | comm -12 -  < (printf  ' %s\n '   " $MINE " ) | wc -l | tr -d  '   ' )
      if [  " $N "   -gt 0 ]; then echo  " ⚠️ $BR — 공유 ${N}파일 " ; fi
    done
  • 겹치는 형제가 있으면 병렬로 돌리지 말고 직렬화한다(형제가 머지된 뒤 착수).
  • 착수 시점에 내 브랜치가 아직 비어 있으면 이 조회는 0건이다 — 구현 중간에 한 번 더 돌리는 편이 실효가 있다.
  • 무인(--unattended)에서는 경고만 남기고 진행한다(차단 쪽 오탐이 더 크다는 아래 "원격 브랜치만 존재" 와 같은 판정).

판정 (신호별로 강도가 다르다 — 일괄 차단하지 않는다):

신호행동이유무인(--unattended) 기본값
열린 PR 이 이 이슈를 참조중단하고 AskUserQuestion — 채택/병합/별도진행 중 택1완성된 경쟁 작업이다. 사람 판단 없이 덮어쓰면 안 된다중단 — Unattended & Approval Contract ① · [INCOMPLETE: duplicate_work_conflict] 보고
이 이슈의 계층 브랜치가 원격에 존재하고 커밋이 0개 (initiative|project|epic|story|task/{이 이슈번호}-* 이고 git rev-list --count origin/{base}..origin/{그 브랜치} == 0)중복이 아니다 — 채택하고 진행컨테이너 브랜치는 아래 자식을 먼저 진행한 다른 머신·세션이 커밋 0개로 만들어 두는 것이 정상 경로다(브레이크다운은 이슈만 만들고 브랜치는 안 만들며, R6 는 커밋이 없어도 즉시 push 한다). 이것을 중복으로 읽고 새 브랜치를 만들면 같은 이슈에 계층 브랜치가 둘 생긴다 — Step 4 의 adopt-before-create(agents/dev/issue-branch-agent.md Step 4)가 이 브랜치를 그대로 채택한다동일 — 채택 후 진행(로그만)
이 이슈의 계층 브랜치가 원격에 존재하고 커밋이 쌓여 있음경고 + 진행 여부 확인(아래 "원격 브랜치만 존재"와 동일 처리)누군가 이 이슈를 실제로 구현 중이다 — GD-03 이 잡으려는 바로 그 상태다. 커밋 0개(컨테이너 선생성)와 반드시 구분한다경고 기록 후 진행. 단 그 브랜치의 최신 커밋이 점유 TTL(4h) 이내면 Step 0.4 가 이미 other-live 로 막았어야 하는 상태다 — 여기까지 왔다는 것은 대장을 못 읽었다는 뜻이므로 [INCOMPLETE: issue_occupied]중단한다
부모(Epic/Project)의 계층 브랜치가 원격에 존재 (epic|project/{부모번호}-*)중복이 아니다 — 그것을 base 로 채택하고 진행다른 세션이 그 부모를 계층으로 굴리는 중이다. 여기서 flat feature/* 를 만들면 내 브랜치가 형제 교집합 조회(refs/heads/story/* 글롭)에 안 걸려 다음 세션에게 보이지 않는다 — 같은 이슈를 또 완주하는 경로가 열린다. ⚠️ 부모의 브랜치는 부모의 점유가 아니다 — 점유는 이슈 단위이며(Step 0.4), 이 행은 base 선택 신호일 뿐이다동일 — 채택 후 진행(로그만)
원격 브랜치만 존재 (PR 없음, 위 계층 브랜치 케이스 아님)경고 + 진행 여부 확인중단된 작업일 수도, 진행 중일 수도 있다 — 차단하면 오탐이 크다경고 기록 후 진행(차단 쪽 오탐이 더 크다는 이유가 무인일 때도 그대로 적용된다)
설명 문구 모드(이슈 번호 없음)경고만, 차단하지 않음키워드 매칭은 오탐이 많다동일 — 해당 없음(원래도 확인을 묻지 않는다)
신호 없음조용히 통과정상 경로에 마찰을 주지 않는다동일
  • gh 부재/미인증 → 경고 후 진행 (위 Degradation Contract 준용 — 도구 부재는 안전 위반이 아니다). 단 이 경우 "중복 확인 못 함"을 남긴다.
  • --skip-dup-check 로 명시적 우회 가능 (이미 알고 이어받는 경우 · 의도적 경쟁 구현).
  • 이 검사는 Step 1 진입 전에 끝낸다. 구현을 다 한 뒤 Step 9(PR 생성)에서 발견하면 이미 늦다.

충돌을 발견했을 때의 처리:

  1. 두 구현을 기능 축으로 비교한다(무엇을 더 하는가 / 무엇을 빠뜨렸는가). 먼저 시작한 쪽이 자동으로 이기는 게 아니다.
  2. 하나를 채택하고, 나머지에서 살릴 부분은 후속 이슈로 분리한다 — 두 PR 을 억지로 합치지 말 것.
  3. 채택된 PR 에 비교 근거를 코멘트로 남긴다(리뷰어가 같은 비교를 반복하지 않도록).

배경: 두 병렬 세션이 같은 이슈를 각자 완주하고, 구현·테스트·품질 게이트를 전부 통과한 마지막 git push 에서 브랜치명이 우연히 겹쳐서야 중복을 발견한 사례가 있다(GD-03). 브랜치명이 하나라도 달랐다면 같은 이슈에 PR 2개가 열렸을 것이다. 조회 3회면 착수 전에 잡힌다. 실사고 4건과 판정 기준의 SoT 는 parallel-session-collision 스킬이다.

Step 0.6: Pre-existing Sub-Issue Discovery ⚠️ (GD-04)#

컨테이너형 이슈(Epic 등)를 더 작은 단위로 쪼개 추적하려 할 때, 새 이슈를 만들기 전에 이미 등록된 sub-issue 가 있는지부터 확인한다. 기획·감사 단계에서 큰 이슈 아래 세부 항목이 GitHub 네이티브 sub-issue 로 미리 등록돼 있는 경우가 흔하다 — 그런데 그걸 발견하는 데 흔히 쓰는 도구가 신뢰할 수 없다.

mcp__zenhub__searchLatestIssues({ query: "parent:${id}" }) 는 "최신 20개 이슈"만 훑고 그 안에서 필터링한다 (툴 설명 자체가 "Get the latest 20 issues from Zenhub"). 오래 전에 만들어진 뒤 아무도 안 건드린 sub-issue 는 다른 최근 활동에 밀려 이 20개 창 밖으로 나가면, parent: 검색 결과에 그냥 안 잡힌다 — 존재하는데 없다고 나온다. getIssue/getChildrenOfParent 가 ZenHub MCP 에 없어서 이 저장소 전체가 이 패턴을 "유일한 조회 수단"으로 써왔는데, 완전성(completeness) 이 필요한 자리에서는 이 툴이 거짓 음성을 낸다.

신뢰할 수 있는 대안은 GitHub 네이티브 sub-issues GraphQL 이다 — recency 창이 없고, 존재하는 모든 하위 이슈를 빠짐없이 반환한다:

gh api graphql -f query= ' 
 {
  repository(owner: " {OWNER} " , name: " {REPO} " ) {
    issue(number:{N}) {
      subIssues(first:100) {
        totalCount
        nodes { number title state createdAt }
      }
    }
  }
} '

다음 두 자리에서는 이 조회를 completeness 의 source of truth 로 쓴다 (ZenHub parent: 검색 단독 판정 금지 — 부가 메타데이터(pipeline·issueType) 보강 조회로만 병행 가능):

  1. 새 sub-issue 를 만들기 직전 — 지금 만들려는 항목이 기존 sub-issue 와 겹치면 새로 만들지 말고 그 번호를 재사용한다(구현 후 Closes #{기존번호}).
  2. 컨테이너 이슈를 닫기 직전의 완료성 확인 — sub-issue 가 하나라도 OPEN 이면 컨테이너를 닫지 않는다(commands/batch.md Phase 2-d 완료성 Hard Gate와 동일 원칙 — 그 Hard Gate 도 이 GD-04 방식을 쓴다).

실사고 (GD-04): Epic #3451 의 sub-issue 5개(#3478~#3482)가 착수 11시간 전에 이미 등록돼 있었다. 처리를 맡은 세션이 parent: 검색으로는 그걸 못 찾았고(최신 20개 창 밖으로 밀려나 있었다), 완전히 새로운 이슈 5개(#3514~#3518)를 만들어 그것들만 고치고 닫은 뒤 Epic 자체도 닫아버렸다 — 원본 #3478~#3482 는 열린 채로 방치됐고, 사용자가 "이 번호들은 뭐냐"고 물어볼 때까지 아무도 몰랐다. 형제 Epic #3449/#3450 도 각각 6개씩 같은 사각지대의 sub-issue 를 갖고 있었으나, 코디네이터가 gh api graphql 로 직접 재확인해 두 워커에게 기존 번호를 쓰라고 개입 메시지를 보낸 뒤에야 반복을 피했다 — 개입이 없었다면 세 Epic 모두 같은 패턴으로 원본이 고아가 됐을 것이다.

Step-by-Step Prerequisites and Verification#

📄 비규범(non-normative) 파생 뷰 — 사람이 훑는 색인이다. 노드·순서·되돌림·게이트 판정의 규범은 위 "규범 선언 — Steps 7–12 흐름 블록"의 ```flow 블록이며, 이 표와 어긋나면 블록이 이긴다 (SoT §6). 반복 횟수·타임아웃 값을 이 표에만 적지 않는다.

StepPhasePrerequisitesVerification
0.1진입 경로 판별 (startedFrom)issue_number / jira / work_content. Steps 1–3 의 실행 조건이며 Step 5 는 어느 경로에서도 생략되지 않는다Step 0 completestartedFrom 확정
0.4Work claim guard (--force-claim 시 스킵) — 점유 판정 후 other-live 면 착수하지 않는다 (Work Claim Contract)Step 0.1 completeclaimStatus 판정 기록(mine/stale/none/unknown 중 하나), 또는 [INCOMPLETE: issue_occupied] 보고
0.5Duplicate work preflight (--skip-dup-check 시 스킵) — 조회 3회: 열린 PR · 내 이슈 번호 브랜치 · 부모 Epic/Project 번호 브랜치Step 0.4 complete열린 PR 0건, 또는 사용자 확인 기록. 부모 계층 브랜치를 찾았으면 그것을 base 로 채택한 기록
0.6Pre-existing sub-issue discovery (GD-04) — 컨테이너 이슈를 쪼갤 때 GitHub 네이티브 sub-issues 로 기존 항목부터 확인하고 있으면 재사용 (Existing-Children Reuse)Step 0.5 complete, 이슈가 컨테이너형(하위 항목 생성 예정)일 때만새 이슈 생성 전 기존 sub-issue 유무 확인 기록, 또는 N/A(leaf 작업)
1Work content analysis (입력이 Jira 키/URL이면 Step 1.0에서 읽기 전용으로 원본 가져옴)Step 0.6 complete-
1.5PM requirements refinement (pm-spec-agent, --skip-pm 시 스킵)Step 1 completebody(계약: FR/AC/스코프, 가정 명시)와 narrative(서술)가 분리되어 반환
2ZenHub issue creation — 서술 20행 초과면 아티팩트 발행 후 링크 블록을 본문에 합침 (Jira 시작 시 제목에 {JIRA_KEY}: 접두사, 라벨에 JIRA-{프로젝트키} 추가)Step 1.5 complete (or Step 1 if --skip-pm)issue.id exists
3Move to Product BacklogStep 2 completepipeline verified
4Branch creation ({type}/{issue_number}-{slug})Steps 2,3 completebranch exists + 이슈번호 포함 검증
5Move to In Progress (+ 부모 체인 cascade)Step 4 completepipeline verified, 부모가 착수 전 칸이었다면 그것도 In Progress로 확인
6BDD scenario writingStep 5 complete, screen feature.feature file exists
7Implementation workStep 5 completecommits exist
7.1디자인 레퍼런스·의사결정cc-designer 지식 스킬을 참고하고, 스펙·시안에 답이 없는 디자인 판단은 design-decision 프로토콜로 되묻지 않고 확정 + DDR 기록Step 6 또는 7 진행 중, screen feature 감지비차단(참고). 단 결정을 내렸다면 prBodyExtras.designDecisions 에 DDR 존재
7.2CoUI 패키지 변경 분리 — CoUI 컴포넌트 수정/확장 필요 시 CoUI 저장소 별도 PR 선행 머지 → 프로젝트는 의존성 반영 후 화면 조립 재개Step 7 진행 중 감지 (composition ladder rung 4)CoUI PR merged + 의존성 갱신, 화면 diff에 CoUI 소스 없음
7.3디자인 검증 게이트 (Figma ↔ 실기기/시뮬레이터) — screen feature + Figma 소스 존재 시, 실제로 앱을 띄워 픽셀 단위로 대조Step 7.2 완료 (해당 시)시각 대조 PASS 또는 명시적 스킵 확인 기록 (prBodyExtras.designVerification 존재)
7.5Backend code generationStep 7 complete, when Backend changes existgeneration success
7.7로컬 풀스택 통합 검증 (Serverpod 4 / 로컬 풀스택)Step 7.5 complete, on Backend or screen feature로컬 통합테스트 PASS
8Test writing + /cc-dev:pr:preflight gate (변경 패키지 unit+widget+integration)Step 7 complete/cc-dev:pr:preflight PASS
8.3BDD Coverage GateStep 8 complete, .feature existsstep_coverage 100%, assertions exist
8.5Pre-push verification + DCM format/lint 0-issue gate + DCM quality improvement (= Per-Push Gate, 이후 모든 push 직전 재실행)Step 8.3 completedcm format, dart analyze 0 issues, dcm analyze error 0 issues
8.7Code Review GateStep 8.5 complete0 Critical issues
9.0중복 착수 재확인 (--state all + baseRefName + 외부 트래커 키 + 원본 티켓 코멘트) — Step 0.5 는 선행 작업만 잡는다. state=MERGED 면 base 가 epic/* 여도 끝난 작업이다Step 8.7 complete경쟁 PR 0건, 또는 겹침 없음 근거 기록
9PR creation — 작업내역 아티팩트를 먼저 발행하고 링크를 PR 본문에 심은 채로 생성 (이슈 생성과 동일 순서). 발행은 비차단(마크다운 폴백). (gate 0: 열린 자식 이슈 0건 — Parent Closure Invariant)Step 9.0 completePR URL exists, 발행 성공 또는 폴백 기록, openChildrenStatus === "none"
10Move to Review/QAStep 9 completepipeline verified
10.5CI 대기 ∥ 작업내역 아티팩트 CI 상태 갱신 — CI 를 백그라운드로 띄우고 결과 회수 후 같은 URL 로 페이지 내용만 갱신(댓글 없음, PR 본문 불변). 갱신은 비차단, CI 판정은 하드 게이트Step 10 completepass/fail/no-checks/unknown 네 verdict 를 전부 분기해 확정 (fail·unknown·default-base no-checks = 차단). CI 결과 회수 완료. 갱신 성공 또는 URL 그대로 유지 기록
11Apply additional review feedbackStep 10.5 complete (CI 통과)review complete
11.9⛔ 머지 직전 열린-자식 재검증 — Step 9 gate 0 이후 CI·리뷰로 수 시간이 흐르는 사이 자식이 늘/재오픈될 수 있다. --merge=pre-authorized 도 덮지 못함Step 11 completeopenChildrenStatus === "none"
12Wait for merge approval → squash merge (merge = Close)Step 11.9 completeuser approval
12b⛔ 머지 직전 PR base 재해석 + retarget — 부모 브랜치가 PR 생성 후에 생겼거나(다른 머신·세션) 폴백으로 development 를 겨냥해 열렸을 수 있다. 그대로 머지하면 커밋이 부모 브랜치에 안 들어가고 부모→상위 PR 이 빈 diff 가 된다. 여기서는 조회만 하고 브랜치를 만들지 않는다(생성 경로는 작업 트리를 옮겨 gh pr merge 를 증발시킨다). 스택 모드에서는 미적용. --merge=pre-authorized 도 덮지 못함 (SoT: branch-hierarchy R7)Step 12 approval == "머지 승인"baseRefName == 조회한 부모 브랜치, head 가 새 base 의 자손(merge-base --is-ancestor), PR head 기준 커밋 0건이면 차단
12g⛔ 머지 직전 CI 재조회 (gh pr checks --json name,state,bucket) — Step 10.5 의 join 이후 리뷰·재푸시로 시간이 흘러 check 가 뒤집힐 수 있다. 캐시된 ciResult 를 신뢰하지 않는다. --merge=pre-authorized 도 덮지 못함Step 12 approval == "머지 승인"모든 check bucket=pass(또는 skipping), 조회 0건·조회 실패·pending(gh exit 8)은 차단
12.5Issue Close 확정 ⭐ — base가 기본(default) 브랜치가 아니면(계층 머지: story/·epic/) Closes # 자동 닫힘이 안 되므로 gh issue close명시적 종료(폴백이 아니라 기본 동작). 이어 GitHub closed + ZenHub Closed 확정 → 열린-자식 불변식 재확인(위반 시 gh issue reopen 복구) → checkout PR base + git pullOrca 워크트리 반납 표시(12.6, 비차단·자기 제거 금지)Step 12 merge 완료GitHub+ZenHub 모두 Closed, 열린 자식 0건, base 최신화, 워크트리 카드가 "회수 가능"으로 표시됨(Orca 밖이면 no-op)

📄 비규범 파생 뷰 — 아래 ASCII 박스는 읽기용 요약이다. 반복 횟수·타임아웃·되돌림 대상 같은 값을 여기만 적어 두지 않는다; 규범은 위 ```flow 블록, 값은 각 루프의 자기 자리다.

┌─────────────────────────────────────────────────────────────────┐
│                    /cc-dev:run  " 작업 내용 "                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Step 0.4: 점유 가드 ⛔ (--force-claim 시 스킵)                   │
│  ├── claimStatus(issue) — 점유 대장 + In Progress 조합 판정       │
│  ├── other-live → 착수 금지 ([INCOMPLETE: issue_occupied])        │
│  └── stale → 인수 기록 후 진행 / none → 통과                      │
│                                                                 │
│  Step 0.5: 중복 착수 preflight ⚠️ (--skip-dup-check 시 스킵)      │
│  ├── 열린 PR 조회 (gh pr list --search {issue})                  │
│  ├── 원격 브랜치 조회 (git ls-remote | grep {issue})              │
│  ├── PR 발견 → 중단 + 확인 / 브랜치만 → 경고 + 확인               │
│  └── gh 부재 → 경고 후 진행 (Step 0 계약 준용)                    │
│                                                                 │
│  Step 0.6: 기존 sub-issue 발견 ⚠️ (GD-04, 컨테이너형만)           │
│  ├── gh api graphql sub-issues(issue.number) — completeness    │
│  │   source of truth (ZenHub parent: 검색은  " 최신 20개 " 만 봄)    │
│  └── 겹치는 기존 sub-issue 있으면 재사용, 새로 만들지 않음         │
│                                                                 │
│  Step 1: 작업 내용 분석                                          │
│  ├── 작업 유형 추론 (feat/fix/chore/refactor)                    │
│  ├── 스코프 추출 (feature명)                                     │
│  ├── 화면 타입 감지 (목록/상세/)                                │
│  └── 예상 복잡도 산정 (Story Point)                              │
│                                                                 │
│  Step 1.5: PM 요구사항 정제 ⚠️ (--skip-pm 시 스킵)                │
│  ├── pm-spec-agent 호출                                         │
│  ├── (피그마 링크 있으면) get_design_context/get_screenshot 조회  │
│  ├── LOCKED seed-spec 있으면 우선 정합                           │
│  ├── FR/AC, 스코프 In/Out, Story Point 재산정                    │
│  ├── 명시적 --type/--scope/--point 는 그대로 존중                │
│  └── 애매한 부분은  " 가정 " 으로 명시하고 진행 (되묻지 않고 자동 진행)│
│                                                                 │
│  Step 2: ZenHub 이슈 생성                                        │
│  ├── mcp__zenhub__createGitHubIssue                             │
│  ├── 이슈 타입 자동 설정 (Feature/Task/Bug)                      │
│  ├── 라벨 자동 지정                                              │
│  └── Estimate 설정                                              │
│                                                                 │
│  Step 3: Product Backlog 이동                                   │
│  ├── mcp__zenhub__moveIssueToPipeline                           │
│  └── Pipeline: Product Backlog                                  │
│                                                                 │
│  Step 4: 브랜치 생성 ⚠️ 필수 (계층 브랜치 전략)                    │
│  ├── 부모 이슈 감지 (ZenHub parentIssueId 조회)                  │
│  ├── base 브랜치 결정:                                          │
│  │   ├── Sub-task → Story 브랜치 기반                            │
│  │   ├── StoryEpic 브랜치 기반                                │
│  │   ├── Epic → development 기반                                │
│  │   └── 독립 이슈 → development 기반                            │
│  ├── 부모 브랜치 미존재 시 자동 생성                               │
│  ├── issue-branch-agent 호출 (base_branch 전달)                 │
│  ├── {type}/{number}-{slug} 형식                                │
│  └── ⚠️ development/main에 직접 커밋 금지                        │
│                                                                 │
│  Step 5: In Progress 이동 (⭐ 기존 이슈 번호로 시작해도 항상 실행) │
│  ├── issue-state-agent 호출                                     │
│  ├── Pipeline: In Progress → 이동 후 읽어서 확인                │
│  └── cascadeStartToParents — 부모(Epic)가 아직 착수 전 칸이면 │
│      조부모까지 함께 In Progress로 이동 (issue-state-agent.md)  │
│                                                                 │
│  Step 6: BDD 시나리오 작성 (screen feature 시)                          │
│  ├── bdd-scenario-agent 호출                                    │
│  ├── Gherkin Feature 파일 생성                                   │
│  ├── Step Definition 생성                                       │
│  └── 커밋:  " test({scope}): ✅ BDD 시나리오 작성 "                    │
│                                                                 │
│  Step 7: 구현 작업                                               │
│  ├── 이슈 내용 기반 구현                                         │
│  └── 증분 커밋 생성                                              │
│                                                                 │
│  Step 7.1: 디자인 레퍼런스·의사결정 (screen feature 시)         │
│  ├── cc-designer 지식 스킬 참고 (ui-/ix-/dsys-* — 비차단)       │
│  ├── 스펙·시안에 답 없는 판단 → design-decision R0R5 확정      │
│  │   (R2 코드 선례 전수 조사가 핵심 — 사람에게 되묻지 않음)     │
│  ├── DDR 기록: .claude/docs/{scope}/design-decisions.md         │
│  └── 브랜드/법·정책/비가역/스펙충돌 4종만 권고안+확인           │
│                                                                 │
│  Step 7.2: CoUI 패키지 변경 분리 (on CoUI 수정/확장 필요) ⚠️        │
│  ├── composition ladder 선행 (as-is→style→compose→extend)        │
│  ├── extend 필요 확정 시: CoUI 저장소 별도 이슈/브랜치/PR          │
│  │   (additive · coui_flutter + coui_web 양쪽 · 버전범프)         │
│  ├── CoUI PR 선행 머지 → 프로젝트 의존성 갱신                     │
│  └── ⚠️ 화면 PR diff에 CoUI 소스 변경 포함 금지 (Step 9 게이트)    │
│                                                                 │
│  Step 7.3: 디자인 검증 게이트 (screen feature + Figma 소스 시) ⚠️      │
│  ├── (도구/기기 부재 또는 --skip-design-verify 시 AskUserQuestion 확인)│
│  ├── flutter run -d {target} --print-dtd (백그라운드, 별도 프로세스) │
│  ├── [선택·비차단] marionette:smoke — 조립/반응 여부만 빠르게 확인   │
│  ├── pixel-loop:loop  < figma-url >   --mode=create|repair --route=...  │
│  ├── pixel-loop:verify --mode=regression (읽기전용 최종 판정) │
│  ├── 실패 시 pixel-loop repair 1회 재시도 → 그래도 실패면 사용자 확인│
│  └── ⚠️ 무음 스킵 금지 — PR body에 상태(pass/skip/미해결) 영구 기록  │
│                                                                 │
│  Step 7.5: Backend 코드 생성 (on Backend changes) ⚠️                │
│  ├── melos run backend:pod:generate                             │
│  └── 커밋:  " chore(backend): 🔧 코드 생성 "                          │
│                                                                 │
│  Step 7.7: 로컬 풀스택 통합 검증 (Backend/screen feature 시) ⚠️       │
│  ├── (--skip-local-integration 시 스킵, 기본 on)                  │
│  ├── serverpod start (백엔드+내장 Postgres+앱 풀스택 기동)         │
│  ├── [Backend 변경] dart test -t integration (로컬, Docker 불필요) │
│  ├── [screen feature] 프론트를 로컬 백엔드 대상 통합 스모크         │
│  ├── 마이그레이션/로그 → Serverpod MCP 활용                       │
│  ├── 검증 후 serverpod start 종료                                │
│  ├── (3.x 폴백: docker compose up -d + dart run bin/main.dart)   │
│  └── ⚠️ 게이트 실패 시 Step 8 진행 차단                           │
│                                                                 │
│  Step 8: 테스트 작성 및 검증 (필수 게이트) ⚠️                      │
│  ├── [Phase 8a: 테스트 코드 작성]                                  │
│  │   ├── [프론트엔드 - 필수]                                       │
│  │   │   ├── UseCase 단위 테스트 (unit-test-agent)               │
│  │   │   └── BLoC 단위 테스트 (bloc-test-agent)                  │
│  │   ├── [백엔드 - on Backend changes 필수]                       │
│  │   │   ├── 엔드포인트/서비스 단위 테스트 (serverpod-test-agent) │
│  │   │   └── 엔드포인트 통합 테스트 (serverpod-test-agent)        │
│  │   ├── BDD Patrol 테스트 (screen feature 시, 손으로 작성)      │
│  │   └── 커밋:  " test({scope}): ✅ 테스트 작성 "                     │
│  ├── [Phase 8b: PR Preflight 게이트] /cc-dev:pr:preflight --auto-scope  │
│  │   ├── 변경 패키지 자동 감지 (git diff {base}..HEAD)            │
│  │   ├── Unit 레이어 (변경 패키지) — 실패 시 즉시 중단             │
│  │   ├── Widget 레이어 (변경 패키지)                              │
│  │   └── Integration @smoke (디바이스 있으면 조건부 실행)          │
│  └── ⚠️ 게이트 실패 시 Step 8.3 진행 불가 → PR 생성 차단           │
│     정책: cc-dev/rules/pr-preflight-policy.md               │
│                                                                 │
│  Step 8.3: BDD Coverage Gate ⚠️ (--skip-bdd 시 스킵)              │
│  ├── .feature 파일 스캔 (현재 feature 범위)                        │
│  ├── Given/When/Then 스텝 파싱                                    │
│  ├── step/ 폴더에서 매칭 step 함수 검증                             │
│  ├── 각 step 함수 내 assertion (expect/verify) 존재 확인            │
│  ├── ⚠️ step_coverage  <   100%enterRework( " S6R " ) (bound:2)      │
│  └── --skip-bdd 시 ASK 확인 + PR body 영구 기록 (경고만 금지)      │
│                                                                 │
│  Step 8.5: Pre-push 검증 + DCM format/lint 0+ DCM 품질 개선 ⚠️ 필수│
│  ├── [Phase 1: DCM 포맷팅 + Dart 린트 자동 수정 + 0건 게이트]      │
│  │   ├── melos run format (dcm format — dart format 대체)         │
│  │   ├── dart fix --apply (린트 자동 수정)                         │
│  │   ├── melos run analyze (Dart SDK 분석)                   │
│  │   ├── error/warning/info 잔존 시 수동 수정 (최대 2회 반복)      │
│  │   └── ⚠️ 2회 시도 후에도 이슈 잔존 시 PR 생성 차단              │
│  ├── [Phase 2: DCM 코드 품질 개선]                                 │
│  │   ├── melos run dcm:analyze (DCM 린트 + 메트릭)               │
│  │   ├── melos run dcm:fix (자동 수정 가능 항목 수정)             │
│  │   ├── melos run dcm:unused-code (미사용 코드 검사)           │
│  │   ├── melos run dcm:analyze (수정 후 재검증)                   │
│  │   └── ⚠️ error 심각도 이슈 잔존 시 PR 생성 차단                 │
│  ├── [Phase 3: 최종 린트 검증]                                     │
│  │   ├── melos run analyze (DCM 수정 후 Dart 재확인)         │
│  │   ├── melos run dcm:analyze (DCM 최종 재확인)                  │
│  │   └── ⚠️ 이슈 0건 필수 — 잔존 시 PR 생성 차단                   │
│  └── 검증 실패 시 Step 9 진행 불가                                │
│                                                                 │
│  Step 8.7: Code Review Gate ⚠️ (gstack 패턴)                    │
│  ├── /cc-quality:review --quick 자동 실행                              │
│  ├── 2-pass 분석:                                               │
│  │   ├── Pass 1: Critical (차단) - 보안, 데이터 안전성, 레이스컨디션 │
│  │   └── Pass 2: Informational (PR body 포함)                   │
│  ├── Critical 발견 시:                                          │
│  │   ├── 자동 수정 시도 (최대 2)                                │
│  │   └── 수정 불가 시 PR 생성 차단                                │
│  ├── Informational 항목 → PR description에 자동 포함             │
│  └── --skip-review 시 PR body에 미실행 영구 기록                  │
│                                                                 │
│  Step 9.0: 중복 착수 재확인 ⚠️ (--state all · 외부 트래커 키)      │
│  └── 이미 머지된 경쟁 PR--state open 으로 안 보인다           │
│                                                                 │
│  Step 9: PR 생성 (검증 게이트 통과 후, 계층 머지)                  │
│  ├── ⚠️ 브랜치 형식 검증 필수                                    │
│  ├── PR base 결정 (계층 브랜치 전략):                             │
│  │   ├── Sub-task → Story 브랜치 (base)                          │
│  │   ├── StoryEpic 브랜치 (base)                              │
│  │   ├── Epic → development (base)                              │
│  │   └── 독립 이슈 → development (base)                          │
│  ├── 🆕 작업내역 페이지 발행 → URL 확보 (실패 시 마크다운 폴백)   │
│  ├── gh pr create --base {target_branch} (본문에 링크 블록 포함) │
│  ├── Closes #{issue_number} 자동 포함                           │
│  └── ZenHub PR 연결                                             │
│                                                                 │
│  Step 10: Review/QA 이동                                        │
│  ├── mcp__zenhub__moveIssueToPipeline                           │
│  └── Pipeline: Review/QA                                        │
│                                                                 │
│  Step 10.5: CI 대기 ∥ 작업내역 아티팩트 CI 상태 갱신              │
│  ├── gh pr checks --watch 를 백그라운드로 먼저 띄움 ⚠️ 순서 고정  │
│  ├── CI 결과 회수(join) → 같은 URL 로 페이지 내용만 갱신 (댓글 X) │
│  ├── 갱신 실패/도구 부재 → 기존 URL 그대로 유지, 워크플로 계속    │
│  ├── CI 실패/판정불가 → throw (L-10.6 최대 3 라운드, 머지 차단)  │
│  └── 체크 0건 → default base 면 차단, 그 외 PR body 기록          │
│                                                                 │
│  Step 11: 추가 리뷰 피드백 반영                                   │
│  ├── Step 8.7에서 자동 코드 review complete됨                           │
│  ├── PR 리뷰 코멘트 확인 및 추가 피드백 반영                      │
│  ├── 재검토 필요 시 반복                                         │
│  └── /cc-quality:checklist:feature-complete 실행                           │
│                                                                 │
│  Step 11.9: ⛔ 머지 직전 열린-자식 재검증                        │
│  ├── openChildrenStatus (GitHub sub-issues, tri-state)          │
│  ├── open / unknown → 머지 차단 (pre-authorized 도 못 덮음)      │
│  └── 근거: CI·리뷰 사이에 자식이 늘거나 재오픈될 수 있음         │
│                                                                 │
│  Step 12: 머지 승인 대기 (머지 = Close)                          │
│  ├── user approval 요청 (pre-authorized 면 질문만 생략)          │
│  ├── S12g ⛔ gh pr checks 재조회 — 전부 통과일 때만 머지         │
│  ├── 승인 시 스쿼시 머지                                         │
│  ├──  " 수정 필요 "enterRework( " S7R " ): 게이트 10개 pending 복귀  │
│  └── GitHub  " Closes # "   키워드로 이슈 자동 Close 시도            │
│                                                                 │
│  Step 12.5: 이슈 Close 검증 + 폴백 ⭐ (ZenHub 2-상태 모델) ⚠️     │
│  ├── GitHub state 확인 (open/closed의 source of truth)           │
│  │   └── 미close 시 gh issue close 로 명시적 종료                 │
│  ├── ZenHub Closed 동기화 확인 (GitHub closed = Closed 1:1)      │
│  │   └── 동기화 지연 시 updateIssue state:CLOSED 강제            │
│  ├── ⛔ 열린-자식 불변식 최종 확인                                │
│  │   └── 위반 시 gh issue reopen + 코멘트 + In Progress 복구      │
│  ├── PR base 브랜치로 이동 + git pull (최신화) ⭐               │
│  └── Step 12.6: Orca 워크트리 반납  " 표시 "   (비차단)               │
│      └── ⛔ 자기 제거 금지 — 회수는 상위 batch 의 머지 큐         │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Verification Gates (Required)#

Step 4 Branch Verification (Required)#

Direct commits on development/main branch prohibited:

# 현재 브랜치 확인
BRANCH=$(git rev-parse --abbrev-ref HEAD)

# development/main 체크
if [[ $BRANCH ==  " development "   || $BRANCH ==  " main "   ]]; then
  echo  " ❌ development/main에 직접 커밋할 수 없습니다 " 
   echo  "     /cc-dev:run 스킬은 반드시 feature 브랜치에서 작업합니다 " 
   echo  " " 
   echo  "     올바른 사용법: " 
   echo  "     1. /cc-dev:run \ " 작업 내용\ "   - 이슈 생성부터 시작 " 
   echo  "     2. /cc-dev:run {number} - 기존 이슈로 시작 " 
   exit 1
fi

# ⭐ 이슈번호 포함 검증 (이슈 추적 용이): {type}/{issue_number}-{slug}
#    브랜치 생성 직후 조기 게이트 — 번호 누락을 Step 9(PR 직전)까지 미루지 않고 즉시 차단
if [[ ! $BRANCH =~ ^(initiative|project|epic|story|task|feature|fix|refactor|chore)/[0-9]+- ]]; then
  echo  " ❌ 브랜치명에 이슈번호가 없습니다: $BRANCH " 
   echo  "     형식: {type}/{issue_number}-{slug}  (예: feature/1810-author-list) " 
   echo  "     이슈 추적을 위해 브랜치명에 반드시 이슈번호가 포함돼야 합니다. " 
   echo  "     /cc-dev:run 은 이슈 생성(Step 2) 후 자동으로 번호를 포함해 브랜치를 만듭니다. " 
   exit 1
fi

Step 9 Pre-PR Verification (Required)#

All of the following conditions must be met to create a PR:

  1. ⛔ 열린 자식 이슈 게이트 (Parent Closure Invariant — 1번째 지점)

    이 PR body 에는 Closes #{issue.number} 가 들어가고, base 가 default 브랜치면 머지 순간 GitHub 이 자식을 보지 않고 이슈를 닫는다. 그래서 PR 을 만들기 전에 이 이슈에 열린 자식이 없음을 확인한다 — 이 게이트는 /cc-dev:run 이 컨테이너(Initiative/Project/Epic/Story)로 호출된 모든 경우에 적용된다(/cc-dev:batch 경유든 직접 호출이든).

        // rules/zenhub-conventions.md →  " Child Enumeration Contract "   (tri-state, fail-closed)
    //   mayClose(kids, issueType):  " none " →통과 ·  " open " →항상 차단 ·
    //    " unknown " →차단, 단 Sub-task(구조적 leaf)는 경고 후 통과 (Step 0 Degradation Contract 준용)
    const kids = await openChildrenStatus(issue.number);
    if (!mayClose(kids, issue.issueType)) {
      throw new Error(
        `⛔ #${issue.number} PR 생성 차단 — ${kids.status ===  " unknown " 
           ?  " 자식 이슈 조회 실패(판정 불가). gh auth status 확인 후 재실행  "   +
             " (subIssues 조회를 쓸 수 없는 환경이면 하위 이슈를 수동 확인) " 
           : `열린 자식 ${kids.open.length}: ${kids.open.map(c = >   " # "   + c.number).join( " ,  " )}. ` +
            `그 자식들을 먼저 처리하라 — /cc-dev:batch ${issue.number}`}`
      );
    } else if (kids.status ===  " unknown " ) {
      console.warn(`⚠️ #${issue.number}(Sub-task) 자식 조회 실패 — 구조적 leaf 이므로 경고 후 진행`);
    }
    

    ⚠️ 이 게이트는 /cc-dev:run 에 없어서 사고가 났다. /cc-dev:batch 의 완료성 Hard Gate 만 불변식을 지키고 있었기 때문에, 컨테이너가 /cc-dev:run 으로 흘러들어가면(leaf 오판·직접 호출· /cc-dev:go 경유) 열린 자식 위에서 그대로 닫혔다. 두 커맨드가 같은 게이트를 공유해야 한다.

  2. Branch format verification

        BRANCH=$(git rev-parse --abbrev-ref HEAD)
    if [[ ! $BRANCH =~ ^(initiative|project|epic|story|task|feature|fix|refactor|chore)/[0-9]+ ]]; then
      echo  " ❌ 브랜치 형식 오류: $BRANCH " 
       echo  "     올바른 형식: feature/30-description 또는 epic/10-description (Initiative/Project는 initiative/·project/) " 
       exit 1
    fi
    
  3. Issue link verification

    • Issue number extractable from branch name
    • ZenHub issue in In Progress state
  4. PR base verification (hierarchical branches)

        # base 브랜치가 올바른 계층인지 확인 (5레벨)
    # Sub-task → Story 브랜치, StoryEpic 브랜치,
    # EpicProject 브랜치(parent 있으면) 또는 development,
    # ProjectInitiative 브랜치(parent 있으면) 또는 development,
    # Initiative/독립 → development
    BASE_BRANCH= " ${baseBranch} " 
     # ⚠️ 판정은 **원격에 직접** 묻는다 — `git rev-parse --verify origin/X` 는 로컬에 캐시된
    #    remote-tracking ref 라, 다른 머신이 방금 만든 부모 브랜치를  " 없음 "   으로 읽는다
    #    (branch-hierarchy  " 계층 브랜치 해석 계약 "   R1).
    git fetch origin --prune --quiet 2 > /dev/null || true
    # `refs/heads/` 를 붙여 정확히 그 브랜치만 매칭한다(패턴은 ref 꼬리와 매칭되므로,
    #  접두사 없이  " development "   를 주면 `refs/heads/foo/development` 도 걸린다).
    #  없으면 exit 2 (0=존재).
    git ls-remote --exit-code --heads origin  " refs/heads/${BASE_BRANCH} "   > /dev/null 2 > & 1 || {
      echo  " ❌ base 브랜치가 원격에 존재하지 않습니다: $BASE_BRANCH " 
       echo  "     → resolveBaseBranch() 로 재해석/생성 후 재시도 (계층 브랜치는 만든 즉시 push — R6) " 
       exit 1
    }
    
  5. Commit verification

        # base 브랜치 대비 커밋 검증 (계층 브랜치 지원)
    COMMITS=$(git rev-list --count origin/${BASE_BRANCH}..HEAD)
    if [[ $COMMITS -eq 0 ]]; then
      echo  " ❌ 커밋이 없습니다. Step 7을 먼저 완료해주세요 " 
       exit 1
    fi
    
  6. CoUI source contamination check (screen feature 시 — Step 7.2 게이트)

        # 화면 PR diff에 CoUI 패키지 소스 변경이 포함되면 차단 (의존성 범프만 허용)
    COUI_TOUCHED=$(git diff --name-only origin/${BASE_BRANCH}..HEAD | grep -E  ' (^|/)(packages/)?coui(_flutter|_web|_core)?/ '   || true)
    if [[ -n  " $COUI_TOUCHED "   ]]; then
      echo  " ❌ 화면 PR에 CoUI 소스 변경 포함 — CoUI 저장소 별도 PR로 분리 후 선행 머지하세요 (Step 7.2) " 
       exit 1
    fi
    

    flow 블록 S7.2 ==> S7R (bound:2) — 이 게이트가 걸리면 CoUI 커밋을 CoUI 저장소 브랜치로 분리한 뒤 enterRework("S7R") 로 되돌린다. 화면 브랜치에서 커밋만 지우고 그대로 PR 을 만들면, 이미 completed 인 7.3~8.7 이 재실행되지 않은 채 통과한다 — 의존성 버전이 바뀌었는데 디자인 검증과 테스트를 다시 돌리지 않은 상태다.

  7. Design verification recorded (screen feature 시 — Step 7.3 게이트)

    ⚠️ 이 게이트의 판정 대상은 워크플로 상태인 prBodyExtras.designVerification 이다. 셸 변수 $DESIGN_VERIFICATION_STATUS 는 이 문서 어디에서도 대입되지 않으므로(Step 7.3 은 prBodyExtras 에만 쓴다), 셸로 판정하면 -z항상 참이 되어 screen feature 마다 무조건 차단되거나 — 반대로 조건이 뒤집혀 있으면 무조건 통과한다. 그래서 셸이 아니라 워크플로 상태로 판정한다.

        // screen feature + Figma 소스가 있었는데 prBodyExtras.designVerification 가 비어 있으면 차단
    // (미검증 상태를 pass 로 간주하지 않는다 — Step 8의 FO-04 원칙과 동일. undetermined ≠ pass)
    const figmaDocPath = `.claude/docs/${analysis.scope}/figma_analysis.md`;
    const figmaDocExists = (await Bash(`test -f  " ${figmaDocPath} "   & &   echo yes || echo no`)).trim() ===  " yes " ;
    if (analysis.requiresBdd  & &   figmaDocExists  & &   !prBodyExtras.designVerification) {
      throw new Error(
         " ❌ Step 7.3 디자인 검증 결과가 기록되지 않았습니다 — PR 생성 차단  "   +
         " (pass / skipped / fail-acknowledged 중 하나가 prBodyExtras.designVerification 에 있어야 한다) " 
       );
    }
    
  8. Pre-push verification + DCM format/lint 0-issue gate + DCM code quality improvement (Step 8.5 — Per-Push Gate: 최초 push뿐 아니라 이후 모든 push 직전에 재실행)

        # Phase 1: DCM 포맷팅 + Dart 린트 자동 수정 + 0건 게이트
    melos run format                     # dcm format (dart format 대체)
    dart fix --apply                     # 자동 수정 가능한 린트 이슈 일괄 수정
    melos run analyze               # Dart SDK 분석 실행
    # → error/warning/info 모두 0건 필수 (남아있으면 수동 수정 후 재검증, 최대 2)
    # → 2회 시도 후에도 이슈 잔존 시 PR 생성 차단
    
    # Phase 2: DCM 코드 품질 개선
    melos run dcm:analyze                # DCM 린트 + 메트릭 검사
    melos run dcm:fix                    # 자동 수정 (린트)
    melos run dcm:unused-code          # 미사용 코드 검사
    melos run dcm:analyze                # 수정 후 재검증
    # → error 심각도 이슈 0건 필수
    
    # Phase 3: 최종 린트 검증 (DCM 수정이 새 린트 이슈를 만들지 않았는지)
    melos run analyze               # Dart SDK 최종 확인
    melos run dcm:analyze                # DCM 최종 확인
    

Step Skip Prevention#

Principle: Each step can only proceed after the previous step is complete

⚠️ 구 구현은 보호가 필요한 단계만 정확히 빠뜨렸다. for (let i = 1; i < stepNumber; i++) + t.content.startsWith("Step " + i) 는 두 가지로 틀렸다:

  • 정수만 센다1.5·7.2·7.3·7.5·7.7·8.3·8.5·8.7·9.0·10.5·11.9·12.5한 번도 보지 않는다. 즉 하드 게이트가 걸린 소수점 단계 전부가 전제조건에서 제외되고, 게이트가 없는 정수 단계만 검사됐다.
  • 접두사 매칭이다"Step 1""Step 1.5"·"Step 10.5"·"Step 11"·"Step 12" 에 먼저 걸려, find 가 엉뚱한 항목의 status 를 읽는다.

노드 순서의 규범은 위 ```flow 블록이다. 아래 STEP_ORDER 는 그 블록의 노드 id 를 소수점까지 그대로 나열한 투영이며, 블록에 노드를 추가하면 이 목록과 TodoWrite 시드를 함께 갱신한다(1 노드 = 1 항목).

// 실행 순서대로 나열한 노드 id — flow 블록의 파생. 정수/소수점을 구분하지 않는다.
// (S7f/S7j/S8w/S8j 는 Step 7·8 항목 안, S12b/S12g/S12m 은 Step 12 항목 안, S12.6 은 Step 12.5 항목
//  안, S7R/S6R 은 되돌림 루프이므로 독립 항목을 갖지 않는다 — enterRework() 가 대신 항목들을
//  pending 으로 되돌린다. S12.6 은 비차단 후처리라 뒤에 게이트할 단계가 없다 — 독립 항목으로
//  올리면 no-op 케이스가 영구 pending 으로 남아 시드만 흐려진다.)
const STEP_ORDER = [
   " 0 " ,  " 0.1 " ,  " 0.5 " ,  " 0.6 " ,  " 1 " ,  " 1.5 " ,  " 2 " ,  " 3 " ,  " 4 " ,  " 5 " ,  " 6 " ,
   " 7 " ,  " 7.1 " ,  " 7.2 " ,  " 7.3 " ,  " 7.5 " ,  " 7.7 " ,
   " 8 " ,  " 8.3 " ,  " 8.5 " ,  " 8.7 " ,
   " 9.0 " ,  " 9 " ,  " 10 " ,  " 10.5 " ,  " 11 " ,  " 11.9 " ,  " 12 " ,  " 12.5 " ,
];

// 비차단 노드(flow 블록의 `.. > `) — 시드에는 있지만 전제조건으로 세지 않는다.
// Step 7.1 은 참고 단계이므로 pending 으로 남아도 다음 단계를 막아선 안 된다.
const ADVISORY_STEPS = new Set([ " 7.1 " ]);

// 단계 전제조건 검증 — stepId 는 문자열( " 8.5 " )이다. 숫자로 받으면 8.5 가 다시 8 로 접힌다.
async function validateStepPrerequisites(stepId: string) {
  const todos = await getTodoList();
  const upto = STEP_ORDER.indexOf(String(stepId));
  if (upto  <   0) {
    throw new Error(`❌ 알 수 없는 단계 id: ${stepId}STEP_ORDER 에 없다(flow 블록과 동기화 필요)`);
  }

  for (const id of STEP_ORDER.slice(0, upto)) {
    if (ADVISORY_STEPS.has(id)) continue;

    // 정확 일치 — startsWith 는  " Step 1 "   이  " Step 1.5 " / " Step 12 "   를 삼킨다
    const step = todos.find(t = >   t.content.match(/^Step ([0-9]+(?:\.[0-9]+)?)/)?.[1] === id);

    // 항목 자체가 없으면  " 검사할 게 없어서 통과 " 가 아니다 — 시드 누락이 곧 게이트 소멸이다(FO-03 계열)
    if (!step) {
      throw new Error(
        `❌ Step ${id} 항목이 TodoWrite 시드에 없습니다 — 시드는 STEP_ORDER 1 항목당 1개여야 합니다. ` +
        `시드를 갱신하고 재실행하세요 (검증 불가 ≠ 통과)`
      );
    }
    if (step.status !==  " completed "   & &   step.status !==  " skipped " ) {
      throw new Error(`
        ❌ Step ${id}() 완료되지 않았습니다.
        현재 상태: ${step.status ||  ' unknown ' }

        먼저 Step ${id}() 완료해주세요.
      `);
    }
  }
}

♻️ 되돌림과의 관계: enterRework()(위 "재작업 루프 계약")이 invalidates: 목록을 pending 으로 되돌리는 이유가 바로 이 함수다 — completed/skipped 를 통과시키므로, 되돌리지 않은 게이트는 되돌아온 뒤에도 재실행되지 않고 그대로 통과한다.


Detailed Implementation#

Step 1: Work Content Analysis#

Step 1.0: 입력 해석 — Jira 티켓 키/URL로 시작하는 경우 (선택)

work_content 자리에 온 인자가 Jira 이슈 키({PROJECT_KEY}-{번호}, 예: UB-123)나 Jira 티켓 URL이면, "가져올 Jira 티켓"으로 해석해 읽기 전용으로 내용을 가져온 뒤 그 요약/설명을 이후 단계(키워드 추론 → Step 1.5 PM 정제 → Step 2 이슈 생성)의 workContent로 쓴다. 일반 한 줄 설명이나 기존 이슈 번호(1810)면 이 단계는 그냥 통과한다. 이 경로에서도 Jira에는 절대 쓰지 않는다 — Issue Tracker Policy 그대로 적용.

const jiraKeyPattern = /^([A-Z][A-Z0-9]+)-(\d+)$/;
const jiraUrlPattern = /atlassian\.net\/browse\/([A-Z][A-Z0-9]+-\d+)/;

function resolveJiraRef(input: string): string | null {
  const fromUrl = input.match(jiraUrlPattern)?.[1];
  if (fromUrl) return fromUrl;
  return jiraKeyPattern.test(input) ? input : null;
}

let workContent = rawInput;   // rawInput = 사용자가 넘긴 원본 인자
let jiraIssueKey = null;      //  " UB-123 "   — 전체 키, Step 2 제목 접두사용
let jiraProjectKey = null;    //  " UB "   — Step 2 라벨용

// `startedFrom` 은 Step 0.1(아래  " Execution Flow "   참조)에서 이미 판별돼 있다 —
// 이 Step 1.0 은 `startedFrom ===  " jira " ` 일 때만 본문을 가져온다.
const jiraRef = resolveJiraRef(rawInput);
if (jiraRef) {
  // ⛔ createJiraIssue/editJiraIssue 금지 — 읽기 전용 조회만
  const jiraIssue = await mcp__atlassian__getJiraIssue({ issueKey: jiraRef });
  jiraIssueKey = jiraIssue.key;                     //  " UB-123 " 
   jiraProjectKey = jiraIssue.fields.project.key;    //  " UB " 
   workContent = jiraIssue.fields.description
    ? `${jiraIssue.fields.summary}\n\n${jiraIssue.fields.description}`
    : jiraIssue.fields.summary;
  console.log(`🔗 Jira 티켓 가져옴 (읽기 전용): ${jiraIssueKey}`);
}

Degradation: Jira MCP 미설치이거나 조회 실패 → throw 하지 않고 rawInput을 그대로 workContent로 사용 + 경고(GD-01과 동일한 계약). jiraIssueKey/jiraProjectKeynull로 남으면 Step 2는 평소처럼 Jira 접두사/라벨 없이 이슈를 만든다.

// 키워드 기반 타입 자동 추론
const typeKeywords = {
  feat: [ " 추가 " ,  " 구현 " ,  " 생성 " ,  " 만들기 " ,  " 화면 " ],
  fix: [ " 수정 " ,  " 고치기 " ,  " 버그 " ,  " 에러 " ,  " 문제 " ],
  refactor: [ " 개선 " ,  " 리팩토링 " ,  " 정리 " ,  " 최적화 " ],
  chore: [ " 설정 " ,  " 빌드 " ,  " 환경 " ,  " 배포 " ],
  docs: [ " 문서 " ,  " README " ,  " 주석 " ],
  test: [ " 테스트 " ,  " test " ,  " 검증 " ],
};

// 화면 타입 감지
const screenKeywords = {
  list: [ " 목록 " ,  " 리스트 " ,  " list " ,  " 조회 " ,  " 검색 " ],
  detail: [ " 상세 " ,  " detail " ,  " 보기 " ],
  form: [ " 추가 " ,  " 생성 " ,  " 수정 " ,  " 편집 " ,  " form " ,  " 등록 " ],
};

const analysis = {
  type: inferType(workContent),      // feat/fix/refactor...
  scope: extractScope(workContent),  // feature명
  screenType: detectScreen(workContent), // list/detail/form/null
  point: estimatePoint(workContent), // 1/3/5/8
  requiresBdd: screenType !== null,  // screen feature이면 true
  jiraIssueKey,                      // Step 1.0에서 설정, 아니면 null — Step 2 제목 접두사용
  jiraProjectKey,                    // Step 1.0에서 설정, 아니면 null — Step 2 라벨용
};

Step 1.5: PM Requirements Refinement (--skip-pm 시 스킵)#

Step 1의 키워드 추론은 이슈를 만들기엔 너무 얕다 — 특히 "화면"/"관리자"류 요청은 실제로는 CRUD 범위, 노출 조건, 디자인 상태 같은 세부사항이 숨어 있다. 이 단계는 pm-spec-agent를 호출해 Step 1 결과를 PM 관점의 실제 스펙으로 승격시킨 뒤 Step 2에 넘긴다.

if (!options.skipPm) {
  // work_content에서 URL 추출 (피그마 등)
  const figmaUrl = extractFigmaUrl(workContent);

  // 기존 확정(LOCKED) 기획 명세가 있으면 우선 사용
  const lockedSeedSpec = await findLockedSeedSpec(analysis.scope); // docs/seed-spec-*.md

  let pmSpec;
  try {
    pmSpec = await Task({
      subagent_type:  " pm-spec-agent " ,
      prompt: `
        work_content: ${workContent}
        initial_analysis: ${JSON.stringify(analysis)}
        explicit_options: ${JSON.stringify({ type: options.type, scope: options.scope, point: options.point })}
        figma_url: ${figmaUrl ??  " none " }
        locked_seed_spec: ${lockedSeedSpec ??  " none " }

        이 작업 요청을 PM 관점(FR/AC, 스코프 In/Out, Fibonacci Story Point)으로 정리해줘.
        애매한 부분은 묻지 말고 합리적으로 가정한 뒤  " Assumptions " 에 명시하고 진행해.
      `,
    });
  } catch (e) {
    console.warn( " ⚠️ PM 요구사항 정제 실패 — Step 1 키워드 추론 결과로 폴백: " , e?.message ?? e);
    pmSpec = null; // degrade, don ' t throw (GD-01과 동일한 계약)
  }

  if (pmSpec) {
    // 명시적 옵션은 그대로 유지, 나머지는 pmSpec으로 승격
    analysis.type = options.type ?? pmSpec.type;
    analysis.scope = options.scope ?? pmSpec.scope;
    analysis.point = options.point ?? pmSpec.point;
    analysis.requiresBdd = pmSpec.screenTypes.length  >   0;
    analysis.issueBody = pmSpec.body; // Step 2가 그대로 사용
  }
} else {
  console.warn( " ⚠️ PM 요구사항 정제를 건너뜁니다 (--skip-pm) " );
}

Degradation (GD-01 준수): figma MCP 미설치/링크 접근 실패 → 디자인 컨텍스트 없이 텍스트만으로 진행 + 경고 (throw 아님). pm-spec-agent 자체가 실패하면 Step 1의 키워드 추론 결과로 폴백 + 경고 — PM 정제 실패가 전체 파이프라인을 막지 않는다.

Skip: --skip-pm 옵션 시 이 단계 전체를 건너뛰고 Step 1의 키워드 추론 결과를 그대로 Step 2에 전달한다 (경고 메시지 출력).

Step 2: ZenHub/GitHub Issue Creation#

⚠️ 이슈 생성은 ZenHub(GitHub)에만 합니다 (단일 출처). Jira에는 절대 이슈를 만들지 않습니다 — createJiraIssue/editJiraIssue/transitionJiraIssue 호출 금지. Jira는 생성된 이슈를 조회·확인하는 읽기 전용 용도로만 씁니다(getJiraIssue, searchJiraIssuesUsingJql). 자세한 정책은 SKILL.md → "Issue Tracker Policy" 참고.

Jira 티켓에서 시작한 경우 (analysis.jiraIssueKey 존재, Step 1.0 참고): 제목에 Jira 키를 접두사로, 라벨에 JIRA-{프로젝트 키}를 추가한다 — 자세한 규칙은 zenhub-conventions.md → "Issue Title Conventions" / "Jira-Sourced Issue Labeling" 참고.

// 이슈 타입 매핑
const issueTypeMap = {
  feat:  " Feature " ,
  fix:  " Bug " ,
  refactor:  " Task " ,
  chore:  " Task " ,
  docs:  " Task " ,
  test:  " Task " ,
};

// Jira 티켓에서 시작한 경우의 제목 접두사 / 라벨 / 본문 출처 표기 (analysis.jiraIssueKey가 null이면 전부 no-op)
const jiraTitlePrefix = analysis.jiraIssueKey ? `${analysis.jiraIssueKey}: ` :  " " ;
const jiraLabels = analysis.jiraProjectKey ? [`JIRA-${analysis.jiraProjectKey}`] : [];
const jiraBodyFooter = analysis.jiraIssueKey ? `\n\n---\n🔗 Source: Jira ${analysis.jiraIssueKey} (read-only import)` :  " " ;

// 아티팩트 발행 (이슈 생성보다 반드시 먼저 — 역순이면 링크 없는 본문이 먼저 생긴다)
// SoT: rules/zenhub-conventions.md → Issue Body Artifact Contract
// analysis.narrative 는 Step 1.5(pm-spec-agent)의 서술 원고. --skip-pm 이면 비어 있다.
let artifactUrl = null;
if (analysis.narrative  & &   lineCount(analysis.narrative)  >   20  & &   isAvailable( " Artifact " )) {
  try {
    // 경로는 이슈당 고정 — 같은 경로로 재발행하면 같은 URL 이 유지된다
    artifactUrl = await Artifact({
      file_path: `.claude/docs/${analysis.scope}/issue-${slugify(analysis.scope)}.html`,
      favicon:  " 📄 " ,              // 재발행 간 고정
      description: `${analysis.scope} 상세 기획`,
    });
  } catch {
    artifactUrl = null;           // 발행 실패는 이슈 생성을 막지 않는다
  }
}
if (!artifactUrl  & &   analysis.narrative) {
  console.log( " ℹ️ Artifact 미사용 — 전량 마크다운 본문으로 생성 " );
}

// GitHub 이슈 생성
// analysis.issueBody는 Step 1.5(pm-spec-agent)가 채운 계약 블록 — --skip-pm 이거나 정제 실패 시 폴백
// artifactUrl 이 있으면  ' ## 📄 상세 기획 '   링크 블록(🔒 공유 안내 포함)을 계약 블록 앞에 붙이고,
// 없으면 narrative 를 계약 블록 뒤에 그대로 합쳐 종전 전량 마크다운으로 만든다.
const issueBody = artifactUrl
  ? linkBlock(artifactUrl, analysis.narrativeSections) + (analysis.issueBody ?? generateIssueBody(workContent, analysis))
  : (analysis.issueBody ?? generateIssueBody(workContent, analysis)) + (analysis.narrative ??  " " );

const issue = await mcp__zenhub__createGitHubIssue({
  repositoryId: githubRepoId,
  title: `${jiraTitlePrefix}${gitmoji} ${analysis.scope}: ${workContent}`,
  body: issueBody + jiraBodyFooter,
  issueTypeId: getIssueTypeId(issueTypeMap[analysis.type]),
  labels: [...generateLabels(analysis), ...jiraLabels],
});

// Estimate 설정
await mcp__zenhub__setIssueEstimate({
  issueId: issue.id,
  estimate: analysis.point,
});

Step 2.6 (optional): Jira read-only verification

이슈는 위 Step 2에서 ZenHub(GitHub)에 이미 생성됨. Jira는 읽기 전용으로 이 이슈가 보이는지 조회·확인만 한다(생성/수정 금지). 조회 실패는 워크플로를 막지 않는다(non-blocking).

Jira 티켓에서 시작한 경우(analysis.jiraIssueKey 존재)는 Step 1.0에서 가져온 원본 키로 직접 재조회해 여전히 유효한지만 확인한다(검색이 아니라 존재 확인). 그 외(일반 한 줄 입력으로 시작)는 기존처럼 생성된 GitHub 이슈 번호로 역검색한다.

// ⛔ createJiraIssue / editJiraIssue / transitionJiraIssue 금지 — 조회 도구만 사용
try {
  if (analysis.jiraIssueKey) {
    // 직접 조회 — Step 1.0에서 가져온 원본 Jira 티켓이 여전히 유효한지만 확인
    const src = await mcp__atlassian__getJiraIssue({ issueKey: analysis.jiraIssueKey });
    console.log(`🔎 Jira 원본 확인: ${src.key} — ${src.fields.status.name}`);
  } else {
    // 읽기 전용: 생성된 이슈가 Jira에서도 조회되는지 확인 (예: GitHub 이슈 번호로 검색)
    const found = await mcp__atlassian__searchJiraIssuesUsingJql({
      jql: `text ~  " #${issue.number} "   ORDER BY created DESC`,
      maxResults: 5,
    });
    if (found?.issues?.length) {
      console.log(`🔎 Jira 확인: ${found.issues.map(i = >   i.key).join( " ,  " )}`);
    } else {
      console.log( " 🔎 Jira 확인: 매칭 이슈 없음 (읽기 전용 확인, 워크플로 계속) " );
    }
  }
} catch (e) {
  console.warn( " 🔎 Jira 조회 건너뜀 (읽기 전용, non-blocking): " , e?.message ?? e);
}

Step 3-5: Pipeline Move & Branch Creation (hierarchical branch strategy)#

// ── 파이프라인 ID 일괄 해석 (fail-closed) ─────────────────────────
// ⚠️ 과거엔 productBacklogPipelineId/inProgressPipelineId/reviewQaPipelineId 가
//    어디에서도 해석되지 않아 move 가 pipelineId=undefined 로 silent no-op 였다(C3).
//    이름이 라이브 파이프라인과 다르면 throw — 무음 누락 금지.
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const pipelineId = (name) = >   {
  const p = workspace.pipelines.find(p = >   p.name === name);
  if (!p) throw new Error(`파이프라인  ' ${name} '   없음. 라이브: ${workspace.pipelines.map(p = >   p.name).join( " ,  " )}`);
  return p.id;
};
const productBacklogPipelineId = pipelineId( " Product Backlog " );
const inProgressPipelineId     = pipelineId( " In Progress " );
const reviewQaPipelineId       = pipelineId( " Review/QA " );

// ⚠️ 이 블록은 Step 3 과 Step 5 를 함께 담고 있지만 **실행 조건이 다르다**:
//    - Step 3(Product Backlog 이동)·Step 2.5(스프린트 배정) → 이슈를 **새로 만든 경로 전용**.
//      `/cc-dev:run {number}`(기존 이슈로 시작)는 Steps 1–3 을 건너뛰므로 해당 없음.
//    - Step 4(브랜치)·**Step 5(In Progress 이동 + 부모 cascade)** → **모든 경로에서 항상 실행**.
//      `/cc-dev:batch` 는 leaf 를 항상 `/cc-dev:run {number}` 로 위임하므로, Step 5 를
//       " Steps 1–3 생략 " 에 휩쓸려 같이 건너뛰면 **보드가 통째로 멈춘다**(Epic 은 Product Backlog,
//      자식은 Product Backlog — 실제로 개발이 도는 동안 아무 칸도 움직이지 않는 상태).

// ── Step 3: Product Backlog 이동 (이슈 신규 생성 경로 전용) ───────
if (startedFrom ===  " work_content "   || startedFrom ===  " jira " ) {
  await mcp__zenhub__moveIssueToPipeline({
    issueId: issue.id,
    pipelineId: productBacklogPipelineId,
  });
}

// ── Step 2.5: 스프린트 배정 (타임라인 가시성 — 신규 생성 경로 전용) ──
// 저수준 타입(Feature/Bug/Task)은 setDatesForIssue(상위 타입 전용)를 쓰지 않고
// 활성 스프린트에 넣어 로드맵/번다운에 노출한다. 기본 on, --no-sprint 로 opt-out.
if (!options.noSprint  & &   startedFrom !==  " issue_number " ) {
  // --sprint 셀렉터: next → 다음(getUpcomingSprint), 그 외(기본 current) → 활성(getSprint)
  const sprint = options.sprint ===  " next " 
     ? await mcp__zenhub__getUpcomingSprint()
    : await mcp__zenhub__getSprint();            // id 없음 = 활성 스프린트
  if (sprint?.id) {
    await mcp__zenhub__addIssuesToSprints({ issueIds: [issue.id], sprintIds: [sprint.id] });
    console.log(`📅 스프린트 배정: ${sprint.name ?? sprint.id}`);
  } else {
    console.warn( " ⚠️ 활성 스프린트 없음 — 스프린트 미배정(로드맵 비가시 가능) " );
  }
}

// ═══════════════════════════════════════════════════════════
// Step 4: 계층 브랜치 전략 - base 브랜치 동적 결정
// ═══════════════════════════════════════════════════════════

// 4-1. 부모 이슈 감지
let baseBranch = options.base ||  " development " ;
let parentIssue = null;

if (!options.base) {
  // ⚠️ ZenHub MCP 에는 id 로 단건 이슈를 가져오는 도구가 없다(getIssue/getChildrenOfParent 미존재).
  //    이슈와 그 부모는 searchLatestIssues 의 nested parentIssue 로 조회한다.
  const issueDetails = await mcp__zenhub__searchLatestIssues({
    query: `#${issue.number}`,
  });
  const self = issueDetails.find(i = >   i.number === issue.number);
  parentIssue = self?.parentIssue ?? null;   // { id, number, title, issueType }

  if (parentIssue) {
    // 부모 브랜치를 확보(없으면 조부모 기반으로 재귀 생성)하고 그 위에서 분기한다.
    // resolveBaseBranch 가 searchLatestIssues 로 계층을 거슬러 올라가며 브랜치를 보장한다.
    baseBranch = await resolveBaseBranch(parentIssue.number);
    console.log(`📐 계층 브랜치: ${baseBranch} 기반으로 분기`);
  }
}

// 4-4. 브랜치 생성 (base 브랜치에서 분기)
await Task({
  subagent_type:  " issue-branch-agent " ,
  prompt: `이슈 #${issue.number} 브랜치 생성 (base: ${baseBranch})`,
  // base_branch 파라미터 전달
});

// ═══════════════════════════════════════════════════════════
// Step 5: In Progress 이동 (⭐ 모든 진입 경로에서 항상 실행 — 생략 불가)
// ═══════════════════════════════════════════════════════════
await mcp__zenhub__moveIssueToPipeline({
  issueId: issue.id,
  pipelineId: inProgressPipelineId,
});

// ⭐ 부모 체인 cascade — 부모(Epic/Project/Initiative)가 아직 착수 전 칸
//    (New Issues/Icebox/Product Backlog/Sprint Backlog)이면 조부모까지 함께 In Progress 로.
//    멱등(이미 In Progress 이상이면 즉시 멈춤) · 닫힌 부모는 건드리지 않음 · 역행 금지.
//    SoT: agents/dev/issue-state-agent.md →  " Automatic Parent Start Cascade " 
 //    ⚠️ 이 호출이 빠지면 자식은 In Progress 인데 Epic 은 Product Backlog 에 남아,
//       보드만 보는 사람에게  " 아직 손도 안 댄 Epic " 으로 보인다.
await cascadeStartToParents(issue.number);

// 읽어서 확인 — pipelineId 오류/권한 문제로 인한 무음 누락 금지
// (위 두 호출은 ../rules/zenhub-conventions.md 의 `reflectBoardState(issue,  " started " )` 와 같은 일을 한다.
//  read-back 은 그 계약의 원칙 3 이다 — 새 검증을 여기서 만드는 것이 아니다)
const afterMove = (await mcp__zenhub__searchLatestIssues({ query: `#${issue.number}` }))
  .find(i = >   i.number === issue.number);
if (afterMove?.pipelineIssue?.pipeline?.name !==  " In Progress " ) {
  console.warn(`⚠️ #${issue.number} In Progress 이동 미확인 (현재: ${afterMove?.pipelineIssue?.pipeline?.name ??  " 조회 실패 " })`);
}

// ⭐ 점유 획득 — In Progress 이동과 **같은 자리**다 (SoT: ../rules/zenhub-conventions.md → Work Claim Contract)
//    ⚠️ 이동만 하고 대장을 쓰지 않으면, 다음 세션은 이 In Progress 가 **내 작업인지 부모 cascade 인지
//       구분할 수 없다** — 그 순간 Step 0.4 가 가진 유일한 신호가 사라진다.
//    ⛔ cascadeStartToParents() 는 대장을 쓰지 않는다. 부모를 잡으면 형제 Epic 이 전부 막힌다.
await acquireClaim(issue, { branch: branchName });   // state: held · owner/run/started/heartbeat 기록
// 이후 각 Step 경계에서 heartbeat(issue) — 마지막 기록이 20분 이내면 생략한다(API 왕복 절약)

Base Branch Decision Function

이 함수는 "다른 머신·다른 세션이 이미 만들어 둔 부모 브랜치를 반드시 찾아내는" 것이 본 역할이다. 못 찾으면 같은 부모 이슈에 계층 브랜치가 하나 더 생기고, 자식이 그 가짜 부모로 머지된다 — 진짜 부모 브랜치는 빈 채로 남아 Project→development PR 이 빈 diff 가 된다. 규칙의 SoT 는 branch-hierarchy"계층 브랜치 해석 계약" (R1~R7)이며, 아래는 그 구현이다 — 여기서 다른 규칙을 새로 만들지 않는다.

// ── 계층 브랜치 해석 계약 구현 (SoT: skills/branch-hierarchy/SKILL.md) ──────────

let _remoteSynced = false;
async function syncRemoteRefs() {                                    // R1
  if (_remoteSynced) return;
  // ⚠️ fetch 없이 origin/* 를 읽으면 다른 머신이 만든 브랜치가  " 없음 "   으로 보인다.
  await Bash(`git fetch origin --prune --quiet || git fetch origin --prune`);
  _remoteSynced = true;
}

// 이슈 레벨 → 계층 브랜치 prefix. resolveBaseBranch 와 S12b 가 공유한다(두 곳에 복제 금지).
function hierarchyPrefixOf(issue): string {
  return issue.issueType ===  " Initiative "   ?  " initiative "   :
         issue.issueType ===  " Project "   ?  " project "   :
         issue.issueType ===  " Epic "   ?  " epic "   :
         issue.issueType ===  " Sub-task "   ?  " task "   :
         issue.parentIssue ?  " story "   :  " feature " ;
}

// 원격에 직접 물어 이 이슈의 계층 브랜치 후보를 전부 찾는다.
// R2: 식별자는 **이슈 번호**다. 재계산한 slug 로 완전일치를 요구하지 않는다
//     (로마자 변환은 세션마다 갈리고, 제목이 수정되면 아예 달라진다).
// baseHint 는 **대장 기록용**이다 — R4 의 커밋 수 비교는 후보들의 공통 조상을 스스로 구한다.
async function findExistingHierarchyBranch(prefix: string, issueNumber: number, baseHint: string): Promise < string | null >   {
  await syncRemoteRefs();
  const raw = await Bash(
    `git ls-remote --heads origin  " refs/heads/${prefix}/${issueNumber}-* "   | sed  ' s|.*refs/heads/|| ' `
  );
  const candidates = raw.split( " \n " ).map(s = >   s.trim()).filter(Boolean);
  if (candidates.length === 0) return null;
  if (candidates.length === 1) return candidates[0];

  // R4 — 후보 2개 이상: 결정적으로 고르고, 고른 사실을 남긴다 (조용한 선택 금지)
  const registered = await readBranchRegistry(issueNumber);          // R3
  let picked = registered  & &   candidates.includes(registered) ? registered : null;
  if (!picked) {
    const prs = JSON.parse(await Bash(
      `gh pr list --state open --json baseRefName,headRefName --limit 100 2 > /dev/null || echo  " [] " `
    ));
    const inPr = candidates.filter(c = >   prs.some(p = >   p.baseRefName === c || p.headRefName === c));
    if (inPr.length === 1) picked = inPr[0];
  }
  if (!picked) {
    // 실제 작업이 쌓인 쪽을 고른다 — ⚠️ 고정 기준(development)으로 세면 안 된다.
    //   후보들의 **fork point 가 다르면**(하나는 initiative/10-x, 하나는 development 에서 갈라짐)
    //   `development..후보` 는 물려받은 조상 커밋까지 세므로 **작업이 없는 쪽이 이긴다**.
    const mergeBase = (await Bash(
      `git merge-base --octopus ${candidates.map(c = >   `origin/${c}`).join( "   " )}`
    )).trim();
    const counts = await Promise.all(candidates.map(async c = >   ({
      c, n: parseInt((await Bash(`git rev-list --count ${mergeBase}..origin/${c}`)).trim(), 10),
    })));
    // 조회 실패(NaN)를 0 으로 뭉개지 않는다 — 하나라도 실패하면 3번을 버리고 4번(사전순)으로 간다.
    if (counts.some(x = >   Number.isNaN(x.n))) {
      picked = [...candidates].sort()[0];
    } else {
      counts.sort((a, b) = >   b.n - a.n || a.c.localeCompare(b.c));
      picked = counts[0].c;
    }
  }
  // 조용한 선택 금지 — 콘솔 + 이슈 코멘트 양쪽에 남긴다. 탈락 후보는 **삭제하지 않는다**
  // (다른 머신에 아직 push 되지 않은 작업이 그 위에 있을 수 있다).
  console.warn(`⚠️ 중복 계층 브랜치 #${issueNumber}: ${candidates.join( " ,  " )} → 채택  ' ${picked} ' `);
  const note = `⚠️ 계층 브랜치가 ${candidates.length}개 발견되어  ' ${picked} '   를 채택했습니다 `
    + `(branch-hierarchy R4). 후보: ${candidates.join( " ,  " )} — 나머지는 삭제하지 않았습니다.`;
  await Bash(`gh issue comment ${issueNumber} --body ${JSON.stringify(note)}`);
  await recordBranchRegistry(issueNumber, picked, baseHint);         // 다음 세션은 1번에서 끝난다
  return picked;
}

// 대장 read/write 는 branch-hierarchy SKILL.md  " R3 "   의 명령 그대로다(여기서 복제하지 않는다).
//   readBranchRegistry(n)                  → 기록된 브랜치명 또는 null (읽기 실패는 null, 차단 아님)
//   recordBranchRegistry(n, branch, base)  → 마커 코멘트 upsert (실패는 비차단 경고)

// 재귀적 base 브랜치 결정
async function resolveBaseBranch(issueNumber: number): Promise < string >   {
  // ⚠️ getIssue 미존재 → searchLatestIssues 로 이슈 + nested parentIssue 조회
  const _found = await mcp__zenhub__searchLatestIssues({ query: `#${issueNumber}` });
  const issue = _found.find(i = >   i.number === issueNumber);
  if (!issue) throw new Error(`이슈 #${issueNumber} 조회 실패 — base 브랜치 해석 불가`);
  // 계층 브랜치 prefix (branch-hierarchy 스킬 기준, 5레벨 — Orca 도입 이후 Initiative/Project도
  // Epic과 동일하게 자기 브랜치를 가진다):
  //   Initiative → initiative/ · Project → project/ · Epic → epic/ · Sub-task → task/
  //   parent 있는 Feature/Bug/Task(=Story) → story/ · parent 없는 단독 이슈 → 기존 feature/
  //   (이 함수는 계층 케이스 전용 — 단독 이슈는 기존 동작 유지)
  const branchPrefix = hierarchyPrefixOf(issue);
  // ⭐ 1순위: **이미 원격에 있는 브랜치를 찾는다** (R1·R2·R4)
  //    다른 머신/세션이 만든 브랜치는 slug 가 다를 수 있으므로 번호 글롭으로 찾는다.
  const found = await findExistingHierarchyBranch(branchPrefix, issue.number,  " development " );
  if (found) return found;

  // 없을 때만 부모를 거슬러 올라간다(있으면 조부모 체인을 건드리지 않는 기존 동작 유지)
  const myBase = issue.parentIssue?.number
    ? await resolveBaseBranch(issue.parentIssue.number)   // 재귀 — 조부모까지 브랜치 보장
    :  " development " ;

  // ⭐ 2순위: 대장에 이름이 있으면 **그 이름 그대로** 재생성한다 (R3)
  //    새 slug 를 만들면 이름이 갈리는 원인을 그대로 재현하는 짓이다.
  const registered = await readBranchRegistry(issue.number);
  // createSlug romanizes non-ASCII and never returns  ' '   (see issue-branch-agent.md). Pass type word for the empty-guard fallback.
  const slug = createSlug(issue.title, branchPrefix);
  const branchName = registered ?? `${branchPrefix}/${issue.number}-${slug}`;
  // Defense-in-depth (LANG-05): a non-romanized Korean-only title must not yield  " feature/25- " .
  if (!registered  & &   (!slug || branchName.endsWith( " - " ))) {
    throw new Error(`빈 slug — 브랜치명 비정상(${branchName}). 제목 로마자 변환 확인 필요`);
  }

  // ⭐ 생성 — base 를 최신화한 뒤 분기하고, **커밋이 0개여도 즉시 push** 한다 (R6).
  //    push 하지 않은 계층 브랜치는 다른 머신에서 존재하지 않는 것과 같고,
  //    `gh pr create --base` 도 그 브랜치를 찾지 못한다.
  //
  // ⛔ base 를 **체크아웃하지 않는다.** `git checkout ${myBase}` 는 그 브랜치가 같은 머신의
  //    **다른 워크트리에 이미 체크아웃돼 있으면 fatal 로 죽는다**:
  //      fatal:  ' development '   is already used by worktree at  ' /path/to/other ' 
   //    Orca 병렬 워크트리 운영에서는 base(=development 등)를 상주 워크트리가 점유하는 것이
  //    **정상 상태**라, 종전의 `git checkout ${myBase} ... || exit 1` 은 그 환경 전체에서
  //    **항상 실패**했다(실측). base 는 분기점으로만 필요하므로 원격 ref 에서 직접 자른다 —
  //    체크아웃이 필요 없을 뿐 아니라, fetch 로 얻은 origin ref 가 로컬 브랜치보다 정확하다.
  await Bash(`
    git fetch origin ${myBase} --quiet || exit 1
    # ⚠️ 같은 이름의 **로컬** 브랜치를 무조건 채택하지 않는다 — 원격이 squash 머지 후 삭제된 뒤
    #    남아 있는 낡은 로컬본을 push 하면 **이미 정리된 diff 를 통째로 되살린다**(squash 커밋은
    #    그 커밋들의 조상이 아니므로 계보 검사에도 안 걸린다).
    if git rev-parse --verify ${branchName}  > /dev/null 2 > & 1; then
      if git merge-base --is-ancestor origin/${myBase} ${branchName}; then
        git checkout ${branchName}                                   # myBase 를 포함 → 이어받는다
      else
        git branch -m ${branchName} ${branchName}-stale-$(date +%s)  # 낡은 로컬본은 보존만
        git checkout -B ${branchName} origin/${myBase}
      fi
    else
      git checkout -B ${branchName} origin/${myBase}
    fi
  `);
  const pushed = await Bash(`git push -u origin ${branchName} 2 > & 1 || echo  " __PUSH_REJECTED__ " `);
  if (pushed.includes( " __PUSH_REJECTED__ " )) {
    // R5 — 그 사이 다른 머신이 먼저 만들었다. 하드 실패도, 새 이름 생성도 하지 않는다.
    // ⚠️ 자기 커밋이 0개면 rebase 하지 않는다 — 그 상태의 `origin/{branch}..HEAD` 는
    //    **base 브랜치의 커밋들**이라, 이미 upstream 에 있는 커밋을 새 해시로 복제해 공유
    //    브랜치에 push 하게 된다(컨테이너→상위 PR diff 오염).
    console.warn(`⚠️ ${branchName} push 거부 — 원격 브랜치를 채택합니다(레이스)`);
    await Bash(`
      git fetch origin ${branchName} || exit 1
      if [  " $(git rev-list --count origin/${myBase}..HEAD) "   -eq 0 ]; then
        git checkout -B ${branchName} origin/${branchName}     # 자기 커밋 없음 → 원격 그대로
      else
        git rebase origin/${branchName} || { echo  " ⛔ rebase 충돌 — 수동 해결 후 재실행 " ; exit 1; }
      fi
      git push -u origin ${branchName}
    `);
  }
  await recordBranchRegistry(issue.number, branchName, myBase);      // R3 — 다음 머신이 이 이름을 쓴다
  console.log(`🌿 계층 브랜치 확보: ${branchName} (base: ${myBase})`);
  return branchName;
}

스택 모드일 때의 base — 선택형 실행 모드로 GitHub 네이티브 Stacked PR 을 켠 경우 (stacked-prs), 이 함수가 계산하는 "이슈 계층의 부모 브랜치"는 PR base 가 아니다. 스택에서 base 는 바로 아래 층 브랜치이며, gh stack submit 이 이를 자동으로 잡아 PR 을 만들거나 갱신한다 — --base 를 손으로 넘기지 않는다. 스택은 선형 사슬이라 형제(형제 Story·형제 Sub-task)를 함께 담을 수 없으므로, 순차 의존이 있는 형제를 한 줄로 늘어놓은 경우에만 스택 모드를 켠다. 서로 독립인 형제는 기본값(이 함수의 계층 base + Orca 병렬 워크트리) 그대로 둔다.

Step 6: BDD Scenario Writing (for screen features)#

if (analysis.requiresBdd  & &   !options.skipBdd) {
  // ⚠️ bdd-scenario-agent 는 cc-flutter 가 제공하는 cross-plugin 선택 의존이다(F3).
  //    미설치면 throw 하지 말고 BDD 단계를 skip + 경고하라(미존재 dispatch 를 PASS 로 간주 금지):
  //    if (!isAgentAvailable( " bdd-scenario-agent " )) { console.warn( " ⚠️ BDD skip — cc-flutter 미설치 " ); /* skip BDD */ }
  await Task({
    subagent_type:  " bdd-scenario-agent " ,
    prompt: `
      feature_name: ${analysis.scope}
      screen_type: ${analysis.screenType}

      화면 타입에 맞는 BDD 시나리오를 생성해주세요.
    `,
  });

  // 커밋
  await Bash(`
    git add .
    git commit -m  " test(${analysis.scope}): ✅ BDD 시나리오 작성

    - ${analysis.screenType} 화면 시나리오 추가
    - Step Definition 생성

    Co-Authored-By: Claude  < noreply@anthropic.com > " 
   `);
}

Step 7-8: Implementation and Testing#

Agent Teams Parallel Mode (auto-detected)

When Agent Teams are available (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1), Steps 7 and 8 are parallelized. When not available, automatically falls back to existing sequential processing.

사전 확인 (Step 7 시작 전):
1. CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS 환경변수 확인
2. teams_available = true → 병렬 모드 진입
3. teams_available = false → 순차 실행 (아래  " 순차 실행 흐름 "   섹션)
4. 병렬 모드 진입 시 사용자에게 팀 구성 계획 출력 후 승인 요청
   — 무인(--unattended) 계약 ②: 답할 사람이 없으면 승인 없이 스폰하지 않고 순차 실행으로
     강등한다(3번 경로) — 기능 차이 없음, 소요 시간만 다름

기질 선택 근거와 Agent Teams 의 제약(같은 워크트리 · teammate 브랜치 금지 · 쓰기 병렬은 소유권 분할 필수)은 SoT §4 Substrate Selection · §5 Fan-out Obligations 가 SoT다. 팬아웃 폭은 여기서 넓어지지 않는다 — 아래는 이미 있던 모드에 own:(경로 글롭)과 tier: 를 붙이고, 테스트 실행을 배리어 뒤로 직렬화한 것이다.

Step 7 Implementation Parallelization (frontend/backend simultaneous): — flow 블록 S7f(width:2) → S7j

Agent Teams 사용 가능 시 (tier:standard — 미설정은 기본값이 아니라 degree 2 승수다):
  ├─ Teammate 1: Backend 구현   own: kobic_server/lib/**  (+ kobic_server/pubspec.yaml)
  │   ├─ Serverpod model 정의
  │   ├─ Endpoint 구현
  │   └─ Service 로직
  │       ⛔ melos run backend:pod:generate 는 teammate 가 실행하지 않는다 —
  │          생성물(**/*.g.dart, 클라이언트 패키지)은 두 소유 영역에 동시에 쓰므로
  │          LeadS7j 배리어 이후 Step 7.5 에서 1회만 돌린다 (own:lead)
  │
  └─ Teammate 2: Frontend 구현  own: kobic/lib/**  (+ kobic/pubspec.yaml)
      ├─ Domain Layer (Entity, UseCase, Repository 인터페이스)
      ├─ Data Layer (Repository 구현, DataSource)
      └─ Presentation Layer (BLoC, Page, Widget)

  → S7j JOIN (mode:barrier, because: 프론트↔백엔드 인터페이스는 교차 항목):
    - 프론트/백엔드 인터페이스 일치 확인
    - Import 참조 정합성 확인
    - 이후 생성물 쓰기(Step 7.5)는 Lead 전용

Fallback (순차):
  Backend → Frontend 순차 실행 (기능 차이 없음, 소요 시간만 다름 — agent-teams 의 Fallback Principle)

Step 8 Test Parallelization: — flow 블록 S8w(width:3) → S8j(serialize:1). 작성만 병렬, 실행은 직렬.

Agent Teams 사용 가능 시 (tier:standard) — 각 teammate 는 테스트 코드를 **작성만** 한다:
  ├─ Teammate 1: Frontend Unit + BLoC 테스트   own: kobic/test/** (unit·bloc 하위)
  │   ├─ UseCase 단위 테스트 (unit-test-agent)
  │   └─ BLoC 단위 테스트 (bloc-test-agent)
  │
  ├─ Teammate 2: Backend Unit + Integration 테스트  own: kobic_server/test/**
  │   ├─ 엔드포인트 단위 테스트 (serverpod-test-agent)
  │   ├─ 서비스 로직 단위 테스트
  │   └─ 엔드포인트 통합 테스트 (코드 작성까지)
  │
  └─ Teammate 3: BDD Patrol 테스트 (screen feature 시)  own: kobic/integration_test/**
      ├─ Gherkin 시나리오 검증
      └─ Step Definition 작성

  ⛔ teammate 는 `flutter test` / `dart test` 를 실행하지 않는다 — 세 teammate 가 **같은 워크트리**를
     공유하므로 포트·시뮬레이터·빌드 산출물 슬롯이 하나뿐이고(런타임 격리는 워크트리 경로에서 결정적으로
     뽑힌다: `skills/parallel-test-env/SKILL.md`), 동시 실행은 서로의 결과를 오염시킨다.

  → S8j JOIN (mode:barrier + serialize:1, because: 실행 슬롯이 하나뿐):
    - Lead 가 `/cc-dev:pr:preflight --auto-scope` 로 **한 번에 직렬 실행**
    - 전체 tests passed 여부 집계 (미실행 항목은 PASS 아님 — FO-04)
    - 실패 시 PR 생성 차단 → flow 블록 `S8 == >   S7R`

Fallback (순차):
  Unit → BLoC → Backend → BDD 순차 작성 후 동일하게 Lead 가 실행

Step 7.0: Bug-type Runtime Repro & Diagnosis (버그 이슈일 때) ⚠️

analysis.type === "fix"(= ZenHub Bug)로 분류된 이슈는 구현 착수 전에 기기에서 재현·진단한다. 절차를 여기서 복제하지 않는다 — commands/bugfix.md Step 3.5(기기 tier 판정 → 앱 기동 → BUGFIX-DEBUG(#N) 태그 계측 → 원인 file:line 특정, 루프 L-bf3.5)와 Step 4.2(수정 후 기기 재현 재확인 + 계측 전량 회수, 잔여 0건 게이트)를 그 문서의 규칙 그대로 실행하고, 결과를 prBodyExtras.runtimeDiagnosis 에 담아 Step 9-10 의 PR body ## Runtime Diagnosis 로 싣는다.

Agent Teams 병렬 모드에서도 이 단계는 Lead 가 팀 스폰 전에 1회 수행한다 — 기기·VM Service 슬롯은 하나뿐이라 teammate 로 나눌 수 없다(Step 8 테스트 실행을 serialize:1 로 두는 것과 같은 이유).

// 버그가 아니면 no-op. 화면 기능의 디자인 대조는 Step 7.3 이 따로 담당한다(서로 다른 검증이다).
if (analysis.type ===  " fix " ) {
  // 기기 부재·--skip-device 는 **무음 스킵이 아니다** — bugfix.md Step 3.5 의 확인 + 기록 계약을 따른다
  // (waivedGates:  " runtime-repro " , 원인은  ' 추정(런타임 미검증) ' 으로 PR 에 남는다).
  prBodyExtras.runtimeDiagnosis = await runBugfixRuntimeDiagnosis(issue, {
    device: options.device, skipDevice: options.skipDevice, unattended: options.unattended,
  });
}
  • --skip-tests 는 이 단계를 면제하지 않는다(면제 스위치는 --skip-device 하나뿐).
  • 계측 잔여 0건(git grep -n "BUGFIX-DEBUG")은 Step 8.5 pre-push 와 함께 면제 불가로 취급한다 — 일반 디버그 산출물 체크리스트의 정본은 agents/pr-readiness-agent.md 다.

Sequential Execution Flow (default / Fallback)

// 구현 작업 — 버그 이슈면 위 Step 7.0 의 rootCause(file:line)를 근거로 고친다(추측 수정 금지)
await Task({
  subagent_type:  " implementation-agent " ,
  prompt: `이슈 #${issue.number} 구현`,
});

// Backend 변경 감지
const hasBackendChanges = detectBackendChanges(issue);

// 테스트 작성 및 검증 (필수 게이트) ⚠️
if (!options.skipTests) {
  const testTypes = [ " unit " ,  " bloc " ];
  if (analysis.requiresBdd) testTypes.push( " bdd " );
  if (hasBackendChanges) testTypes.push( " backend_unit " ,  " backend_integration " );

  // ──────────────────────────────────────────────────
  // Phase 1: 테스트 플랜 작성
  // ──────────────────────────────────────────────────
  // PR의 Test Plan 항목을 먼저 정의 — 구현 코드의 핵심 동작 시나리오
  const testPlan = generateTestPlan({
    workContent,
    scope: analysis.scope,
    testTypes,
    hasBackendChanges,
    requiresBdd: analysis.requiresBdd,
  });

  // ──────────────────────────────────────────────────
  // Phase 2: 테스트 플랜 기반 테스트 코드 작성
  // ──────────────────────────────────────────────────
  await Task({
    subagent_type:  " test-runner-agent " ,
    prompt: `
      feature_name: ${analysis.scope}
      test_types: ${JSON.stringify(testTypes)}
      test_plan: ${JSON.stringify(testPlan)}
      auto_fix: true
      require_tests: true
      write_tests_for_plan: true
    `,
  });

  // ──────────────────────────────────────────────────
  // Phase 3: 테스트 실행 및 결과 수집
  // ──────────────────────────────────────────────────
  // 3-1. 정적 분석 (dart analyze) — 테스트 코드 컴파일 검증
  // FO-10: 파이프 `| grep -c` 는 grep 의 exit code 만 남겨 dart analyze 의 실패(미설치/크래시)를
  //   0( " clean " )으로 가린다. analyze 를 직접 실행해 exit code 로 판정하고,
  //   명령 미실행/비정상 종료는 0 이 아니라 명시 FAIL 로 둔다(검증 불가 ≠ 통과).
  const analyzeRaw = await Bash(
    `dart analyze ${testPlan.map(t = >   t.file).join( '   ' )} 2 > & 1`
  );
  if (analyzeRaw.exitCode === 127 /* command not found */) {
    throw new Error( " dart analyze 실행 실패(도구/스크립트 부재) — 검증 불가 = 차단 (Step 0 도구 점검 참조) " );
  }
  // dart analyze: exit 0 = 이슈 없음, 그 외 = 이슈 존재(또는 실행 오류 → 차단)
  const analyzeErrors = analyzeRaw.exitCode === 0
    ? 0
    : ((analyzeRaw.stdout.match(/^\s*error\b/gim) || []).length || 1);

  // 3-2. 테스트 실행 (정적 분석 통과 시)
  let testResults = { totalPassed: 0, totalFailed: 0, items: [] };
  if (analyzeErrors === 0) {
    const testOutput = await Bash(
      `flutter test ${testPlan.map(t = >   t.file).filter(Boolean).join( '   ' )} 2 > & 1`
    );
    testResults = parseTestOutput(testOutput.stdout);
  }

  // ──────────────────────────────────────────────────
  // Phase 4: 결과를 PR Test Plan에 반영
  // ──────────────────────────────────────────────────
  prBodyExtras.testPlanResults = testPlan.map(item = >   ({
    ...item,
    analyzePass: analyzeErrors === 0,
    testPass: testResults.items.find(r = >   r.name === item.item)?.passed ?? null,
  }));
  prBodyExtras.testSummary = {
    analyzeErrors,
    totalPassed: testResults.totalPassed,
    totalFailed: testResults.totalFailed,
  };

  // ⚠️ 게이트: 정적 분석 실패 시 PR 생성 차단
  if (analyzeErrors  >   0) {
    throw new Error(`테스트 코드 정적 분석 실패 (${analyzeErrors}) - 에러 수정 후 재시도`);
  }
  // ⚠️ 게이트: 테스트 실패 시 PR 생성 차단 (런타임 실행 가능했던 경우)
  //   flow 블록 `S8 == >   S7R on:test.fail bound:2` — 되돌림·예산은 enterRework() 한 곳에만 있다.
  //   (단순 throw 는  " 다음에 다시 하자 " 로 끝나 이미 completed 인 게이트들을 그대로 남긴다)
  if (testResults.totalFailed  >   0) {
    console.error(`❌ 테스트 ${testResults.totalFailed}건 실패 — Step 7 재작업으로 되돌린다`);
    return await enterRework( " S7R " );
  }
  // ⚠️ 게이트(FO-04): 실행되지 않은(미검증) 테스트는 PASS 로 간주하지 않는다.
  //   testPass === null 은  " 실패 아님 " 이 아니라  " 검증 안 됨 "   → 차단 상태.
  const unverified = prBodyExtras.testPlanResults.filter(r = >   r.testPass === null);
  if (unverified.length  >   0) {
    throw new Error(`테스트 미실행 ${unverified.length}건 — 검증되지 않은 항목은 PASS 로 간주하지 않음(차단)`);
  }
} else {
  // --skip-tests 사용 시 사용자 확인 필수 — flow 블록 `S8 ~~ >   S8.3 record:ASK+prBodyExtras.testsSkipped`
  // 무인(--unattended) 계약 ③: 플래그로 이미 의도를 밝혔으므로 재확인 없이 스킵 확인을 자동 채택한다.
  const confirm = options.unattended
    ?  " 스킵 확인 " 
     : await AskUserQuestion({
        questions: [{
          header:  " 테스트 스킵 확인 " ,
          question:  " 테스트를 스킵하면 검증 없이 PR이 생성됩니다. 계속하시겠습니까? " ,
          options: [
            { label:  " 스킵 확인 " , description:  " 테스트 없이 진행 (긴급 핫픽스용) — PR body에 영구 기록 "   },
            { label:  " 테스트 실행 " , description:  " 테스트를 실행합니다 "   },
          ],
        }],
      });

  // ⚠️ 답을 실제로 분기한다 — 예전에는 물어보고 답을 버려서  " 테스트 실행 " 을 골라도 스킵됐다.
  if (confirm ===  " 테스트 실행 " ) {
    options.skipTests = false;
    return await runFromStep( " 8 " );         // 같은 단계를 테스트 실행 경로로 재진입 (id 는 문자열)
  }

  // ⚠️ 콘솔 경고는 내구 기록이 아니다 — 스킵 사실은 PR body 로 남는다 (Step 7.3 스킵과 동일 처리)
  prBodyExtras.testsSkipped = { reason:  " --skip-tests (user confirmed) " , at: new Date().toISOString() };
  console.warn( " ⚠️ 테스트 스킵 확인됨 — PR body에  ' 테스트 미검증 '   경고 영구 기록 " );
}

Step 7.1: Design Reference & Decision (screen features)#

화면 작업 중 디자인에 관해 일어나는 일은 두 층위다. 참고(무엇이 좋은 디자인인가)와 결정(그래서 이 화면은 무엇으로 하는가). 이 단계는 둘 다를 cc-designer 로 처리하고, 결정 때문에 파이프라인이 멈추지 않게 한다.

층위무엇도구성격
참고색·타이포·간격·모션·접근성 판단 근거cc-designer 지식 스킬 (ui-*/ix-*/dsys-*) — 작업 맥락에서 자동 트리거비차단
결정스펙·시안에 답이 없는 디자인 판단을 하나로 확정cc-designer:design-decision 스킬 / /cc-designer:decide비차단, 되묻지 않음, DDR 기록 필수
검수다 만든 뒤 결과물 비평/cc-designer:critique-screen (Step 8.7 직전, 선택)비차단

결정 트리거 (이때 design-decision 으로 들어간다)

구현 중 다음이 발생하면 사람에게 묻지 말고 프로토콜을 돌린다:

  • 이슈 AC·시안에 명시가 없는 상태 동작 (빈 목록·0건 검색·오프라인·권한 없음·키보드 노출 시 무엇을 유지/해제)
  • 같은 성격의 UI 가 코드 안에서 서로 다르게 동작·표기 (포맷·자릿수·피드백 방식 불일치)
  • 토큰에 없는 값이 필요해 보임 (색·간격·타입 — 대개 기존 토큰으로 해결된다)
  • 문구(빈 상태·에러·버튼 라벨)를 새로 지어야 함
  • 접근성 기준(타겟 크기·대비·포커스 순서)이 시안에 없어 판단이 필요함

절차

// Step 7.1: 디자인 레퍼런스 · 의사결정 (screen feature 시)
if (!analysis.requiresBdd) {
  // 화면 기능이 아니면 참고·결정 대상 없음 — 조용히 통과
} else {
  // ── 1. 참고 (비차단) ──
  //   ui-*/ix-*/dsys-* 지식 스킬은 작업 맥락에서 자동 트리거된다.
  //   CoUI 확장이 필요해 보이면 Step 7.2 의 composition ladder 를 먼저 타라
  //   ( " CoUI 로 안 된다 " 의 대부분은 style override / composition 으로 해결된다).

  // ── 2. 결정 (되묻지 않음) ──
  //   판정 로직의 SoT 는 cc-designer 의 design-decision 스킬이다. 여기 복제하지 않는다.
  //   근거 사다리 R0(확정 스펙) → R1(시안) → R2(코드 선례 전수 조사)
  //              → R3(디자인 시스템 지식) → R4(정량 기준) → R5(가역성 타이브레이크)
  for (const question of designQuestions) {
    const ddr = await Skill({
      skill:  " cc-designer:design-decision " ,   // 또는 /cc-designer:decide  " < 질문 > "   --brief
      args: `${question} --scope ${analysis.scope}`,
    });
    prBodyExtras.designDecisions ??= [];
    prBodyExtras.designDecisions.push(ddr);   // { id, decision, rung, evidence, reversibility, confidence }
  }
  // 누적 로그(append-only)는 다음 작업의 R2 선례가 된다 — 같은 질문을 두 번 결정하지 않기 위함
  //   .claude/docs/{scope}/design-decisions.md
}

비대화형 계약

디자인 판단은 사람의 승인을 필요로 하지 않는다. 도출 가능한 질문을 사람에게 넘기는 것은 판단 위임이 아니라 판단 회피다. Step 7.1 은 AskUserQuestion 을 쓰지 않는다 — 아래 4종 예외만.

예외처리
브랜드 정체성 변경 (로고·브랜드 컬러 자체·보이스)권고안 1개 + 근거 제시 후 확인
비즈니스·법적 정책 (가격 표기, 환불·약관·동의 문구, 개인정보 노출 범위)동일
비가역 변경 (데이터 마이그레이션/삭제, 공개 URL·도메인, 배포된 API 계약)동일
R0 충돌 (LOCKED seed spec ↔ 시안 모순)결정 아님 → batch.md Phase 0.5 MAJOR-DRIFT 계약으로 이관
  • "디자이너 확인 필요"로 TODO 를 남기고 넘어가는 것도 금지 — 그것은 결정을 미룬 것이다.
  • 묻지 않는 것이 기록하지 않는 것은 아니다. 모든 결정은 DDR 로 남고 리뷰어가 사후 열람한다.
  • 확신이 낮으면(R5 도달) 결정은 하되 신뢰도: low + PR body ⚠️ 재검토 로 남긴다. 멈추지 않는다.
  • 무인(--unattended) 계약 ④: 위 4종 예외가 실제로 발생했는데 답할 사람이 없으면, 권고안을 확정하지 않고 이 이슈만 정지한다 — 호출자에게 [INCOMPLETE: design_escalation_pending] 을 보고하고 권고안·근거는 이슈 코멘트에 남긴다(go.md D-3/Error Handling의 "그 이슈만 [INCOMPLETE] 로 기록하고 다음으로"와 동일 원칙 — 이 커맨드가 단독으로 호출된 경우도 같게 처리한다).

기록과 게이트 관계

  • PR body ## Design Decisions 로 렌더된다 (Step 9). 결정이 0건이면 섹션 자체가 없다 — 결정 0건은 정상이며 게이트가 아니다.
  • 본문/아티팩트 분배: 이슈 본문과 PR body 에는 결정문 한 줄만 남기고, 근거 사다리·기각 대안·가역성 서술은 아티팩트(있으면)와 누적 로그 .claude/docs/{scope}/design-decisions.md 에 둔다. 구현·리뷰 단계는 결정문만 있으면 충분하다 (SoT: rules/zenhub-conventions.mdIssue Body Artifact Contract).
  • Step 7.3(디자인 검증 게이트)과의 관계: DDR 이 의도적으로 시안과 다른 결정을 했다면, 그 DDR 이 Step 7.3 잔존 diff 판정의 근거 자료가 된다 (근거 없는 불일치와 구분).
  • Step 8.7(코드 리뷰 게이트)과의 관계: Step 7.1 은 여전히 비차단이다 — 화면 기능의 강제 관문 목록은 위 Step 표가 유일한 기준이며(4·7.2·7.3·8·8.3·8.5·8.7) 여기서 다시 열거하지 않는다. 목록을 여러 곳에 복제하면 반드시 어긋난다.

Step 7.2: CoUI Package Change Separation (on CoUI component modification/extension) ⚠️#

화면 구현(Step 7) 중 CoUI 컴포넌트 자체의 수정/확장이 필요해지는 경우의 분리 절차. CoUI는 여러 프로젝트가 공유하므로 CoUI 소스 변경은 화면 PR에 절대 섞지 않는다.

감지 조건 (composition ladder 선행 필수):

1. CoUI 컴포넌트 as-is 사용                    → 해결되면 Step 7.2 불필요
2. Core < X > Style / typed param 오버라이드        → 해결되면 Step 7.2 불필요
3. Composition (CoUI behavior + 커스텀 content) → 해결되면 Step 7.2 불필요 (프로젝트 내 해결)
4. CoUI 확장 필요 확정                          → Step 7.2 진입 ⚠️

ladder 1~3은 전부 프로젝트 안에서 해결되며 CoUI PR이 필요 없다. rung 4 확정 시에만 분리 절차를 밟는다. 상세: coui-composition-and-extension 스킬.

분리 절차:

// Step 7 구현 중 CoUI 확장 필요 감지 시
if (needsCouiExtension) {
  // 1. 화면 작업 일시 정지 — composition으로 진행 가능한 부분은 계속
  //    (CoUI 신규 capability에 막힌 부분만 보류)

  // 2. CoUI 저장소(coco-de/coui)에서 별도 dev 사이클 실행
  //    - 별도 이슈/브랜치/PR (화면 이슈와 상호 링크)
  //    - additive · backward-compatible 확장만 (기존 기본값/동작 보존)
  //    - ⚠️ coui_core 계약 + coui_flutter + coui_web 양쪽 모두 반영 (한쪽만 금지)
  //    - Widgetbook use-case + minor 버전 범프 + CHANGELOG

  // 3. CoUI PR 선행 머지 (CoUI 저장소 자체 게이트/리뷰 통과)

  // 4. 프로젝트로 복귀: CoUI 의존성 갱신 후 화면 조립 재개
  //    - pubspec 버전 범프 / vendored copy 갱신 / submodule 포인터 업데이트
  //    - 커밋:  " chore(deps): 🔧 coui 버전 범프 (vX.Y.Z) " 
   //    - 이후 화면 브랜치에서 컴포넌트 조립(assembly)만 계속
}

Step 9 게이트 — 화면 PR의 CoUI 소스 오염 차단:

# 화면 PR diff에 CoUI 패키지 소스 변경이 포함되면 PR 생성 차단
COUI_TOUCHED=$(git diff --name-only ${BASE_BRANCH}..HEAD | grep -E  ' (^|/)(packages/)?coui(_flutter|_web|_core)?/ '   || true)
if [ -n  " $COUI_TOUCHED "   ]; then
  echo  " ❌ 화면 PR에 CoUI 소스 변경 포함 — Step 7.2 절차로 CoUI 저장소 별도 PR로 분리하세요 " 
   echo  " $COUI_TOUCHED " 
   exit 1
fi
# 허용: pubspec.yaml/lockfile 버전 범프, submodule 포인터 커밋 (의존성 반영)

스킵 시 문제:

  • CoUI 자체 리뷰/하위호환 게이트를 우회 → 다른 소비 프로젝트가 조용히 깨짐
  • coui_flutter만 고치고 coui_web을 빠뜨리면(또는 반대) 크로스플랫폼 API 계약 desync
  • 화면 PR과 디자인시스템 변경이 결합되어 독립 머지/리버트 불가

Step 7.3: Design Verification Gate (Figma ↔ Runtime) ⚠️#

배경: 지금까지 화면 기능은 /cc-flutter:figma:analyze가 뽑은 정적 텍스트/토큰 스펙만 보고 구현된 뒤 곧장 테스트/린트/리뷰로 넘어갔다 — 시뮬레이터·실기기에 실제로 앱을 띄워 Figma 시안과 픽셀 단위로 비교하는 단계가 어디에도 없었다. 이 단계가 "매번 대충 만들어 디자인이 달라진다"는 문제의 직접적인 원인이었다. Step 7.3은 이미 존재하는 4-layer 검증 스택(cc-pixel-loop가 design-conformance 레이어 — Marionette 캡처 + Figma diff) 중 저작(pixel-loop, 코드를 실제로 고침)과 게이트(visual-verify, 읽기 전용 판정)를 이 워크플로에 처음으로 연결한다.

Step 7.1(Design Reference, cc-designer)과는 다른 층위다: Step 7.1은 화면을 "작성하는 동안" 색상·타이포·모션 판단 근거를 참고하는 비차단 참고 단계이고, Step 7.3은 화면을 "다 작성한 뒤" 실제 실기기 렌더를 Figma 시안과 픽셀 단위로 대조하는 차단 검증 게이트다. 둘은 경쟁하지 않고 보완한다.

감지 조건: analysis.requiresBdd(screen feature)이면서 .claude/docs/${analysis.scope}/figma_analysis.md에 Figma URL이 있을 때만 실행. 화면 기능인데 Figma 소스 자체가 없으면(순수 텍스트 설명) 비교 대상이 없으므로 not-applicable로 조용히 skip.

루프 계약 L-7.3 (필드 정의: SoT §2)

inv:      종료 시 prBodyExtras.designVerification 가 항상 채워져 있다
          (pass | skipped | fail-acknowledged 중 하나 — 비어 있는 종료는 Step 9 게이트에서 차단된다)
prog:     verify.diffCount, repair 라운드마다 강한 감소
          no-prog: 같은 diff 에 같은 repair 를 반복하지 않는다 → 즉시 AskUserQuestion 으로 올린다
term:     verify.pass === true
budget:   inner repair 1(pixel-loop 자체 진동 감지와 별개) / outer = 프레젠테이션 레이어 push 당 1(per-push 정책은 아래 ♻️ 문단 — Step 8.5 처럼 매 push 전체 재실행이 아니다)
exhaust:  AskUserQuestion" Step 7로 복귀 "   = enterRework( " S7R " ) (bound:1) ·
           " 그대로 진행 "   = status: " fail-acknowledged "PR body 에 미해결 기록 (무음 통과 금지)
resume:   cc-pixel-loop:visual-verify 재실측 + prBodyExtras.designVerification.status
log:       " 7.3 #1: diff=12→3 "   · 최종 status 와 tolerance/target 을 PR body 에 영구 기록
// ══════════════════════════════════════════════════════════
// Step 7.3: 디자인 검증 게이트 (Figma ↔ 실기기/시뮬레이터)
// ══════════════════════════════════════════════════════════
const figmaDocPath = `.claude/docs/${analysis.scope}/figma_analysis.md`;
const figmaDocExists = (await Bash(`test -f  " ${figmaDocPath} "   & &   echo yes || echo no`)).trim() ===  " yes " ;
const hasDesignSource = analysis.requiresBdd  & &   figmaDocExists;

if (!hasDesignSource) {
  console.log( " ℹ️ Step 7.3 skip — screen feature 아님 또는 Figma 소스 없음 (대조 대상 없음) " );
} else if (analysis.platform ===  " web " ) {
  // pixel-loop 캡처는 VM Service 기반(Marionette) — Flutter Web 미지원. cc-jaspr-web 또는 cc-flutter:figma:analyze 의 Playwright 폴백 경로 안내
  console.warn( " ⚠️ Flutter Web 대상 — pixel-loop/visual-verify 미지원. cc-jaspr-web 또는 /cc-flutter:figma:analyze 의 Playwright 폴백 참고 " );
  prBodyExtras.designVerification = { status:  " skipped " , reason:  " flutter-web (see /cc-flutter:figma:analyze Playwright fallback) "   };
} else {
  // ── 1. 도구/기기 가용성 확인 (Step 0 capability map 확장분 참조) ──
  // ⚠️ Step 0의  " 미설치→degrade+경고(무음) "   기본 계약의 예외: 여기서는 무음 스킵 금지.
  const designToolingOk =
    capabilityMap.figmaMcp  & &   capabilityMap.marionetteMcp  & &   capabilityMap.dartMcp  & &   capabilityMap.device;

  if (!designToolingOk || options.skipDesignVerify) {
    // 무인(--unattended) 계약 ⑤: 도구/기기 부재 또는 명시적 스킵 요청 모두  " 스킵 확인 " 을 자동 채택한다.
    const confirm = options.unattended
      ?  " 스킵 확인 " 
       : await AskUserQuestion({
          questions: [{
            header:  " 디자인 검증 스킵 확인 " ,
            question: !designToolingOk
              ?  " figma/marionette/dart MCP 또는 시뮬레이터·기기가 준비되지 않았습니다. 디자인 검증 없이 진행할까요? " 
               :  " 화면 실기기 검증 없이 진행하면 디자인 드리프트가 PR에 그대로 실릴 수 있습니다. 계속할까요? " ,
            options: [
              { label:  " 스킵 확인 " , description:  " 디자이너 수동 확인 필요 — PR에 경고 영구 기록 "   },
              { label:  " 검증 실행 " , description:  " 지금 도구를 갖추고 검증을 실행합니다 "   },
            ],
          }],
        });
    if (confirm !==  " 검증 실행 " ) {
      console.warn( " ⚠️ Step 7.3 스킵 확인됨 — PR body에  ' 디자인 검증 미실행 '   경고 기록 " );
      prBodyExtras.designVerification = {
        status:  " skipped " ,
        reason: !designToolingOk ?  " tooling/device unavailable "   :  " --skip-design-verify (user confirmed) " ,
      };
    }
  }

  if (!prBodyExtras.designVerification) {
    // ── 2. 앱 기동 — 별도 프로세스 (Step 7.7의 serverpod 로컬 앱과 공유하지 않음, 7.3이 7.7보다 먼저 실행) ──
    const target = options.designTarget ||  " auto " ;
    const boot = await Bash(`flutter run -d ${target} --print-dtd 2 > & 1  & `, { run_in_background: true });
    const dtdUri = extractDtdUri(boot.stdout);             //  " ws://127.0.0.1:PORT/token "   — Dart MCP(hot reload)용
    const vmServiceUri = extractVmServiceUri(boot.stdout); //  " ws://127.0.0.1:PORT/token/ws "   — Marionette(캡처)용. DTD와 다른 URI다

    const figmaUrl = extractFigmaUrl(await Read(figmaDocPath)); // pixel-loop-handoff 와 동일 추출 규칙, 재구현 금지
    const resolvedRoute = resolveRouteForScope(analysis.scope);  // lib/router.dart TypedRoute 또는 *_route.dart 매칭
    const mode = isReworkPass ?  " repair "   :  " create " ;
    const tolerance =
      options.designTolerance || (mode ===  " create "   ?  " pixel=2,color_delta_e=3 "   :  " pixel=1,color_delta_e=2 " );

    // ── 3. (선택·비차단) Marionette 스모크 —  " 보이지만 안 눌리는 "   버그를 조기 포착 ──
    await Skill({ skill:  " cc-marionette:smoke " , args: `--routes=${resolvedRoute}` })
      .catch(e = >   console.warn(`⚠️ marionette smoke 실패(informational, 비차단): ${e?.message ?? e}`));

    // ── 4. pixel-loop — 실제 저작/수렴 (코드를 고침) ──
    await Skill({
      skill:  " cc-pixel-loop:loop " ,
      args: `${figmaUrl} --mode=${mode} --route=${resolvedRoute} --target=${target} --tolerance ${tolerance}`,
    });

    // ── 5. visual-verify — 읽기 전용 최종 판정 (이 화면이 건드린 모든 라우트) ──
    let verify = await Skill({
      skill:  " cc-pixel-loop:visual-verify " ,
      args: `--figma=${figmaUrl} --routes=${touchedRoutesForScope(analysis.scope).join( " , " )} --mode=regression --target=${target} --tolerance ${tolerance}`,
    });

    if (!verify.pass) {
      // 1회 repair 재시도 (pixel-loop 자체 진동-감지와 별개로, 여기서도 무음 통과는 금지)
      await Skill({
        skill:  " cc-pixel-loop:loop " ,
        args: `${figmaUrl} --mode=repair --route=${resolvedRoute} --target=${target} --tolerance ${tolerance}`,
      });
      verify = await Skill({
        skill:  " cc-pixel-loop:visual-verify " ,
        args: `--figma=${figmaUrl} --routes=${touchedRoutesForScope(analysis.scope).join( " , " )} --mode=regression --target=${target} --tolerance ${tolerance}`,
      });
    }

    await Bash(`kill %1 2 > /dev/null || true`); // flutter run 프로세스 종료

    if (!verify.pass) {
      // ⚠️ FO-04와 동일 원칙: 미해결 diff를 무음으로 통과시키지 않는다
      // 무인(--unattended) 계약 ⑥: 안전한 쪽(Step 7 복귀)을 자동 채택 —  " 그대로 진행 " 은 결함을 숨긴다.
      const decision = options.unattended
        ?  " Step 7로 복귀 " 
         : await AskUserQuestion({
            questions: [{
              header:  " 디자인 검증 실패 " ,
              question: `재시도 후에도 디자인 불일치가 남아 있습니다(${verify.diffCount}). 어떻게 할까요?`,
              options: [
                { label:  " Step 7로 복귀 " , description:  " 코드를 더 고친 후 재시도 (권장) "   },
                { label:  " 그대로 진행 " , description:  " 디자이너 수동 확인 필요 — PR에 미해결로 기록 "   },
              ],
            }],
          });
      if (decision !==  " 그대로 진행 " ) {
        // flow 블록 `S7.3 == >   S7R bound:1` — 이 엣지의 예산은 1회다.
        // 이미 재작업 패스(isReworkPass)에서 또 실패했다면 예산 소진이므로 되돌리지 않고 멈춘다.
        if (isReworkPass) {
          await blockIssue(issue,  " design_verify_exhausted " ,
            { detail: `재작업 패스에서도 잔존 diff ${verify.diffCount}(L-7.3 bound:1 소진)` });
          throw new Error(
            `디자인 검증 실패 — 재작업 패스에서도 잔존 diff ${verify.diffCount}. ` +
            `L-7.3 bound:1 소진 → BLOCKED( ' design_verify_exhausted ' ) 보드 반영 완료, 디자이너 수동 확인 필요`
          );
        }
        return await enterRework( " S7R " );   // invalidates: 목록을 pending 으로 되돌린 뒤 Step 7 재실행
      }
      prBodyExtras.designVerification = { status:  " fail-acknowledged " , tolerance, target, ...verify };
    } else {
      prBodyExtras.designVerification = { status:  " pass " , tolerance, target, ...verify };
    }
  }
}

console.log(`✅ Step 7.3 complete — ${prBodyExtras.designVerification?.status ??  " not-applicable " }`);

♻️ Per-push 재실행 정책 (Step 8.5와 다름): Step 7.3은 Step 8.5(린트 게이트)처럼 매 push마다 전체를 다시 돌리지 않는다 — 기기 부팅 + Figma/Marionette MCP 왕복 비용이 크기 때문. 대신 Step 11 리뷰 피드백 push가 프레젠테이션 레이어 파일(**/*_page.dart, **/*_widget.dart, **/*_view.dart, **/*_screen.dart, **/presentation/**/*.dartcc-dcm:dcm-flutter 스킬에 이미 정의된 glob 그대로 재사용)을 건드릴 때만 cc-pixel-loop:visual-verify(읽기 전용)만 가볍게 재실행하고, 회귀가 발견될 때만 cc-pixel-loop:loop --mode=repair 1회로 에스컬레이션한다.

스킵/실패 시 문제:

  • 무음으로 건너뛰면 화면이 디자인과 다르게 나가는 지금까지의 문제가 그대로 반복된다
  • 디자이너가 PR에서 확인할 객관적 증빙(스크린샷·diff 리포트)이 없으면 "됐다"는 주장만 남고 실제 검증은 안 됨
  • Figma 소스가 있는데 prBodyExtras.designVerification가 비어 있으면 Step 9 게이트에서 차단됨 (아래 Verification Gates 참고)

Step 7.7: Local Full-Stack Integration Verification (Serverpod 4 / 로컬 풀스택)#

조건부 실행: Backend 변경(hasBackendChanges) 또는 screen feature(analysis.requiresBdd)일 때만 실행. 기본 on, --skip-local-integration 옵션으로 스킵 가능. 게이트 실패 시 Step 8 진행 차단.

⚠️ 백엔드 통합/E2E 검증 대상은 프로젝트의 Serverpod 버전에 따라 갈린다 — 아래 코드 블록의 "로컬 풀스택 우선" 흐름은 Serverpod 4+ 로 새로 시작한 프로젝트 전제다. 기존 Serverpod 3.x 프로젝트(다수의 프로덕션 프로젝트가 여기 해당)에서는 로컬 풀스택 흉내에 비공식 서드파티 포크가 필요해 리스크가 있으므로, Staging을 먼저 검토한다. 판단 기준은 patrol-bdd-conventions 의 "백엔드 대상" 절 참조 — 이 Step 을 프로젝트에 적용하기 전에 먼저 그 절로 어느 경로를 탈지 정할 것.

⚠️ Staging 을 쓰면 blast radius 가 조용히 바뀐다 — 같은 테스트 명령(dart test -t integration, flutter test integration_test)이 로컬(E0)에서는 프로세스와 함께 사라지고, Staging(E1)에서는 팀이 공유하는 데이터를 바꾼다. Staging 을 실제로 사용했다면 cc-quality:qa-environment-hygiene 계약이 적용된다 (변이 전 원본 캡처 → 검증 → 자동 되돌림 + 재확인). 7.7-6 참고. 상세 가이드: serverpod-local-fullstack, serverpod-mcp-guide 스킬 참조.

// ══════════════════════════════════════════════════════════
// Step 7.7: 로컬 풀스택 통합 검증
// ══════════════════════════════════════════════════════════
// Serverpod 4 (tech preview):  ' serverpod start '   하나로 백엔드 서버 +
//   내장 Postgres + Flutter 앱을 함께 기동. Docker/docker-compose 불필요.
//   재컴파일·재시작 없이 서버·DB·웹·앱 전체 stateful 핫리로드.
//   (kobic 프로덕션은 3.x — 아래 3.x 폴백 경로 참조)

const needsLocalIntegration = hasBackendChanges || analysis.requiresBdd;

if (needsLocalIntegration  & &   !options.skipLocalIntegration) {
  // 7.7-1. 로컬 풀스택 기동 (백그라운드)
  //   Serverpod 4: 내장 Postgres 포함 → 완전 로컬, Docker 불필요
  //   핫리로드가 막히면  ' serverpod start '   터미널에서 R 키로 서버+앱 강제 재시작
  await Bash(`serverpod start`, { run_in_background: true });
  // → 서버 부팅 대기 (http://localhost:8080/ 헬스 확인)

  // ── 3.x / legacy 폴백 (로컬 불가 / nightly) ──────────────
  //   v3 경로는 그대로 보존:
  //     docker compose up -d        # Postgres 등 의존 서비스 기동
  //     dart run bin/main.dart      # 백엔드 서버 기동
  //   Staging 백엔드는 로컬 풀스택 불가 시 폴백으로 사용.

  let localIntegrationPass = true;

  // 7.7-2. [Backend 변경 시] 로컬 통합 테스트
  //   withServerpod 헬퍼 +  ' dart test -t integration '   (v3와 동일 API).
  //   Serverpod 4 내장 Postgres 덕분에 Docker 없이 완전 로컬 실행.
  if (hasBackendChanges) {
    const integrationOut = await Bash(`dart test -t integration 2 > & 1`);
    const integrationResult = parseTestOutput(integrationOut.stdout);
    if (integrationResult.totalFailed  >   0) localIntegrationPass = false;
    prBodyExtras.localIntegration = integrationResult;
  }

  // 7.7-3. [screen feature 시] 프론트를 로컬 백엔드 대상으로 통합 스모크
  //   앱은 dev flavor에서 로컬 서버(http://localhost:8080/)로 연결.
  //   Staging은 폴백. 앱의 기존 client/OpenApiService/flavor 설정에 맞춤
  //   — base-URL 하드코딩 금지.
  if (analysis.requiresBdd) {
    const smokeOut = await Bash(
      `flutter test integration_test --dart-define=flavor=dev 2 > & 1`
    );
    const smokeResult = parseTestOutput(smokeOut.stdout);
    if (smokeResult.totalFailed  >   0) localIntegrationPass = false;
    prBodyExtras.localSmoke = smokeResult;
  }

  // 7.7-4. 마이그레이션 / 서버 로그는 Serverpod MCP 활용
  //   Serverpod MCP 서버(번들)로 실행 중 로컬 서버에 연결 →
  //   DB 마이그레이션 생성·적용, 서버 로그 읽기, 앱 구동+스크린샷 검토.
  //   (tool ID는 preview라 변동 가능 — 능력 기준으로 사용. serverpod-mcp-guide 참조)

  // 7.7-5. 검증 후 로컬 풀스택 종료
  //    ' serverpod start '   프로세스 종료 (3.x 폴백 시 docker compose down)
  await stopLocalFullstack();

  // 7.7-6. ⚠️ 환경 정리 — 실제로 사용한 백엔드가 무엇이었는지로 갈린다 (플레이버가 아니라 백엔드)
  //   로컬 풀스택(E0)이었다면: 프로세스 종료가 곧 정리 — 추가 작업 없음.
  //   Staging 폴백(E1)이 실제로 쓰였다면: 검증이 공유 상태를 바꿨을 수 있다.
  //     → cc-quality:qa-environment-hygiene 계약 적용 (CBM 선캡처 + 등급별 자동 되돌림)
  //     → /cc-quality:cleanup 으로 TMR 대장의 pending 정리 + 되돌림 재확인
  //   이 분기를 무음으로 넘기면 안 된다: 같은 테스트 명령이 두 환경에서 똑같이 실행되므로
  //    " 로컬인 줄 알았는데 스테이징이었다 " 가 정확히 이 지점에서 발생한다.
  //   usedStagingFallback: 7.7-1 에서 로컬 풀스택 기동이 실패해 staging 으로 폴백했는지 여부.
  //   플레이버·설정값이 아니라 **실제로 어느 백엔드에 붙어 테스트가 돌았는지**로 판정한다.
  if (usedStagingFallback) {
    const cleanup = await Skill({ skill:  " cc-quality:qa-environment-hygiene "   });  // 또는 /cc-quality:cleanup
    //   → { reverted, leftIntentional, irreversible, escalated }
    //     = qa-environment-hygiene 의 terminal status 4종 그대로. 합산하지 않고 그대로 나른다 —
    //      " 남김 " 을  " 되돌림 완료 " 에 더하면 아무도 되돌리지 않은 것을 되돌렸다고 보고하게 된다.
    prBodyExtras.envCleanup = {
      env:  " E1-staging-fallback " ,
      reverted: cleanup.reverted,                 // 되돌리고 재확인까지 끝난 것만
      leftIntentional: cleanup.leftIntentional,   // C4/C6 — 의도적 잔존
      irreversible: cleanup.irreversible,         // C5 — 되돌릴 수 없음 (가장 중요한 항목)
      escalated: cleanup.escalated,               // 원본 미캡처·복원 충돌·되돌림 실패 (게이트는 아니다)
    };
  }

  // ⚠️ 게이트: 로컬 통합 검증 실패 시 Step 8 진행 차단
  //   flow 블록 `S7.7 == >   S7R on:integration.fail bound:2` — 되돌림은 enterRework() 소관.
  //   ⛔ --skip-local-integration 을 실패 회피용으로 여기서 자동 적용하지 않는다 (게이트가 사라진다)
  if (!localIntegrationPass) {
    console.error( " ❌ 로컬 풀스택 통합 검증 실패 — Step 7 재작업으로 되돌린다 " );
    return await enterRework( " S7R " );
  }
  console.log(`✅ Step 7.7 complete — 로컬 풀스택 통합 검증 PASS`);
} else if (options.skipLocalIntegration) {
  console.warn(`⚠️ 로컬 풀스택 통합 검증 스킵 (--skip-local-integration)`);
} else {
  console.log(`ℹ️ Step 7.7 skip — Backend 변경/screen feature 아님`);
}

Step 8.3: BDD Coverage Gate#

// ══════════════════════════════════════════════════════════
// Step 8.3: BDD Coverage Gate
// ══════════════════════════════════════════════════════════
// .feature 파일의 모든 Gherkin step이 구현된 step 함수를 갖고 있는지 검증
// step 함수에 assertion이 포함되어 있는지 검증

if (!options.skipBdd) {
  // 1. .feature 파일 스캔 — ⛔ feature 패키지 안(구 test/src/bdd/)이 아니라
  //    app/{app}/integration_test/features/ 하나에 전부 모여 있다(패키지별 격리 아님).
  //    scope(feature 패키지 이름)와 .feature 파일은 더 이상 디렉토리 포함 관계가 아니므로
  //    도메인 태그(@{feature_domain})나 파일명 컨벤션으로 매칭해야 한다 — 정확한 매칭 규칙은
  //    프로젝트의 실제 명명 컨벤션을 먼저 확인할 것(문서로 추측해 넣지 말 것).
  const integrationTestDir = findIntegrationTestDir(app); // app/{app}/integration_test/
  const featureDir = `${integrationTestDir}/features`;
  const stepDir = `${integrationTestDir}/step`;
  const featureFiles = (await Glob(`${featureDir}/**/*.feature`))
    .filter(f = >   matchesScope(f, scope)); // @{feature_domain} 태그 또는 파일명 컨벤션 매칭

  if (featureFiles.length === 0) {
    // FO-03: screen feature(BDD 필수)인데 .feature 가 0개면  " 스킵 " 이 아니라 FAIL — 검증 불가 ≠ 통과.
    if (analysis.requiresBdd) {
      throw new Error(`BDD Coverage Gate failed: screen feature 인데 .feature 파일이 0(검증 불가 ≠ 통과). /cc-flutter:bdd:generate ${scope} 로 작성`);
    }
    console.warn(`⚠️ .feature 파일 없음 (${featureDir})BDD 비대상 scope, gate not-applicable`);
  } else {
    // 2. Given/When/Then 스텝 파싱
    const allSteps = [];
    for (const file of featureFiles) {
      const content = await Read(file);
      const steps = content.match(/^\s*(Given|When|Then|And|But)\s+(.+)$/gm) || [];
      for (const step of steps) {
        const keyword = step.match(/^\s*(Given|When|Then|And|But)/)[1];
        const pattern = step
          .replace(/^\s*(Given|When|Then|And|But)\s+/,  ' ' )
          .replace(/#.*$/,  ' ' )  // 한글 주석 제거
          .trim();
        allSteps.push({ file, keyword, pattern });
      }
    }

    // 3. step/ 폴더에서 매칭 step 함수 검증 (stepDir 는 위에서 이미 app 레벨로 계산됨)
    const stepFiles = await Glob(`${stepDir}/**/*.dart`);
    const stepContents = {};
    for (const f of stepFiles) {
      stepContents[f] = await Read(f);
    }
    const allStepCode = Object.values(stepContents).join( ' \n ' );

    // step 패턴 → camelCase 함수명 변환 후 존재 여부 확인
    const missingSteps = allSteps.filter(step = >   {
      const funcName = stepPatternToFunctionName(step.pattern);
      return !allStepCode.includes(funcName);
    });

    // FO-03: BDD 필수 scope 에서 step 0개는  " 100% 커버리지 " 가 아니라 FAIL(검증할 게 없음 ≠ 통과).
    const stepCoverage = allSteps.length  >   0
      ? (allSteps.length - missingSteps.length) / allSteps.length
      : (analysis.requiresBdd ? 0.0 : 1.0);

    // 4. 각 step 함수 내 assertion 존재 확인
    const filesWithoutAssertions = stepFiles.filter(f = >   {
      const content = stepContents[f];
      // UnimplementedError는 stub → assertion 미구현으로 간주
      if (content.includes( ' UnimplementedError ' )) return true;
      // expect, verify, expectVisible, expectTextVisible 등 확인
      return !content.match(/expect\(|verify\(|expectVisible|expectTextVisible|expectAsync/);
    });

    // 5. Gate 판정 — 실패는 flow 블록 `S8.3 == >   S6R on:coverage < 1 bound:2` 로 되돌아간다
    //    (되돌림 대상: S6,S8.3,S8.5,S8.7,S9.0,S11.9 — enterRework() 가 pending 으로 되돌린다)
    if (stepCoverage  <   1.0) {
      console.error(`❌ BDD step coverage: ${(stepCoverage * 100).toFixed(1)}% (required: 100%)`);
      missingSteps.forEach(s = > 
         console.error(`   Missing: ${s.keyword} ${s.pattern} (${s.file})`)
      );
      console.error(`\n   💡 step 함수 생성: /cc-flutter:bdd:generate ${scope} --only-steps true`);
      return await enterRework( " S6R " );
    }

    if (filesWithoutAssertions.length  >   0) {
      console.error(`❌ Assertion 없는 step 파일 ${filesWithoutAssertions.length}:`);
      filesWithoutAssertions.forEach(f = >   console.error(`   ${f}`));
      console.error(`\n   💡 UnimplementedError stub을 실제 assertion으로 교체하세요`);
      return await enterRework( " S6R " );
    }

    console.log(`✅ Step 8.3 complete — BDD coverage 100%, all ${allSteps.length} steps have assertions`);
  }
} else {
  // --skip-bdd — flow 블록 `S8.3 ~~ >   S8.5 record:ASK+prBodyExtras.bddSkipped`
  // ⚠️ 예전에는 console.warn 한 줄이 전부였다. 콘솔은 내구 기록이 아니다 — 세션이 끝나면
  //     " BDD 커버리지 게이트가 이 PR 에서 아예 실행되지 않았다 " 는 사실이 남는 곳이 없다.
  //    --skip-tests 와 동일한 처리(ASK 확인 + PR body 영구 기록)로 통일한다.
  // 무인(--unattended) 계약 ⑦: 플래그로 이미 의도를 밝혔으므로 재확인 없이 스킵 확인을 자동 채택한다.
  const confirmBdd = options.unattended
    ?  " 스킵 확인 " 
     : await AskUserQuestion({
        questions: [{
          header:  " BDD 커버리지 게이트 스킵 확인 " ,
          question: analysis.requiresBdd
            ?  " screen feature 인데 BDD Coverage Gate를 스킵하면 시나리오 미구현이 그대로 머지됩니다. 계속하시겠습니까? " 
             :  " BDD Coverage Gate를 스킵합니다. 계속하시겠습니까? " ,
          options: [
            { label:  " 스킵 확인 " , description:  " BDD 검증 없이 진행 — PR body에 영구 기록 "   },
            { label:  " 게이트 실행 " , description:  " BDD Coverage Gate를 실행합니다 "   },
          ],
        }],
      });

  if (confirmBdd ===  " 게이트 실행 " ) {
    options.skipBdd = false;
    return await runFromStep( " 8.3 " );     // 같은 단계를 게이트 실행 경로로 재진입
  }

  prBodyExtras.bddSkipped = {
    reason:  " --skip-bdd (user confirmed) " ,
    requiresBdd: analysis.requiresBdd,   // true 면 screen feature 에서 게이트를 끈 것이다 — 기록이 특히 중요
  };
  console.warn(`⚠️ BDD Coverage Gate skipped (--skip-bdd)PR body에 영구 기록`);
}

Step 8.5: Pre-push Verification + DCM Format/Lint 0-Issue Gate + DCM Code Quality Improvement#

♻️ Per-Push Verification Gate (재사용): 아래 검증 시퀀스를 runPrePushGate()라 부른다. 최초 push(Step 9 직전) 1회로 끝나지 않고, PR 브랜치로 push가 발생할 때마다 — Step 11 추가 리뷰 피드백 반영, changes requested 재작업, CI 실패 수정, conflict 해소 후 --force-with-lease re-push 포함 — push 직전에 동일하게 재실행한다. 게이트 실패 시 해당 push는 차단된다 (LEFTHOOK=0 우회 금지).

루프 계약 L-8.5 (필드 정의: SoT §2 · budget: 숫자 산정: ../skills/job-timeout-budget/SKILL.md)

inv:      iteration 종료 시 작업 트리 commit-clean · auto-fix 커밋은 원자적
          `// ignore:` 삽입이나 `LEFTHOOK=0` 로 0건을 달성하는 것 금지
prog:     residual = dartAnalyzeIssues + dcmErrorIssues, 매 attempt 강한 감소
          측정은 **매번 재실측**한다(루프 진입 전 스냅샷 재순회 금지 — 감소 관측 자체가 불가능해진다)
          no-prog: 잔여가 줄지 않으면 남은 예산을 같은 수정에 쓰지 않고 즉시 차단한다
term:     dartAnalyzeIssues === 0  & &   dcmErrorIssues === 0 (재실측으로 확인한 0)
budget:   inner 2 attempts (Phase 1) / outer = push 당 1회 재진입 (push 마다 Phase 1~3 전체를 처음부터)
exhaust:  throwPR 생성/push 차단 ( " 경고 후 계속 "   금지 — 이 루프에는 degradable 선언이 없다)
resume:   `melos run analyze` · `melos run dcm:analyze` 재실측으로 위치 판정
          (대화 카운터는 `/clear` 를 못 넘으므로 예산이 아니다)
log:       " 8.5 #2: dart=5→2 dcm=1→0 "   · 0 은 재실측으로 확인한 0 만 출력한다 (없음 ≠ 확인함)
// ══════════════════════════════════════════════════════════
// Phase 0: 측정 헬퍼 — FO-10(pipe-masked exit code) 을 두 도구에 모두 적용
// ══════════════════════════════════════════════════════════
// SoT §3.2 의 FO-10: `cmd | grep` 계열은 뒤쪽 명령의 exit code 만 남겨 **도구가 죽은 것을  " 이슈 0건 " 으로**
// 가린다. 아래 두 헬퍼는 exit code 를 먼저 보고,  " 비정상 종료 + 파싱 0건 "   을 명시 FAIL 로 둔다
// (검증 불가 ≠ 통과). Step 8 Phase 3 의 `dart analyze` 판정과 같은 규칙·같은 `{exitCode, stdout}` 형태다.

async function measureAnalyze(label) {
  const out = await Bash(`melos run analyze 2 > & 1`);
  if (out.exitCode === 127 || out.exitCode === null) {
    throw new Error(`melos run analyze 실행 실패(도구/스크립트 부재, ${label}) — 검증 불가 = 차단 (Step 0 capability 맵 확인)`);
  }
  const lines = out.stdout.split( ' \n ' )
    .filter(line = >   line.match(/\s+(info|warning|error)\s+[•·-]/));
  if (out.exitCode !== 0  & &   lines.length === 0) {
    // 크래시·사용법 오류·melos 스크립트 부재 → 출력이 비어  " 0건 " 처럼 보인다. 접지 않는다
    throw new Error(`melos run analyze 비정상 종료(exit ${out.exitCode}, ${label}) + 파싱 가능한 이슈 0건 — 판정 불가 = 차단`);
  }
  return lines;
}

async function measureDcm(label) {
  const out = await Bash(`melos run dcm:analyze 2 > & 1`);
  if (out.exitCode === 127 || out.exitCode === null) {
    throw new Error(`melos run dcm:analyze 실행 실패(도구 부재, ${label}) — 검증 불가 = 차단`);
  }
  const issues = parseDcmOutput(out.stdout);
  if (out.exitCode !== 0  & &   issues.length === 0) {
    // parseDcmOutput 은 빈 출력에서 `[]` 를 돌려준다 — `[]` 를  " 이슈 없음 " 으로 읽으면 empty-set pass 다
    throw new Error(`melos run dcm:analyze 비정상 종료(exit ${out.exitCode}, ${label}) + 파싱 0건 — 판정 불가 = 차단`);
  }
  return issues;
}

// ══════════════════════════════════════════════════════════
// Phase 1: DCM 포맷팅 + Dart 린트 자동 수정 (0건 목표)
// ══════════════════════════════════════════════════════════
// ⚠️ 포매터: dcm format으로 통일 (dart format 사용 금지)
//    melos run format → 내부적으로 dcm format lib 실행

// 1-1. DCM 포맷팅 적용
await Bash(`melos run format`);  // dcm format (dart format 대체)

// 1-2. dart fix --apply 로 자동 수정 가능한 린트 이슈 일괄 수정
//      (deprecated API 대체, unnecessary_import 제거, prefer_const 적용 등)
await Bash(`dart fix --apply`);

// 1-3. 포맷팅 + fix 변경 커밋 (변경 있을 때만)
const hasFixChanges = await Bash(`git diff --name-only`);
if (hasFixChanges.trim()) {
  await Bash(`
    git add .
    git commit -m  " style: 🎨 dart fix + format 자동 수정

    - dart fix --apply 린트 자동 수정
    - dcm format 코드 스타일 정리

    Co-Authored-By: Claude  < noreply@anthropic.com > " 
   `);
}

// 1-4. Dart SDK analyze 실행 및 결과 파싱 (dart analyze — DCM과 별도)
//   ⚠️ `let` 이다 — 매 attempt 의 작업 목록이 되어야 하므로 재실측 결과로 갱신된다
let analyzeLines = await measureAnalyze( " 1-4 초기 측정 " );

// 1-5. 린트 이슈가 남아있으면 수동 수정 시도 (L-8.5 budget: inner 2 attempts)
if (analyzeLines.length  >   0) {
  console.warn(`⚠️ dart analyze 이슈 ${analyzeLines.length}건 발견`);

  for (let attempt = 0; attempt  <   2; attempt++) {
    const before = analyzeLines.length;

    // ⚠️ 순회 대상은 **직전 재실측 결과**다. 예전에는 루프 진입 전 스냅샷(1-4 의 `analyzeLines`)을
    //    2회 모두 다시 순회하고 `recheckLines` 는 종료 판정에만 쓰고 버렸다 — 이미 고친 이슈를
    //    다시 고치려 하고, `prog:`(강한 감소)를 관측할 방법이 없었다.
    for (const issue of analyzeLines) {
      // 파일 경로, 라인, 규칙명을 파싱하여 해당 코드를 수정
      await fixAnalyzeIssue(issue);
    }

    // 수정 후 재포맷 + 재분석 → 그 결과가 다음 attempt 의 작업 목록이 된다
    await Bash(`dart fix --apply`);
    await Bash(`melos run format`);  // dcm format
    analyzeLines = await measureAnalyze(`1-5 attempt ${attempt + 1}`);
    console.log(`8.5 #${attempt + 1}: dart=${before}→${analyzeLines.length}`);   // log: iteration 당 한 줄

    if (analyzeLines.length === 0) {
      console.log(`✅ dart analyze 이슈 0건 — 린트 게이트 통과 (재실측으로 확인한 0)`);
      break;
    }

    // prog: 강한 감소 필수. 줄지 않으면 남은 예산을 같은 수정에 쓰지 않는다 (no-prog:)
    if (analyzeLines.length  > = before) {
      analyzeLines.forEach(l = >   console.error(`   ${l.trim()}`));
      throw new Error(
        `Dart lint gate blocked: 이슈가 줄지 않음(${before}→${analyzeLines.length}) — ` +
        `자동 수정을 반복하지 않고 중단한다. 수동 수정 후 재실행하라`
      );
    }

    if (attempt === 1) {
      // 2회 시도(= budget 소진) 후에도 이슈가 남아있으면 상세 출력 + PR 차단
      console.error(`❌ dart analyze 이슈 ${analyzeLines.length}건 미해결 — PR 생성 차단`);
      analyzeLines.forEach(l = >   console.error(`   ${l.trim()}`));
      throw new Error(`Dart lint gate blocked: ${analyzeLines.length} issues remaining`);
    }
  }

  // 수정된 파일 커밋
  const hasLintFixes = await Bash(`git diff --name-only`);
  if (hasLintFixes.trim()) {
    await Bash(`
      git add .
      git commit -m  " fix: 🐛 flutter analyze 린트 이슈 수정

      - error/warning/info 이슈 0건 달성
      - 정적 분석 클린 상태 확보

      Co-Authored-By: Claude  < noreply@anthropic.com > " 
     `);
  }
} else {
  console.log(`✅ flutter analyze 이슈 0건 — 린트 게이트 통과`);
}

// ══════════════════════════════════════════════════════════
// Phase 2: DCM 코드 품질 개선 (dcm-code-quality 스킬 활용)
// ══════════════════════════════════════════════════════════

// 2-1. DCM 린트 + 메트릭 검사 실행 (FO-10 판정 포함 — Phase 0 의 measureDcm)
const dcmBefore = await measureDcm( " 2-1 사전 측정 " );

// 2-2. 자동 수정 가능 항목 수정
await Bash(`melos run dcm:fix`);

// 2-3. 미사용 코드 검사
await Bash(`melos run dcm:unused-code`);

// 2-4. 수정 후 재검증 → 이 값이 곧 잔여 이슈 목록이다 (2-6 에서 원시 출력을 다시 파싱하지 않는다)
const remainingIssues = await measureDcm( " 2-4 재검증 " );
console.log(`8.5 phase2: dcm=${dcmBefore.length}→${remainingIssues.length}`);

// 2-5. 수정된 파일 커밋
const hasDcmChanges = await Bash(`git diff --name-only`);
if (hasDcmChanges.trim()) {
  await Bash(`
    git add .
    git commit -m  " refactor: ♻️ DCM 코드 품질 자동 수정

    - dcm fix . 자동 수정 적용
    - 미사용 코드 정리

    Co-Authored-By: Claude  < noreply@anthropic.com > " 
   `);
}

// 2-6. 잔여 이슈 요약 → PR body에 포함 (2-4 의 측정값 그대로 — 재파싱 금지)
if (remainingIssues.length  >   0) {
  prBodyExtras.dcmFindings = remainingIssues;
  console.log(`ℹ️ DCM 잔여 이슈 ${remainingIssues.length}건 → PR body에 포함`);
}

// 2-7. error 심각도 이슈가 남아있으면 차단
const errorIssues = remainingIssues.filter(i = >   i.severity ===  " error " );
if (errorIssues.length  >   0) {
  console.error(`❌ DCM error 이슈 ${errorIssues.length}건 미해결 — PR 생성 차단`);
  throw new Error( " DCM quality gate blocked " );
}

// ══════════════════════════════════════════════════════════
// Phase 3: 최종 린트 검증 (DCM 수정이 새로운 린트 이슈를 만들지 않았는지 확인)
// ⚠️ dart analyze + dcm analyze 모두 실행 (검사 범위가 다름)
//    - dart analyze: Dart SDK 기본 린트 + 타입 체크 + 언어 수준 오류
//    - dcm analyze: DCM 전용 린트 규칙 + 메트릭 + 안티패턴
// ══════════════════════════════════════════════════════════

// 3-1. Dart SDK 최종 검증 (FO-10 판정 포함 — 도구가 죽어 출력이 빈 것을  " 0건 " 으로 읽지 않는다)
const finalDartIssues = await measureAnalyze( " 3-1 최종 검증 " );

if (finalDartIssues.length  >   0) {
  console.error(`❌ 최종 검증 실패: dart analyze 이슈 ${finalDartIssues.length}건`);
  finalDartIssues.forEach(l = >   console.error(`   ${l.trim()}`));
  throw new Error(`Final dart lint gate blocked: ${finalDartIssues.length} issues remaining`);
}

// 3-2. DCM 최종 검증 (FO-10 판정 포함)
const finalDcmErrors = (await measureDcm( " 3-2 최종 검증 " )).filter(i = >   i.severity ===  " error " );

if (finalDcmErrors.length  >   0) {
  console.error(`❌ 최종 검증 실패: dcm analyze error ${finalDcmErrors.length}건`);
  throw new Error(`Final DCM lint gate blocked: ${finalDcmErrors.length} error issues remaining`);
}

console.log(`✅ Step 8.5 complete — dart analyze 0, dcm analyze error 0건`);

// ♻️ 위 Phase 1~3 전체가 runPrePushGate() — Step 11 등 이후의 모든 push 직전에 재호출된다.

Step 8.7: Code Review Gate (gstack pattern)#

Agent Teams Parallel Mode (auto-detected)

When Agent Teams are available, 8 review categories are distributed across 3 teams for parallel execution. (Agent Teams availability is already checked before Step 7 start)

Agent Teams 사용 가능 시:
  ├─ Teammate 1: 보안 + 성능 (Critical Focus)
  │   ├─ 보안 취약점 (injection, XSS, 인증 우회)
  │   ├─ 데이터 안전성 (null safety, race condition)
  │   └─ 성능 (N+1, 메모리 누수, 불필요한 rebuild)
  │
  ├─ Teammate 2: 아키텍처 + 상태관리
  │   ├─ Clean Architecture 준수 (레이어 의존성)
  │   ├─ BLoC 패턴 (상태 전이, 이벤트 처리)
  │   └─ DI / Repository 인터페이스 준수
  │
  └─ Teammate 3: 가독성 + i18n + 접근성 (Informational)
      ├─ 코드 가독성 (네이밍, 구조, 복잡도)
      ├─ i18n (하드코딩 문자열, 번역 키)
      └─ 접근성 (semanticLabel, 터치 타겟)Lead가 결과 병합:
    - Critical 이슈 (Teammate 1, 2)Gate 차단 판정
    - Informational (Teammate 3)PR body에 포함
    - Critical 발견 시 자동 수정 시도 (최대 2)

Fallback (순차):
  8개 카테고리 순차 검사

Sequential Execution Flow (default / Fallback)

루프 계약 L-8.7 (필드 정의: SoT §2)

inv:      게이트 판정은 **마지막 recheck 의 verdict** 로만 한다. 루프 진입 전 스냅샷을 판정에 쓰지 않는다
prog:     criticalCount, 매 attempt 강한 감소 (매 라운드 verdict 에서 다시 도출한 값)
          no-prog: 같은 개수가 두 번 나오면 자동 수정을 반복하지 않고 즉시 차단한다
term:     criticalCount === 0
budget:   2 attempts (autoFixCriticalIssues)
exhaust:  throwPR 생성 차단 ( " 경고 후 계속 "   금지). 우회는 `--skip-review` 를 사람이 명시할 때만
resume:   `/cc-quality:review --quick --gate-mode` 재실행 결과(verdict)
log:       " 8.7 #2: critical=3→1 "   + 남은 Critical 의 rule/file:line
if (!options.skipReview) {
  // Pass 1: Critical 이슈 검사
  const reviewResult = await Skill({
    skill:  " quality:review " ,
    args:  " --quick --gate-mode " ,
  });

  // ⚠️ `let` — 매 라운드 verdict 에서 다시 도출한다. 진입 전 스냅샷을 계속 쓰면
  //    이미 고친 이슈를 또 고치려 하고, 감소(prog:)를 관측할 수 없다.
  let criticalIssues = reviewResult.issues.filter(i = >   i.severity ===  " critical " );
  let informationalIssues = reviewResult.issues.filter(i = >   i.severity !==  " critical " );

  if (criticalIssues.length  >   0) {
    // ⚠️ finalCheck 를 **루프 밖에서 선언하고 매 라운드 재대입**한다.
    //    예전에는 아무 곳에서도 대입되지 않은 `finalCheck` 를 게이트 판정에 썼다 —
    //    ReferenceError 로 죽거나(운이 좋은 경우), 삼켜지면 Critical 이 남은 채 통과했다.
    //    criticalCount 도 스킬 반환 필드에 의존하지 않고 issues 에서 직접 센다.
    let finalCheck = { criticalCount: criticalIssues.length, issues: reviewResult.issues };

    for (let attempt = 0; attempt  <   2; attempt++) {          // L-8.7 budget: 2 attempts
      const before = finalCheck.criticalCount;
      await autoFixCriticalIssues(criticalIssues);

      const recheck = await Skill({ skill:  " quality:review " , args:  " --quick --gate-mode "   });
      criticalIssues = recheck.issues.filter(i = >   i.severity ===  " critical " );
      informationalIssues = recheck.issues.filter(i = >   i.severity !==  " critical " );
      finalCheck = { criticalCount: criticalIssues.length, issues: recheck.issues };
      console.log(`8.7 #${attempt + 1}: critical=${before}→${finalCheck.criticalCount}`);

      if (finalCheck.criticalCount === 0) break;

      // prog: 강한 감소 필수 (no-prog: 남은 예산을 같은 자동 수정에 쓰지 않는다)
      if (finalCheck.criticalCount  > = before) {
        criticalIssues.forEach(i = >   console.error(`   [${i.rule ??  " critical " }] ${i.message} (${i.file}:${i.line})`));
        throw new Error(
          `Code Review Gate blocked: Critical 이 줄지 않음(${before}→${finalCheck.criticalCount}) — ` +
          `자동 수정 중단, 수동 수정 후 재시도`
        );
      }
    }

    // 재검증 후에도 Critical 남아있으면 차단 (finalCheck = 마지막 recheck 의 verdict)
    if (finalCheck.criticalCount  >   0) {
      console.error(`❌ Code Review Gate 실패: ${finalCheck.criticalCount}건의 Critical 이슈`);
      criticalIssues.forEach(i = >   console.error(`   [${i.rule ??  " critical " }] ${i.message} (${i.file}:${i.line})`));
      console.error( "     --skip-review 옵션으로 우회하거나, 수동 수정 후 재시도하세요 " );
      throw new Error( " Code Review Gate blocked " );
    }
  }

  // Pass 2: Informational 항목은 PR body에 포함할 데이터로 저장 (마지막 라운드 기준)
  prBodyExtras.reviewFindings = informationalIssues;
} else {
  // flow 블록 `S8.7 ~~ >   S9.0 record:prBodyExtras.reviewSkipped` — 콘솔만으로 끝내지 않는다
  prBodyExtras.reviewSkipped = { reason:  " --skip-review "   };
  console.warn( " ⚠️ Code Review Gate를 건너뜁니다 (--skip-review) — PR body에 영구 기록 " );
  console.warn( "     프로덕션 배포 전 반드시 /cc-quality:review 를 실행하세요 " );
}

Step 9-10: PR Creation & Move to Review/QA (hierarchical merge)#

Step 9.0: 중복 착수 재확인 ⚠️ (Step 0.5 가 원리적으로 못 잡는 구간)

Step 0.5 는 선행 작업만 잡는다. 내가 구현하는 동안 시작된 세션은 착수 전 확인으로 잡을 수 없으므로, PR 생성 직전과 머지 직전에 한 번 더 확인한다.

# --state all 이 핵심 — 경쟁 세션이 이미 머지했으면 --state open 으로는 안 보인다
# baseRefName 도 함께 받는다 — 계층 작업의 머지는 development 가 아니라 epic/*·project/* 로 간다
gh pr list --repo  " $REPO "   --state all --search  " $ISSUE "   \
  --json number,title,state,mergedAt,baseRefName
# 외부 트래커가 원본이면 그 키로도 검색 (이슈 번호가 갈렸을 수 있다)
gh pr list --repo  " $REPO "   --state all --search  " $JIRA_KEY "   \
  --json number,title,state,mergedAt,baseRefName
# 원본 티켓 코멘트 — 다른 세션이 착수·완료를 남기는 곳 (읽기 전용)
#   mcp__mcp-atlassian__jira_get_issue(issue_key:  " $JIRA_KEY " , ...)
상황행동
경쟁 PR 이 열려 있음머지하지 말고 중단 → 아래 "충돌을 발견했을 때" 절차
경쟁 PR 이 이미 머지됨내 변경과 겹치는 파일·함수를 확인한다. 겹치지 않으면 상보적일 수 있으니 근거를 PR 에 남기고 진행. 겹치면 머지 금지 — 한쪽이 조용히 되돌려진다
머지 후에 발견되돌린 게 없는지 즉시 검증: git log --oneline <경쟁PR커밋>..origin/development -- <겹치는파일> 로 순서 확인, git merge-base --is-ancestor A B 로 선후 확정

state=MERGEDbaseRefName 이 무엇이든 끝난 작업이다. 계층 작업에서 머지된 story PR 은 epic 브랜치에만 있으므로, development 에 그 코드가 없다는 사실은 아무 신호도 아니다 — 여기서 "아직 안 됐다"로 읽으면 이미 완주된 이슈를 한 번 더 완주한다(실사고: 폐기한 쪽이 5커밋 44파일). 같은 이유로 이 판정에 git log origin/development 이나 git merge-base --is-ancestor <머지커밋> origin/development 을 쓰지 않는다.

배경: 착수 전 확인이 정상 통과했는데도 세 세션이 같은 티켓에 얽힌 사례가 있다. 두 번째 세션은 머지 후 원본 티켓 코멘트를 읽다가 비로소 중복을 발견했다. 이번엔 두 수정이 상보적이어서 공존했지만, 실제로 한 세션의 보정을 다른 세션이 제거한 일도 같은 티켓에서 일어났다. --state all 재조회면 PR 생성 전에 잡힌다.

// PR base 브랜치 결정 (계층 브랜치 전략)
// baseBranch는 Step 4에서 결정된 값 사용
const prBaseBranch = baseBranch; // epic/xxx, story/xxx, task/xxx, feature/xxx(단독 이슈), 또는 development

// ⚠️ PR 생성보다 먼저 발행 — 이슈 생성(Step 2)과 동일한 순서.
// 프로토콜: skills/pr-work-artifact/SKILL.md · 메커니즘 SoT: rules/artifact-publishing.md
let workArtifact = { url: null, fallback: false };
try {
  workArtifact.url = await Skill({ skill:  " cc-dev:pr-work-artifact "   });
  //   페이지 구성·발행·링크 블록 형식은 전부 스킬/SoT 소관 (여기서 복제하지 않는다)
} catch (e) {
  console.log( " ℹ️ Artifact 미사용 — 전량 마크다운으로 진행 (사유:  "   + e.message +  " ) " );
  workArtifact.fallback = true;
}
prBodyExtras.workArtifactUrl = workArtifact.url;

// PR 생성 — 작업내역 링크(또는 폴백 요약)를 본문에 담은 채로 생성한다 (댓글 아님)
await Bash(`
  gh pr create --base  " ${prBaseBranch} "   \
    --title  " ${gitmoji} ${analysis.scope}: ${workContent} "   \
    --body  " $(cat  < < ' EOF ' 
 ## Summary
- ${workContent}

## Related Issue
Closes #${issue.number}
${parentIssue ? `Parent: #${parentIssue.number}` :  ' ' }

## Test Plan
${prBodyExtras.testPlanResults?.map(t = >   {
  const check = t.testPass === true ?  ' x '   : (t.testPass === false ?  '   '   :  ' ~ ' );
  const status = t.analyzePass
    ? (t.testPass === true ?  ' ✅ '   : (t.testPass === false ?  ' ❌ '   :  ' ⚠️ 분석만 통과 ' ))
    :  ' ❌ 분석 실패 ' ;
  return `- [${check}] ${t.item} (${status})`;
}).join( ' \n ' ) ?? `
- [ ] UseCase 단위 tests passed
- [ ] BLoC 단위 tests passed
${hasBackendChanges ?  ' - [ ] 백엔드 엔드포인트 통합 tests passed '   :  ' ' }
${analysis.requiresBdd ?  ' - [ ] BDD 시나리오 통과 '   :  ' ' }
`}
${prBodyExtras.testSummary ? `
 >   테스트 결과: ${prBodyExtras.testSummary.totalPassed}건 통과` +
  (prBodyExtras.testSummary.totalFailed  >   0 ? `, ${prBodyExtras.testSummary.totalFailed}건 실패` :  ' ' ) +
  (prBodyExtras.testSummary.analyzeErrors  >   0 ? ` | 정적 분석 에러: ${prBodyExtras.testSummary.analyzeErrors}건` :  '   | 정적 분석: ✅ ' )
:  ' ' }
${prBodyExtras.envCleanup ? `
## Verification Environment
⚠️ 공유 스테이징(E1) 폴백 사용 — 검증이 공유 상태를 변경했을 수 있음
- TMR: 되돌림 재확인 ${prBodyExtras.envCleanup.reverted}건 · 의도적 잔존 ${prBodyExtras.envCleanup.leftIntentional}건 | 대장: \`.claude/qa/${analysis.scope}/mutations.jsonl\`
${prBodyExtras.envCleanup.irreversible  >   0 ? `- 🔴 되돌릴 수 없는 변경 ${prBodyExtras.envCleanup.irreversible}건 (C5 — 발송·결제 등). 대장에서 대상 확인 후 소유자 통지 필요` :  ' ' }
${prBodyExtras.envCleanup.escalated  >   0 ? `- ⚠️ 미해결 ${prBodyExtras.envCleanup.escalated}건 — 원본 미캡처·복원 충돌·되돌림 실패 (\`/cc-quality:cleanup\` 재실행 또는 수동 판단 필요)` :  ' ' }
` :  ' ' }
${prBodyExtras.designDecisions?.length  >   0 ? `
## Design Decisions
${prBodyExtras.designDecisions.map(d = > 
   `- **${d.id}** ${d.decision} — 근거 ${d.rung}: ${d.evidence} | 되돌리기: ${d.reversibility}` +
  (d.confidence ===  " low "   ? ` | ⚠️ 재검토` :  ' ' )
).join( ' \n ' )}

 >   디자인 판단은 승인 게이트 없이 근거 사다리로 확정됩니다 (`cc-designer:design-decision`). 누적 로그: \`.claude/docs/${analysis.scope}/design-decisions.md\`
` :  ' ' }
${prBodyExtras.designVerification ? `
## Design Verification
${prBodyExtras.designVerification.status ===  " pass "   ? `✅ PASStolerance ${prBodyExtras.designVerification.tolerance}, target ${prBodyExtras.designVerification.target}` :  ' ' }
${prBodyExtras.designVerification.status ===  " skipped "   ? `⚠️ 디자인 검증 미실행 (${prBodyExtras.designVerification.reason}) — 디자이너 수동 확인 필요` :  ' ' }
${prBodyExtras.designVerification.status ===  " fail-acknowledged "   ? `⚠️ 잔존 불일치 ${prBodyExtras.designVerification.diffCount}건 — 사용자 확인 후 진행 (수동 검토 필요)` :  ' ' }
${prBodyExtras.designVerification.artifactPath ? `- Artifacts: \`${prBodyExtras.designVerification.artifactPath}\`` :  ' ' }
` :  ' ' }
${workArtifact.url ? `
## 📄 작업내역

**${workArtifact.url}**

↳ 변경 요약 · 리뷰 가이드( " 여기부터 보세요 " ) · 설계 판단과 대안 · 테스트/검증 결과 · 범위 밖

 >   🔒 이 아티팩트 링크는 발행 시점에 **비공개**입니다. 팀이 열어야 하면 claude.ai 아티팩트 페이지의 공유 메뉴에서 직접 공유를 켜주세요. 켠 뒤 접근 범위는 claude.ai 조직(Cocode Inc.) 단위입니다.

⏳ CI 진행 중 — 결과가 나오면 같은 링크의 내용이 갱신됩니다 (링크 자체는 바뀌지 않습니다).
` : (workArtifact.fallback ? `
## 📄 작업내역 (마크다운 폴백)
${prBodyExtras.workLogSummary ??  ' - (아티팩트 도구 부재 — 요약 생략됨. 수동 보강 권장) ' }
` :  ' ' )}
${(prBodyExtras.testsSkipped || prBodyExtras.bddSkipped || prBodyExtras.reviewSkipped) ? `
## Skipped Gates ⚠️
${prBodyExtras.testsSkipped ? `- ❌ **테스트 미검증** — \`--skip-tests\` (${prBodyExtras.testsSkipped.reason}). 이 PR 의 코드는 unit/widget/integration 으로 검증되지 않았다` :  ' ' }
${prBodyExtras.bddSkipped ? `- ❌ **BDD Coverage Gate 미실행** — \`--skip-bdd\`${prBodyExtras.bddSkipped.requiresBdd ?  '   · ⚠️ screen feature 인데 껐다 (시나리오 미구현이 그대로 실릴 수 있음) '   :  ' ' }` :  ' ' }
${prBodyExtras.reviewSkipped ? `- ❌ **Code Review Gate 미실행** — \`--skip-review\`. 머지 전 \`/cc-quality:review\` 를 돌릴 것` :  ' ' }

 >   스킵은 콘솔이 아니라 여기 남는다 —  " 왜 이게 통과됐지 " 를 나중에 이 줄로 추적한다.
` :  ' ' }
${prBodyExtras.dcmFindings?.length  >   0 ? `
## DCM Quality Report
${prBodyExtras.dcmFindings.map(f = >   `- **[${f.severity}]** ${f.rule}: ${f.message} (${f.file}:${f.line})`).join( ' \n ' )}
` :  ' ' }
${prBodyExtras.reviewFindings?.length  >   0 ? `
## Review Findings (Informational)
${prBodyExtras.reviewFindings.map(f = >   `- **[${f.category}]** ${f.message} (${f.file}:${f.line})`).join( ' \n ' )}
` :  ' ' }
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
) " 
 `);

// Review/QA 이동
await mcp__zenhub__moveIssueToPipeline({
  issueId: issue.id,
  pipelineId: reviewQaPipelineId,
});

스택 모드 분기 (선택형 — 기본값은 위 gh pr create 그대로)

모드PR 생성 명령
기본(수동 계층)gh pr create --base "${prBaseBranch}" ... — 위 코드 그대로
스택gh stack submit --auto (push + PR 생성/갱신 + 스택 갱신을 한 번에). --auto 로 만들어진 신규 PR 은 draft 다 — 바로 ready for review 로 올리려면 --open 을 함께 준다

⚠️ 스택 모드에서도 본문 게이트(작업내역 아티팩트 발행 → 본문 링크, Closes #, gate 0 열린-자식 0건)는 그대로다. --auto 는 제목·본문 에디터를 생략하므로, 본문 요구사항을 만족시키려면 --auto 로 만든 뒤 gh pr edit 으로 본문을 채우거나 에디터 모드로 제출한다. 상세는 stacked-prs.

Step 10.5: CI 대기 ∥ 작업내역 아티팩트 CI 상태 갱신#

프로토콜 SoT: skills/pr-work-artifact/SKILL.md · 발행 메커니즘 SoT: rules/artifact-publishing.md 여기서는 언제 부르고 결과를 어디로 넘기는지만 정한다. 절차를 복제하지 않는다.

작업내역 자체는 Step 9(PR 생성)에서 이미 발행되어 PR 본문에 링크로 들어가 있다 — 이 Step은 그 페이지의 "CI 상태" 섹션을 최종 결과로 같은 URL에 갱신할 뿐이다. 새 댓글도, 새 링크도 만들지 않는다.

루프 계약 L-10.5 / L-10.6 (필드 정의: SoT §2 · budget: 숫자 산정: ../skills/job-timeout-budget/SKILL.md · exhaust: 사다리: ../agents/sequential-workflow.md)

L-10.5 (CI 대기 ∥ 갱신)
inv:      CI watch 가 먼저 기동돼 있다(순서 고정) · 갱신은 CI 판정에 영향을 주지 않는다(비차단)
prog:     회수된 check 수 / 전체 check 수, join 까지 단조 증가
          no-prog: watch 가 진행 없이 멈추면 예산을 더 쓰지 않고 timeout 분기로 간다
term:     ciResult ∈ {pass, fail, no-checks} 로 확정됨
budget:   join timeout 30m (1. 실측 최대 × 2 규칙으로 산정 — 형제 CI 잡과 정합)
exhaust:  ciResult =  " unknown "**차단**. 판정 불가는 통과가 아니다. PR 은 그대로 두고 재실행 안내
resume:   `gh pr checks {pr} --json name,state,bucket` 재조회 (백그라운드 핸들은 /clear 를 못 넘는다)
log:       " 10.5: checks=12/12 → pass "   · 아티팩트 URL 또는 폴백 사유

L-10.6 (CI 실패 수정 → re-push)S10.5·S11·S12g 세 진입점이 공유하는 하나의 순환
inv:      re-push 직전 runPrePushGate() 재실행 · 아티팩트는 같은 URL 갱신(새 링크 금지, 댓글 없음)
prog:     failingChecks = 실패 check **이름의 집합**, 매 라운드 진부분집합으로 축소(|set| 강한 감소)
          no-prog: 같은 집합이 두 번 나오면 예산을 더 쓰지 않고 Rung 2(`/cc-dev:unstuck`)로 종류를 바꾼다
term:     failingChecks.size === 0 (= ciResult  " pass " )
budget:   3 rounds (`ciFixRound`, 세 진입점 합산 — Step 0.1 에서 사이클 전역으로 초기화)
exhaust:  blockIssue(issue,  ' ci_exhausted ' ) 후 중단 — holding 이동 + 사유 코멘트 + 점유 해제.
          머지 금지이며 `--merge=pre-authorized` 도 이것을 덮지 못한다
resume:   `gh pr checks {pr} --json name,state,bucket` (라운드 수는 PR 커밋 이력의 재푸시 횟수로 근사 —
          링크가 안정적이라 코멘트 마커가 없다)
log:       " 10.6 #2/3: failing={build,test}→{test} "   · 탈락시킨 check 와 원인 커밋
// 1) CI 를 백그라운드로 띄운다 (여기서 기다리지 않는다)
const ciWatch = await Bash({
  command: `gh pr checks ${pr.number} --watch --fail-fast`,
  run_in_background: true,
});

// 2) 결과 회수(join) → 같은 파일 경로로 재발행 (Step 9의 workArtifact.url 재사용, URL 불변)
//    join 이 예산(30m)을 넘기면 결과는  " unknown "   이다 — 판정 불가는 통과가 아니다 (L-10.5 exhaust:)
const ciResult = await ciWatch.join();   // pass | fail | no-checks | unknown
if (workArtifact.url) {
  await republishSamePathWithCiResult(workArtifact, ciResult);   // 스킬 절차 4 — 실패해도 비차단
}

// 3) 분기 — CI 는 하드 게이트, 아티팩트 갱신은 아니다.
//    ⚠️ 네 verdict 를 **전부** 명시 분기한다. 예전에는 `fail` 만 분기하고 나머지는 그냥 흘러가서,
//       `no-checks`(워크플로 미트리거)와 join 실패가 조용히  " 통과 " 로 읽혔다
//       (게이트 tri-state: ../rules/orchestration-graph.md §3 — undetermined 의 기본값은 fail).
if (ciResult ===  " fail " ) {
  //  갱신 성패와 무관하게 CI 실패는 실패. pr-lifecycle-agent 의 CI Check Handling 으로 위임
  //  수정 → runPrePushGate() → re-push 시 이 Step 이 다시 호출되어 같은 URL 을 갱신한다
  await handleCiFailure(pr, ciResult);

  // ⛔ handleCiFailure 는 **다음 라운드를 준비**할 뿐 게이트를 통과시키지 않는다.
  //    throw 가 없으면 이 호출이 돌아온 뒤 Step 11 → Step 12 로 그대로 흘러 CI 실패 PR 이 머지된다.
  //     " 하드 게이트 " 라고 표에 적혀 있어도, 되돌아가지 않으면 게이트가 아니다.
  ciFixRound += 1;                                   // L-10.6 budget: 3 rounds (사이클 전역 카운터)
  if (ciFixRound  >   3) {
    await blockIssue(issue,  " ci_exhausted " , { detail: `CI 수정 3 라운드 소진 (PR #${pr.number})` });
    throw new Error(
      `⛔ CI 수정 예산 소진(L-10.6 bound:3)BLOCKED( ' ci_exhausted ' ) 보드 반영 완료, 중단. ` +
      `머지 금지 (--merge=pre-authorized 도 덮지 못한다)`
    );
  }
  throw new Error(
    `⛔ CI 실패 — PR #${pr.number} 머지 차단 (라운드 ${ciFixRound}/3). ` +
    `수정 → runPrePushGate() → re-push 후 Step 10.5 재실행. 실패 check 집합이 줄지 않으면 /cc-dev:unstuck`
  );
}

if (ciResult ===  " unknown " ) {
  // join timeout —  " 확인 못 함 " 을  " 통과 " 로 읽지 않는다 (L-10.5 exhaust:)
  throw new Error(
    `⛔ CI 판정 불가(watch timeout ≥30m)PR #${pr.number} 머지 차단. ` +
    `gh pr checks ${pr.number} --json name,state,bucket 으로 재확인한 뒤 재실행하라`
  );
}

if (ciResult ===  " no-checks " ) {
  // check 0건은  " 전부 통과 " 가 아니다(empty-set pass). base 로 갈린다:
  //   - base 가 default 브랜치 → 아래 두 원인 중 하나. 어느 쪽이든 자동 진행은 없다.
  //   - 계층 머지(story/·epic/ 등 비-default base) → 이 base 에 워크플로가 없는 정상 구성이므로
  //     허용하되 **PR body 에 영구 기록**한다 (콘솔 경고는 내구 기록이 아니다).
  //   prBaseBranch = Step 9 에서 결정된 PR base (계층: 부모 브랜치, 독립: development)
  const defaultBranch = (await Bash(`gh repo view --json defaultBranchRef -q .defaultBranchRef.name`)).trim();
  const integrationBases = [defaultBranch,  " development " ];   // 둘 다 본다 — default 가 main 이어도 development 는 통합 base 다
  if (integrationBases.includes(prBaseBranch)) {
    // ⚠️ 통합 base + 체크 0건에는 **원인이 둘** 있고 처방이 정반대다. 종전에는 (a) 하나로만
    //    단정해  " 트리거 조건을 확인하고 재실행하라 " 고 안내했는데, (b) 에서는 고칠 대상이 없어
    //    그 지시가 **영원히 수행 불가능**하다:
    //      (a) 트리거 누락 버그 — paths 목록에서 빠진 경로를 건드린 PR. 워크플로를 고쳐야 한다
    //      (b) paths 필터에 의한 **정당한** 미발화 — 애초에 CI 대상이 아닌 변경(문서 전용 등).
    //          워크플로는 정상이고 고칠 게 없는데, ruleset 이 required check 를 요구하면
    //          그 체크가 **생성조차 되지 않아** PR 이 영구 BLOCKED 가 된다
    //    실측: `.claude/rules/*.md` + `CLAUDE.md` 만 바꾼 PR 이 ci.yml paths 에 걸리지 않아
    //    required check  " 📊 CI 요약 "   이 생성 불가 → mergeStateStatus=BLOCKED, `--auto` 도 무의미.
    //    자동으로 뚫지 않는다 — 진단 재료를 모아 **사람에게 넘긴다**.
    const changed = (await Bash(`gh pr diff ${pr.number} --name-only 2 > /dev/null | head -50`)).trim();
    const requiredChecks = (await Bash(
      `gh api repos/{owner}/{repo}/rules/branches/${prBaseBranch} ` +
      `--jq  ' [.[] | select(.type== " required_status_checks " ) | .parameters.required_status_checks[].context] | join( " ,  " ) '   2 > /dev/null || true`
    )).trim();
    throw new Error(
      `⛔ CI 체크 0건 — 통합 base(${prBaseBranch}) 대상 PR 에서 워크플로가 트리거되지 않았다(검증 불가 ≠ 통과).\n` +
      `   변경 파일:\n     ${changed.split( " \n " ).join( " \n      " ) ||  " (조회 실패) " }\n` +
      (requiredChecks ? `   ruleset required check: ${requiredChecks}\n` :  " " ) +
      `   원인을 먼저 가려라:\n` +
      `     (a) 트리거 누락 — 위 경로가 CI 대상인데 워크플로 paths 에 없다 → 워크플로를 고치고 재실행\n` +
      `     (b) 정당한 미발화 — 위 경로가 애초에 CI 대상이 아니다(문서 전용 등) → 고칠 것이 없고,\n` +
      `         required check 가 있으면 이 PR 은 영구 BLOCKED. paths 에 그 경로를 추가하는 것은\n` +
      `         해법이 아니다(문서만 바꾸는 모든 PR 이 전체 CI 를 돌게 된다).\n` +
      `         → **사람 승인**을 받아 admin 머지로 처리한다: gh pr merge ${pr.number} --squash --admin\n` +
      `            (--merge=pre-authorized 는 이 승인을 대신하지 못한다 — 그건 CI 통과를 전제한 사전 승인이다)`
    );
  }
  prBodyExtras.ciNoChecks = { base: prBaseBranch, defaultBranch };
  await Bash(`gh pr edit ${pr.number} --body  " $(gh pr view ${pr.number} --json body -q .body)

## CI
⚠️ CI 체크 0건 — base ${prBaseBranch} 에 워크플로가 없다 (default ${defaultBranch} 아님).
이 브랜치의 변경은 상위(부모) PR 의 CI 에서 검증돼야 한다. " `);
  console.warn(`⚠️ CI 체크 0건 — base ${prBaseBranch}(-default). PR body 에 기록하고 진행`);
}

계약

항목규칙
순서PR 생성(Step 9) 시 발행 → CI watch 기동 → join → 같은 URL 갱신. 뒤집지 않는다
링크 위치PR 본문 (Step 9에서 이미 심어짐) — 이 Step은 댓글을 달지 않는다
아티팩트 갱신비차단 — 도구 부재·재발행 실패 모두 그대로 두고 계속
CI 판정하드 게이트 — 네 verdict 를 전부 분기한다. fail·unknown = throw, no-checks 는 아래 행. --merge=pre-authorized 도 이것을 덮지 못한다
no-checksbase 가 통합 base(default 또는 development)면 차단. 단 원인이 둘이고 처방이 다르다 — (a) 트리거 누락은 워크플로를 고친다, (b) paths 필터에 의한 정당한 미발화(문서 전용 변경 등)는 고칠 것이 없고 required check 가 생성 불가라 영구 BLOCKED 이므로 사람 승인 후 admin 머지가 정상 경로다. 계층 base(story/·epic/ 등)에서만 자동 허용하고 PR body 에 기록
판정 불가(unknown)join timeout = 차단. "확인 못 함"을 "통과"로 읽지 않는다
재시도 예산L-10.6 — 3 라운드, 실패 check 집합이 줄지 않으면 중단. 소진 시 BLOCKED('ci_exhausted')
정직성Step 9 발행 시점엔 CI 통과를 주장하지 않았다. 결과 확정 후 같은 URL의 내용만 갱신
민감정보rules/artifact-publishing.md §1 — 시크릿·내부 URL·고객 데이터 게시 금지

--merge=pre-authorized 가 덮지 못하는 것 (사전 승인이 생략하는 것은 Step 12 의 승인 클릭 하나뿐이다)

게이트지점실패 시
CI 판정 (fail/unknown/통합-base no-checks)Step 10.5throw — PR 유지, L-10.6 로 재시도
머지 직전 CI 재조회 (S12g)Step 12, gh pr merge 직전throw — 캐시된 ciResult 를 신뢰하지 않는다
열린-자식 재검증 (openChildrenStatus)Step 9 gate 0 · Step 11.9 · Step 12.5-3throw / reopen 복구
테스트·lint·DCM·리뷰 Critical 하드 게이트Step 8 · 8.3 · 8.5 · 8.7throw — PR 생성 자체가 막힌다
Step 0 조기 중단 (gh 부재/미인증)Step 0PR 단계 진입 전 중단

--merge=pre-authorized(/cc-dev:go 경로)에서도 이 단계는 동일하게 수행된다. 사전 승인이 생략하는 것은 Step 12 의 승인 클릭 하나이며, CI 통과 요구와 갱신 단계는 그대로다.

Step 11: Apply Additional Review Feedback#

루프 계약 L-11 (필드 정의: SoT §2)

inv:      피드백 반영 커밋 뒤 push 직전 runPrePushGate() 재실행 (PR 이 이미 있어도 예외 없음)
prog:     미해결 리뷰 코멘트 수, 매 라운드 강한 감소
          no-prog: 같은 코멘트가 두 라운드 남으면 반영을 반복하지 않고 리뷰어에게 질의 코멘트를 남긴다
term:     미해결 리뷰 코멘트 0& &   /cc-quality:checklist:feature-complete 통과
budget:   2 rounds (라운드마다 push 1회 → Step 10.5 재진입, CI 순환 예산은 L-10.6 이 별도로 센다)
exhaust:  blockIssue(issue,  ' review_unresolved ' ) 후 사람 판단 대기. 머지 진행 금지
resume:   `gh pr view {pr} --json reviews,comments` 재조회 (세션 메모리가 아니라 PR 이 원본이다)
log:       " 11 #1/2: unresolved=4→1 "   · 반영 커밋 SHA
// Step 8.7에서 자동 코드 review complete됨
// Step 11은 PR 생성 후 추가 리뷰 피드백 반영 단계
if (!options.skipReview) {
  // PR 리뷰 코멘트 확인 및 추가 피드백 반영
  await Skill({ skill:  " quality:review "   });

  // 피드백 반영 커밋 (변경 있을 때만)
  const hasFeedbackChanges = await Bash(`git status --porcelain`);
  if (hasFeedbackChanges.trim()) {
    await Bash(`
      git add .
      git commit -m  " refactor: ♻️ PR 리뷰 피드백 반영

      Co-Authored-By: Claude  < noreply@anthropic.com > " 
     `);

    // ⚠️ Per-Push Verification Gate: push 직전 Step 8.5 게이트 전체 재실행
    //    (format → dart fix → analyze 0건 → dcm:analyze error 0건)
    //    PR이 이미 존재해도 예외 없음 — 실패 시 throw로 push 차단
    await runPrePushGate();   // = Step 8.5 Phase 1~3

    await Bash(`git push`);

    // ♻️ push 로 CI 가 다시 돈다 → Step 10.5 재실행 (같은 URL 갱신, PR 본문은 그대로 유효)
    await runStep10_5(pr);
  }

  // /cc-quality:checklist:feature-complete 실행
  await Skill({ skill:  " checklist:feature-complete "   });
}

Step 12 · 12.5 · 12.6: Merge Approval (merge = Close) + Issue Closure Verification + 워크트리 반납 표시#

⚠️ 스택 모드에서는 auto-merge 를 쓸 수 없다. GitHub 문서가 "Auto-merge is not supported for stacked pull requests" 라고 명시한다 — gh pr merge --auto 계열은 금지다. 스택 모드의 머지는 gh stack merge <pr-number> --squash -y 이며, 이 명령은 지정 PR 까지의 아래층 전부를 단일 all-or-nothing 연산으로 머지한다(중간 PR 단독 머지 불가). 따라서 아래 S12g(머지 직전 CI 재조회)와 Step 11.9(열린-자식 재검증)의 적용 범위가 달라진다: 게이트 대상은 "이 PR 하나"가 아니라 함께 머지될 아래층 PR 전부다 — 각 층에 대해 gh pr checks 를 재조회하고 전부 통과해야 머지한다. 한 층이라도 미통과면 스택 전체를 머지하지 않는다. 상세는 stacked-prs 의 "머지 의미론" 절.

Step 12.5(이슈 Close 확정)는 스택 모드에서도 그대로 유지한다. 스택 머지 시 Closes #N 이 각 층에 대해 어떻게 발화하는지는 문서에 명시가 없다 — 자동 종료를 가정하지 말고, 기존대로 머지 후 GitHub state 를 읽어 gh issue close 로 명시 종료한다.

S12b(머지 직전 base retarget)는 스택 모드에서 적용하지 않는다. 스택에서 PR base 는 이슈 계층의 부모 브랜치가 아니라 바로 아래 층 브랜치이고(Step 4 의 "스택 모드일 때의 base" 주석), 그 base 는 gh stack submit/gh stack sync 가 소유해 자동 retarget 한다. 여기서 gh pr edit --base 로 손대면 스택이 diverged 가 되어 이후 gh stack 이 비대화형에서 아무것도 하지 않고 중단하며, 그 PR 의 diff 에는 아래층 형제의 커밋이 통째로 섞여 들어간다. 스택 모드의 base 재정렬은 gh stack sync 다.

// ══════════════════════════════════════════════════════════
// Step 11.9: ⛔ 머지 직전 열린-자식 재검증 (Parent Closure Invariant — 2번째 지점)
// ══════════════════════════════════════════════════════════
// Step 9 gate 0 이후 CI + 리뷰로 **수 시간**이 흐른다. 그 사이 QA 가 이 이슈에 새 자식을 달거나
// 닫혔던 자식이 재오픈될 수 있다(실사고 #3451: 게이트와 머지 사이 11시간). 머지는 되돌릴 수 없고
// `Closes #N` 은 머지 순간 자식을 보지 않고 발화하므로, **머지 직전에 반드시 다시 확인**한다.
// ⚠️ --merge=pre-authorized 는 이 게이트를 덮지 못한다 (사전 승인은 승인 클릭 하나만 생략한다).
const preMergeKids = await openChildrenStatus(issue.number);
if (!mayClose(preMergeKids, issue.issueType)) {   // unknown 은 Sub-task 만 경고 후 통과
  throw new Error(
    `⛔ #${issue.number} 머지 차단 — ${preMergeKids.status ===  " unknown " 
       ?  " 자식 이슈 조회 실패(판정 불가) " 
       : `열린 자식 ${preMergeKids.open.length}: ${preMergeKids.open.map(c = >   " # "   + c.number).join( " ,  " )}`}. ` +
    `PR 은 그대로 두고 자식을 먼저 완료한 뒤 재실행하라.`
  );
}

// 최종 상태 요약
displayMergeSummary(issue, pr, testResults, reviewResults);

// user approval 요청 — --merge=pre-authorized(/cc-dev:go·batch 등 상위 계획 게이트에서 사전 승인)면 질문 생략
// ⚠️ pre-authorization 이 덮는 것은 이 승인 클릭 하나뿐이다. 테스트/lint/DCM/리뷰 Critical
//    하드 게이트와 Step 0 조기 중단은 이미 이 지점 앞에서 동일하게 적용된 상태다.
let approval;
if (options.merge ===  " pre-authorized " ) {
  console.log( " ✅ merge pre-authorized — 상위 계획 게이트에서 사전 승인됨, 승인 질문 생략 " );
  approval =  " 머지 승인 " ;
} else if (options.unattended) {
  // 무인(--unattended) 계약 ⑧: 답할 사람이 없는 세션에서 AskUserQuestion 을 던지지 않는다 —
  // pre-authorization 이 없으면 머지하지 않고 PR을 그대로 둔 채 정지한다(batch.md 행 ⑥과 동일 계약).
  throw new Error(
    `⛔ #${issue.number} 머지 정지 — --unattended 인데 --merge=pre-authorized 가 없다. ` +
    `PR #${pr.number} 은 그대로 두고 사람 승인을 기다린다. ` +
    `[INCOMPLETE: merge_approval_unavailable] — 재개: --merge=pre-authorized 와 함께 재실행하거나 사람이 PR을 직접 머지하라.`
  );
} else {
  approval = await AskUserQuestion({
    questions: [{
      header:  " 머지 " ,
      question:  " PR을 머지하시겠습니까? " ,
      options: [
        { label:  " 머지 승인 " , description:  " 스쿼시 머지 후 이슈 클로즈 "   },
        { label:  " 수정 필요 " , description:  " Step 7로 돌아가서 수정 "   },
        { label:  " 취소 " , description:  " 현재 상태 유지 "   },
      ],
      multiSelect: false,
    }],
  });
}

if (approval ===  " 머지 승인 " ) {
  // ══════════════════════════════════════════════════════════
  // S12b: ⛔ 머지 직전 PR base 재해석 + retarget (branch-hierarchy 해석 계약 R7)
  // ══════════════════════════════════════════════════════════
  // 부모 브랜치는 **이 PR 이 열린 뒤에** 생겼을 수 있다 — 다른 머신/세션이 그 사이에 만들었거나,
  // 부모를 못 찾아 development 로 폴백해 PR 을 열었을 수 있다. 그 상태로 머지하면 이 커밋은
  // 부모 브랜치에 **들어가지 않고**, 부모→상위 PR 이 빈 diff 가 된다(Epic 은 닫혔는데 Project 에는
  // 아무것도 없는 상태). 그래서 머지 직전에 한 번 더 해석해 맞춘다.
  // ⚠️ --merge=pre-authorized 는 이 게이트를 덮지 못한다.
  // ⚠️ 스택 모드에서는 건너뛴다(로그만) — 스택의 base 는 이슈 계층의 부모가 아니라 바로 아래 층이며,
  //    그 base 는 `gh stack submit`/`gh stack sync` 가 소유한다. 여기서 손대면 스택이 diverged 된다.
  if (parentIssue?.number  & &   !options.base  & &   !stackMode) {   // --base 를 사람이 명시했으면 존중한다
    // ⚠️ 여기서는 **조회 전용**이다 (branch-hierarchy R7: RESOLVED =  " R1~R4 로 해석한 부모 브랜치 " ).
    //    R5/R6 의 생성 경로는 `git checkout` 으로 작업 트리를 부모 브랜치로 옮기고 push 까지 하는데,
    //    아래 S12m 의 `gh pr merge` 는 **현재 브랜치**로 PR 을 찾으므로 머지가 통째로 증발한다.
    const resolvedBase =
      (await findExistingHierarchyBranch(                       // R1·R2·R4 (fetch + 번호 글롭)
        hierarchyPrefixOf(parentIssue), parentIssue.number,  " development " )) ??
      (await readBranchRegistry(parentIssue.number));           // R3
    if (!resolvedBase) {
      throw new Error(
        `⛔ #${issue.number} 머지 차단 — 부모 #${parentIssue.number} 의 계층 브랜치를 원격에서 찾지 못했다. ` +
        `머지 직전에 새로 만들지 않는다(R7 은 해석만 한다). 부모 브랜치 확보 후 재실행하라`
      );
    }
    const prRefs = JSON.parse(await Bash(`gh pr view ${pr.number} --json baseRefName,headRefName`));
    const currentBase = prRefs.baseRefName;
    if (currentBase !== resolvedBase) {
      console.warn(`🎯 PR #${pr.number} base retarget: ${currentBase} → ${resolvedBase} (R7)`);
      await Bash(`gh pr edit ${pr.number} --base ${resolvedBase}`);
      baseBranch = resolvedBase;                        // Step 12.5 의 post-merge 최신화도 이 값을 쓴다
      // base 가 바뀌면 diff 도 바뀐다 — **계보와 커밋 범위를 모두** 확인한다.
      // ⚠️ 로컬 HEAD 가 아니라 **PR 의 head ref** 로 센다(작업 트리가 다른 브랜치에 있을 수 있다).
      await Bash(`git fetch origin ${resolvedBase} ${prRefs.headRefName} --quiet`);
      // ⛔ ① 계보 검사가 진짜 게이트다 — 커밋 수만으로는 부족하다. head 가 새 base 의 자손이 아니면
      //    `origin/{base}..origin/{head}` 범위에 **이미 상위에 통합된 커밋들**이 섞여 count > 0 이
      //    그냥 만족된다. 그대로 squash 하면 그 변경 전부가 부모 브랜치에 복제되어 이후
      //    부모→상위 PR 이 중복·충돌로 착지한다.
      const descends = (await Bash(
        `git merge-base --is-ancestor origin/${resolvedBase} origin/${prRefs.headRefName}  & &   echo ok || echo no`
      )).trim();
      if (descends !==  " ok " ) {
        throw new Error(
          `⛔ #${issue.number} 머지 차단 — PR head 가 새 base ${resolvedBase} 의 자손이 아니다(계보 어긋남). ` +
          `head 를 ${resolvedBase} 위로 rebase → runPrePushGate() → re-push → CI 재통과 후 재실행하라`
        );
      }
      // ② 커밋 0건이면 머지하지 않는다.
      const commits = parseInt((await Bash(
        `git rev-list --count origin/${resolvedBase}..origin/${prRefs.headRefName}`
      )).trim(), 10);
      if (!commits) {
        throw new Error(
          `⛔ #${issue.number} 머지 차단 — base 를 ${resolvedBase} 로 옮기니 커밋 0건이다. ` +
          `이미 부모에 들어간 변경이다. 계보 확인 후 재실행하라`
        );
      }
      console.log(`✅ retarget 후 계보 확인 + 커밋 ${commits}건 — CI 는 아래 S12g 에서 재조회한다`);
    }
  }

  // ══════════════════════════════════════════════════════════
  // S12g: ⛔ 머지 직전 CI 재조회 (flow 블록 `S12g GATE ... undet:fail fail:S10.6`)
  // ══════════════════════════════════════════════════════════
  // Step 10.5 의 join 이후 Step 11 리뷰 반영·재푸시로 시간이 흐르고, 그 사이 새 커밋·워크플로 재실행으로
  // check 가 뒤집힐 수 있다. 캐시된 `ciResult` 는 그 시점의 사실일 뿐이므로 **머지 직전에 다시 읽는다**
  // (열린-자식 게이트를 Step 9/11.9/12.5 에서 세 번 평가하는 것과 같은 이유다).
  // ⚠️ --merge=pre-authorized 는 이 게이트를 덮지 못한다.
  // ⚠️ 필드명은 `name,state,bucket` 이다. `conclusion` 은 `gh pr checks` 에 **존재하지 않는
  //    필드**이고(있는 것: bucket·completedAt·description·event·link·name·startedAt·state·workflow),
  //    지정하면 gh 가 `Unknown JSON field` 로 exit 1 한다. 이때 `2 > /dev/null || echo  " [] " ` 를 붙이면
  //    그 실패가 **빈 배열로 둔갑**해  " 체크 0건 " 으로 읽힌다 — §3.2 의 pipe/`||`-masked exit code 다.
  //    그래서 종료코드를 삼키지 않고 **분리해서** 읽는다: 조회 실패(unknown)와 체크 0건(no-checks)은
  //    서로 다른 사실이고, 전자는 명령을 고쳐야 하는 버그다.
  const raw = await Bash(
    `gh pr checks ${pr.number} --json name,state,bucket 2 > & 1; echo  " __EXIT__$? " `
  );
  const exitCode = Number(raw.match(/__EXIT__(\d+)/)?.[1] ?? 1);
  const body = raw.replace(/__EXIT__\d+\s*$/,  " " ).trim();
  // gh 종료코드 8 =  " checks pending "   (문서화된 값) — 판정 불가이므로 통과가 아니다.
  if (exitCode !== 0  & &   exitCode !== 8) {
    throw new Error(
      `⛔ #${issue.number} 머지 차단 — CI 조회 자체가 실패(exit ${exitCode}): ${body.slice(0, 200)}. ` +
      ` " 조회 실패 "" 체크 없음 " 으로 접지 않는다. 명령/인증을 고친 뒤 재실행하라`
    );
  }
  const checks = exitCode === 8 ? [] : JSON.parse(body ||  " [] " );
  // `bucket` 이 gh 가 문서화한 정규 분류다: pass · fail · pending · skipping · cancel.
  // pass/skipping 만 통과로 읽는다 — pending 은  " 아직 모름 " 이므로 §3 tri-state 상 fail 쪽이다.
  const notSuccess = checks.filter(c = >   ![ " pass " ,  " skipping " ].includes(c.bucket));

  if (exitCode === 8) {
    throw new Error(
      `⛔ #${issue.number} 머지 차단 — CI 진행 중(gh exit 8). 완료 후 재실행하라`
    );
  }
  if (checks.length === 0) {
    // 조회 0건 = 판정 불가. Step 10.5 에서 비-default base 예외로 **기록된 경우만** 통과시킨다
    if (!prBodyExtras.ciNoChecks) {
      throw new Error(
        `⛔ #${issue.number} 머지 차단 — gh pr checks 재조회 결과 0(판정 불가). ` +
        `Step 10.5 의 no-checks 분기 기준으로 확인한 뒤 재실행하라`
      );
    }
    console.warn(`⚠️ CI 체크 0건 — Step 10.5 에서 base ${prBodyExtras.ciNoChecks.base}(-default)로 기록된 예외`);
  } else if (notSuccess.length  >   0) {
    ciFixRound += 1;                                   // L-10.6 과 같은 예산을 공유한다
    if (ciFixRound  >   3) {
      throw new Error(
        `⛔ #${issue.number} 머지 차단 — CI 수정 예산 소진(L-10.6 bound:3), BLOCKED( ' ci_exhausted ' )`
      );
    }
    throw new Error(
      `⛔ #${issue.number} 머지 차단 — CI check ${notSuccess.length}건 미통과: ` +
      `${notSuccess.map(c = >   `${c.name}(${c.bucket}/${c.state ||  " in-progress " })`).join( " ,  " )}. ` +
      `수정 → runPrePushGate() → re-push (라운드 ${ciFixRound}/3)`
    );
  }
  console.log(`✅ S12gCI check ${checks.length}건 전부 통과 확인 (머지 직전 재조회)`);

  // 스쿼시 머지 → GitHub  " Closes # "   키워드로 이슈 자동 Close
  // 정책:  " 머지 = Close "   (AI agent가 머지 전 풀스택 E2E/리뷰 완료). Done 파이프라인 미사용.
  // ⚠️ **PR 번호를 명시한다.** 인자 없는 형태는 **현재 브랜치**로 PR 을 찾으므로, 앞선 게이트가
  //    작업 트리를 옮겼다면 엉뚱한 PR 을 머지하거나  " PR 없음 "   으로 조용히 실패한다.
  //
  // ⭐ 머지 **전에** head SHA 와 base tip 을 기록한다. `--delete-branch`(그리고 많은 리포의
  //     " 머지 시 head 자동 삭제 "   설정)로 원격 head 가 사라지므로, 머지 후  " 내 트리가 그대로
  //    들어갔는가 "   는 `origin/ < head > ` 가 아니라 **이 SHA 로만** 검증할 수 있다. 실측:
  //    `git fetch --prune` 뒤 `git diff origin/ < head >   origin/ < base > ` 는 ref 가 없어 stderr 한 줄만
  //    내고 **공허하게 통과**했다 — 검증한 것처럼 보이지만 아무것도 비교하지 않은 것이다.
  const headOid = (await Bash(`gh pr view ${pr.number} --json headRefOid -q .headRefOid`)).trim();
  const baseTipBefore = (await Bash(`git fetch origin ${baseBranch} --quiet  & &   git rev-parse origin/${baseBranch}`)).trim();
  const mergeOut = await Bash(`gh pr merge ${pr.number} --squash --delete-branch 2 > & 1; echo  " __EXIT__$? " `);

  // ⚠️ **머지 명령의 비정상 종료 == 머지 실패가 아니다.** `--delete-branch` 는 원격 브랜치를
  //    지운 뒤 **로컬에서 base 브랜치를 체크아웃**하는데, 그 base 가 다른 워크트리에 점유돼
  //    있으면 여기서 죽는다 — 서버 쪽 머지는 이미 끝난 뒤다(실측):
  //      failed to run git: fatal:  ' development '   is already used by worktree at  ' ... ' 
   //    이 출력을  " 머지 실패 " 로 읽으면 재시도하거나 사이클을 중단하게 되므로, 종료코드가
  //    아니라 **PR 상태를 원본으로** 판정한다.
  if (!/__EXIT__0\s*$/.test(mergeOut)) {
    const state = (await Bash(`gh pr view ${pr.number} --json state -q .state`)).trim();
    if (state !==  " MERGED " ) {
      throw new Error(`⛔ PR #${pr.number} 머지 실패(state=${state}): ${mergeOut.slice(0, 300)}`);
    }
    console.warn(
      `⚠️ gh pr merge 가 비정상 종료했으나 PR #${pr.number}MERGED — 후처리(로컬 base 체크아웃) ` +
      `단계의 실패다. 머지는 성공했으므로 계속 진행한다`
    );
  }

  // ⭐ squash 결과 검증 — **머지 전 head SHA** 기준. 내가 바꾼 파일에 한해 head 와 새 base tip 이
  //    같아야 한다(base 가 그 사이 다른 커밋을 받았어도 이 파일 집합에서는 diff 0). 0 이 아니면
  //    다른 머지가 같은 파일을 건드린 것이므로 경고로 남기고 사람이 본다 — 사이클을 막지는 않는다.
  //    ⛔ `origin/${headRefName}` 로 비교하지 말 것 — 위 주석대로 ref 가 이미 없다.
  const changedFiles = (await Bash(`git diff --name-only ${baseTipBefore} ${headOid} | tr  ' \\n '   '   ' `)).trim();
  const treeDiff = changedFiles
    ? (await Bash(`git fetch origin ${baseBranch} --quiet  & &   git diff --stat ${headOid} origin/${baseBranch} -- ${changedFiles} | tail -1`)).trim()
    :  " " ;
  if (treeDiff) {
    console.warn(`⚠️ 머지 결과가 머지 전 head(${headOid.slice(0, 10)})와 다르다 — 같은 파일을 건드린 다른 머지가 끼었을 수 있다: ${treeDiff}`);
  } else {
    console.log(`✅ squash 트리 검증 — 변경 파일 기준 head ${headOid.slice(0, 10)} == origin/${baseBranch}`);
  }

  // ══════════════════════════════════════════════════════════
  // Step 12.5: 이슈 Close 검증 + 폴백 ⚠️ (ZenHub 2-상태 모델)
  // ══════════════════════════════════════════════════════════
  // ZenHub는 두 상태를 따로 관리한다:
  //   - Pipeline(보드 칼럼): Done으로 옮겨도 GitHub 이슈는 open 그대로
  //   - GitHub state(open/closed): Closed 파이프라인 = GitHub closed와 1:1
  // 따라서  " 닫혔다 " 의 진실은 pipeline이 아니라 GitHub state로 판단한다.
  // `Closes #N` 자동 close가 누락/지연될 수 있으므로 머지 후 반드시 검증·폴백한다.

  // 12.5-1. GitHub 이슈 상태 확인 (open/closed의 source of truth)
  const ghState = (await Bash(`gh issue view ${issue.number} --json state -q .state`)).trim();
  if (ghState !==  " CLOSED " ) {
    // `Closes #N`이 발화하지 않음(키워드 누락/크로스리포 링크 등) → 명시적 close
    console.warn(`⚠️ #${issue.number} 자동 close 안 됨 → 명시적으로 close합니다`);
    await Bash(`gh issue close ${issue.number} --reason completed`);
  }

  // 12.5-2. ZenHub가 Closed 파이프라인으로 동기화됐는지 확인 (GitHub close → 자동 동기화)
  //         동기화 지연 시 ZenHub state를 직접 CLOSED로 강제
  const closedOnZh = await mcp__zenhub__searchClosedIssues({ query: `#${issue.number}` });
  if (!closedOnZh.find(i = >   i.number === issue.number)) {
    console.warn(`⚠️ ZenHub Closed 동기화 지연 → state를 CLOSED로 강제`);
    await mcp__zenhub__updateIssue({ issueId: issue.id, state:  " CLOSED "   });
  }
  console.log(`✅ Step 12.5 — #${issue.number} GitHub + ZenHub 모두 Closed 확정`);

  // 12.5-2b. 점유 해제 — 닫힌 이슈에 점유가 남아 있으면 재오픈·후속 작업이 자기 자신을 막는다.
  //          대장을 지우지 않고 state: released 로 갱신한다(누가 언제 잡았다 놓았는지가 다음 판단의 재료다).
  await releaseClaim(issue,  " closed:merged " );   // SoT: ../rules/zenhub-conventions.md → Work Claim Contract

  // 12.5-3. ⛔ 종료 후 열린-자식 불변식 확인 + 복구 (Parent Closure Invariant — 3번째 지점)
  //         Step 11.9 와 머지 사이에도 자식이 생길 수 있고, `Closes #N` 은 자식을 보지 않는다.
  //         위반이면 **머지는 그대로 두고 이슈만 재오픈**해 보드 상태를 사실과 일치시킨다.
  const postKids = await openChildrenStatus(issue.number);
  if (postKids.status ===  " open " ) {
    const list = postKids.open.map(c = >   " # "   + c.number).join( " ,  " );
    await Bash(`gh issue reopen ${issue.number}`);
    await Bash(`gh issue comment ${issue.number} --body  " ⚠️ PR #${pr.number} 머지로 자동 종료됐으나 열린 하위 이슈가 남아 재오픈했습니다: ${list} " `);
    // ⚠️ reopen 이후에만 파이프라인을 옮긴다 — 닫힌 이슈를 열린 칸으로 옮기면 GitHub 이 재오픈시킨다
    //    (zenhub-conventions.md →  " Never moveIssueToPipeline a closed issue " )
    await mcp__zenhub__moveIssueToPipeline({ issueId: issue.id, pipelineId: inProgressPipelineId });
    // ⚠️ 여기서 점유를 다시 잡지 않는다 — 이 세션은 끝났고, 남은 자식을 처리할 다음 세션이 잡아야 한다.
    //    잡은 채로 끝내면 그 다음 `/cc-dev:batch` 가 자기 앞선 세션 때문에 `other-live` 로 막힌다.
    console.warn(`⛔ #${issue.number} 재오픈 — 남은 자식(${list}) 처리 후 /cc-dev:batch ${issue.number} 로 마무리하라`);
  } else if (postKids.status ===  " unknown " ) {
    // ⚠️ tri-state 계약의 유일한 예외 지점: 여기서 `unknown` 은 **차단하지 않고 경고**한다.
    //    머지는 이미 끝났고 차단할 대상이 없다 —  " 조회 실패 "   를 근거로 정상 종료된 이슈를
    //    되돌리면 오탐 피해가 더 크다. 대신 수동 확인 경로를 남긴다.
    console.warn(`⚠️ #${issue.number} 자식 조회 실패 — 열린 자식 여부 미확인. \`gh api graphql\` subIssues 조회(run.md Step 0.6)로 수동 확인, 또는 \`/cc-dev:zenhub:manage sync-closed --issue ${issue.number}\` 재실행`);
  }
  // 참고: 위 12.5-1/12.5-2와 동일한 재동기화 로직을 이 워크플로우 밖에서(수동 GitHub close,
  // 팀원의 직접 머지 등) 돌리고 싶으면 `/cc-dev:zenhub:manage sync-closed`로 독립 실행 가능
  // (plugins/cc-dev/commands/zenhub/manage.md 참조).

  // ⭐ 계층 재귀 확인: 방금 닫힌 이슈가 Initiative/Project/Epic(=자기 브랜치를 갖는 컨테이너
  // 레벨)이면, 그 바로 위 부모의 완료 여부를 확인한다. Story/Sub-task close는 여기서
  // 재귀를 타지 않는다 — 컨테이너 레벨 자신은 아래  " 계층 브랜치 "   블록의 안내를 거쳐 사람이
  // (또는 /cc-dev:batch가) 자기 PR을 머지해야 닫히므로, 자식 close만으로 컨테이너를
  // 앞질러 닫지 않는다.
  //
  // ⚠️ Orca 도입 이후 checkAndCloseParent는  " 부모가 모두 닫혔다 " 만으로 부모를 바로 닫지
  // 않는다 — 부모(Initiative/Project/Epic)도 이제 자기 브랜치를 가지므로, 그 브랜치의 PR이
  // 아직 병합되지 않았을 수 있다. 부모가 브랜치를 가진 레벨이면 checkAndCloseParent는 닫는
  // 대신  " /cc-dev:batch {parent}로 마무리하세요 " 를 경고 로그로 남긴다(agents/dev/issue-state-agent.md
  // 참조). 부모 타입이 이 워크스페이스에 아예 없는(브랜치 없는) 레거시 폴백 케이스에서만 예전처럼
  // 즉시 close한다. 이 함수는 여기서  " 실제로 병합해 닫는 "   재귀 cascade를 수행하지 않는다 —
  // 그건 /cc-dev:batch의 책임이다(이 함수는 standalone 실행 시 안내만 한다).
  if ([ " Initiative " ,  " Project " ,  " Epic " ].includes(issue.issueType)) {
    await checkAndCloseParent(issue.number); // see agents/dev/issue-state-agent.md
  }

  // ⭐ 머지 후: PR base 브랜치로 이동 + 최신 상태로 동기화 + 서브모듈 최신화 (필수)
  // - gh pr merge --delete-branch 는 base 브랜치를 체크아웃하지만 pull 하지 않는다.
  // - release 자동화가 머지 직후 chore(release) 커밋을 origin에 추가하므로,
  //   base 브랜치를 명시적으로 최신화하여 로컬을 항상 최신 상태로 둔다.
  // - git pull 은 superproject가 기록한 서브모듈 포인터만 갱신할 뿐 서브모듈 워킹트리는
  //   체크아웃하지 않는다. git submodule update 없이는 서브모듈이 머지 이전 리비전에 머물러
  //   Serverpod/Melos 모노레포에서 빌드/코드젠 drift가 발생한다.
  //   git submodule sync 로 .gitmodules URL 변경을 먼저 반영한 뒤 update 한다.
  //   서브모듈이 없는 리포지토리에서는 두 명령 모두 no-op 이므로 항상 실행해도 안전하다.
  // - baseBranch 는 Step 4에서 결정된 PR base (계층: 부모 브랜치, 독립: development).
  //
  // ⛔ 여기서도 base 체크아웃은 **실패할 수 있다** (위 머지 후처리와 같은 원인 — 다른 워크트리가
  //    base 를 점유). 그건 사이클을 실패시킬 이유가 아니다: 머지·close 는 이미 끝났고 이 블록은
  //    **로컬 편의를 위한 최신화**일 뿐이다. 그래서 ① origin ref 최신화는 체크아웃 없이 **항상**
  //    수행하고(다음 이슈의 resolveBaseBranch 가 쓰는 것은 origin ref 다), ② 워킹트리 이동은
  //    가능할 때만 시도하며 실패해도 경고로 끝낸다.
  await Bash(`
    git fetch origin ${baseBranch} --prune --quiet || true   # ← 체크아웃과 무관하게 반드시 최신화
    if git checkout ${baseBranch} 2 > /dev/null; then
      git pull --ff-only origin ${baseBranch} || true
      git submodule sync --recursive
      git submodule update --init --recursive
    else
      # ⚠️ 현재 브랜치가 **방금 머지된 head** 면 여기 머무를 수 없다 — 원격은 이미 지워졌고, 다음
      #    이슈가 이 위에서 분기하면 squash 이전 커밋을 물려받는다(계보 오염). base 를 잡을 수
      #    없으니 최소한 origin/base 트리 위에 선다. 워크트리가 원래 있던 개인 브랜치(Orca 가
      #    만든 것)를 알면 그 이름으로 되감는 편이 좋다: git checkout -B  < 개인 브랜치 >   origin/ < base > 
       HEAD_REF=$(gh pr view ${pr.number} --json headRefName -q .headRefName 2 > /dev/null)
      if [ -n  " $HEAD_REF "   ]  & &   [  " $(git rev-parse --abbrev-ref HEAD) "   =  " $HEAD_REF "   ]; then
        git checkout --detach origin/${baseBranch} \
           & &   git branch -D  " $HEAD_REF "   > /dev/null 2 > & 1 \
           & &   echo  " ↩️ 머지된 head  ' $HEAD_REF '   를 떠나 origin/${baseBranch} 위(detached)에 선다 — 개인 브랜치가 있으면  ' git checkout -B  < 개인 브랜치 >   origin/${baseBranch} '   로 되감을 것 " 
       else
        echo  " ⚠️ ${baseBranch} 체크아웃 불가(다른 워크트리 점유 등) — origin ref 만 최신화하고 현재 브랜치를 유지한다 " 
       fi
    fi
  `);
  console.log(`✅ 머지 후 origin/${baseBranch} 최신화 완료 (워킹트리 이동은 가능한 경우에만)`);

  // 계층 브랜치: 부모(자기 브랜치를 갖는 레벨)의 자식이 모두 완료되면 안내
  // ⚠️ 형제 조회도 Child Enumeration Contract 를 쓴다 — searchLatestIssues({parent:…}) 는
  //    latest-20 창에 걸려  " 남은 형제 " 를 놓치고  " 다 끝났다 " 고 잘못 알릴 수 있다.
  if (parentIssue  & &   [ " Initiative " ,  " Project " ,  " Epic " ,  " Feature " ,  " Bug " ,  " Task " ].includes(parentIssue.issueType)) {
    const sib = await openChildrenStatus(parentIssue.number);
    if (sib.status ===  " none " ) {
      console.log(`\n🎉 ${parentIssue.issueType} #${parentIssue.number}의 모든 자식이 완료되었습니다.`);
      console.log(`   부모 브랜치의 PR 생성·머지·close 는 batch 가 수행합니다:`);
      // ⚠️ /cc-dev:run 이 아니다 — 컨테이너를 run 으로 돌리면 자식 재조회 없이 구현 경로로 들어간다.
      console.log(`   /cc-dev:batch ${parentIssue.number}`);
    } else if (sib.status ===  " open " ) {
      console.log(`\nℹ️ ${parentIssue.issueType} #${parentIssue.number}: ${sib.open.length}개 자식 남음 (${sib.open.map(c = >   " # "   + c.number).join( " ,  " )})`);
    } else {
      console.warn(`\n⚠️ #${parentIssue.number} 자식 조회 실패 — 완료 여부 미확인`);
    }
  }

  // ══════════════════════════════════════════════════════════
  // Step 12.6: Orca 워크트리 반납 표시 ⛔ (자기 제거 금지)
  // ══════════════════════════════════════════════════════════
  // 이 사이클이 `/cc-dev:batch` 에 디스패치돼 **Orca 워크트리 안에서** 돌고 있다면, 머지가 끝난
  // 지금이 그 작업 공간의 수명이 끝나는 지점이다. 다만 **지우는 것은 이 세션의 일이 아니다** —
  // 워크트리를 만든 쪽(부모 batch 의 머지 큐)이 자기 자리에서 회수한다.
  //   ⛔ 자기 제거가 금지인 이유(SoT: ../skills/orca-worktree-lifecycle/SKILL.md §1):
  //      ① 위 12.5-1~12.5-3 · 점유 해제 · post-merge base 최신화는 **머지 이후**에 돈다.
  //         자기 발밑을 지우면 그 단계들이 한 줄도 실행되지 않고 세션이 사라진다
  //         (보드는 In Progress, 점유는 잡힌 채로 남는다).
  //      ② Orca 의 `active`/`current` 선택자는 셸의 cwd 로 해석된다 —  " 이 워크트리 " 를 지목하는
  //         가장 짧은 명령이 곧 가장 파괴적인 명령이 된다.
  //      ③ 부모의 머지 큐는 그 체크아웃을 아직 쓸 수 있다(현재 myBranch 위로 갱신 → 재게이트).
  //         워커의  " 내 일은 끝났다 "   는 오케스트레이터의  " 이제 안 쓴다 "   와 다른 시점이다.
  //
  // 여기서 하는 일은 **표시 두 가지**뿐이다 — Orca UI 에서  " 정리해도 되는 카드 " 로 보이게 하고,
  // 부모의 회수/스윕이 읽는 힌트를 남긴다(판정의 근본 근거는 그 스킬 §3 의 안전 판정 5종이다).
  //   - workspace-status → 완료
  //   - comment → `머지 #{pr} — 회수 가능`
  //
  // ⚠️ 명령은 `Skill(orca-cli)` 로 **실행 직전에** 최신 가이드를 받아 쓴다(discovery stub —
  //    서브커맨드·플래그는 버전마다 바뀐다. 캐시된 형태를 외워 쓰지 않는다).
  // ⚠️ 이 단계는 **비차단**이다: Orca 미실행·CLI 부재·워크트리 밖 직접 실행(standalone)이면
  //    조용한 no-op 이고, 실패해도 경고만 남기고 사이클은 성공으로 끝난다. 머지·close 는 이미
  //    끝나 되돌릴 수 없으므로, 표시 실패로 사이클을 실패시키는 것이 더 큰 사고다.
  // 정의 자리: ../skills/orca-worktree-lifecycle/SKILL.md §1 (여기서 재구현하지 않는다 —
  // 사이트마다 다시 구현하면 그중 하나가 반드시 자기 제거를 하거나 실패로 사이클을 죽인다)
  await markWorktreeReclaimable({ pr: pr.number, issue: issue.number });   // best-effort
} else if (approval ===  " 수정 필요 " ) {
  // ══════════════════════════════════════════════════════════
  // S12 == >   S7R : 재작업 루프 진입 (flow 블록: bound:2, invalidates 10 항목)
  // ══════════════════════════════════════════════════════════
  // ⚠️ 예전에는 이 분기 자체가 없었다 — 옵션 설명( " Step 7로 돌아가서 수정 " )만 있고, 실제로는
  //    아무 상태도 되돌리지 않았다. validateStepPrerequisites() 는 completed 를 통과시키므로
  //    Step 7 로 돌아가도 7.2·7.3·7.7·8·8.3·8.5·8.7·9.0·11.9 는 **한 건도 재실행되지 않는다**.
  //    되돌림·예산·복귀는 enterRework() 한 곳에만 있다 (위  " 재작업 루프 계약 " ).
  return await enterRework( " S7R " );
} else {
  // 취소 — flow 블록 `S12 ~~ >   SKIP`. PR·브랜치를 그대로 두고 종료한다(되돌리지 않는다)
  console.log(`ℹ️ 머지 취소 — PR #${pr.number} 와 브랜치를 그대로 유지한다.`);
  console.log(`   이어서 하려면 /cc-dev:run ${issue.number} 로 재진입 (Step 8.5 부터 재실측)`);
  // TodoWrite: Step 12 는 in_progress 로 남긴다 (State Tracking Rules 의  " Keep on failure " )
}

Output Format#

Progress Display#

╔════════════════════════════════════════════════════════════════╗
║  /cc-dev:run Progress                                            ║
╠════════════════════════════════════════════════════════════════╣
║                                                                ║
║  [████████░░░░░░░░░░░░] 50% - Step 6/12                       ║
║                                                                ║
║  ✅ Step 1: 작업 내용 분석 완료 (screen feature 감지: List)          ║
║  ✅ Step 2: 이슈 #1810 생성 완료                                ║
║  ✅ Step 3: Product Backlog 이동                               ║
║  ✅ Step 4: 브랜치 생성 완료                                   ║
║  ✅ Step 5: In Progress 이동                                   ║
║  🔄 Step 6: BDD 시나리오 작성 중...                            ║
║  ⏳ Step 7: 구현 작업 대기                                     ║
║  ⏳ Step 8: 테스트 작성/실행 대기                               ║
║  ⏳ Step 9: PR 생성 대기 (작업내역 아티팩트 발행 포함)           ║
║  ⏳ Step 10: Review/QA 이동 대기                               ║
║  ⏳ Step 10.5: CI 대기 ∥ 작업내역 아티팩트 CI 상태 갱신          ║
║  ⏳ Step 11: 코드 리뷰 대기                                    ║
║  ⏳ Step 12: 머지 승인 대기                                    ║
║                                                                ║
╚════════════════════════════════════════════════════════════════╝

On Completion#

╔════════════════════════════════════════════════════════════════╗
║  Workflow Complete: #1810                                      ║
╠════════════════════════════════════════════════════════════════╣
║                                                                ║
║  📋 Issue: #1810 - 저자 목록 화면 추가                          ║
║  🔀 PR: #1815                                                  ║
║  🌿 Branch: feature/1810-author-list (deleted)                 ║
║                                                                ║
║  📝 Changes:                                                   ║
║    - 12 files changed                                         ║
║    - +520 / -30 lines                                         ║
║                                                                ║
║  ✅ Tests: 35/35 passed                                        ║
║    - UseCase Unit: 10/10                                      ║
║    - BLoC Unit: 8/8                                           ║
║    - Backend Unit: 6/6 (endpoint: 3, service: 3)              ║
║    - Backend Integration: 4/4                                 ║
║    - BDD: 7/7 scenarios                                       ║
║                                                                ║
║  ✅ Design Verify: PASS (pixel≤2, ΔE3)                         ║
║                                                                ║
║  ✅ Review: All issues resolved                                ║
║  ✅ Checklist: 11/11 passed                                   ║
║  ✅ CI: All checks passed                                      ║
║  📄 작업내역: https://claude.ai/code/artifact/… (🔒 비공개)     ║
║                                                                ║
║  📊 Duration: 22m 15s                                          ║
║  🧹 Worktree: 반납 표시 완료 (회수는 상위 batch)                 ║
║  🏁 Final State: CLOSED (by merge)                             ║
║                                                                ║
╚════════════════════════════════════════════════════════════════╝

TodoWrite Integration (Required)#

Rule: Immediately update TodoWrite on each step start/completion

Initialization (On Step 1 Start)#

📄 비규범 파생 뷰 — 상태 추적용 투영이다. 노드와 순서의 규범은 위 ```flow 블록이다. 다만 이 시드는 validateStepPrerequisites() 가 읽는 유일한 입력이므로, STEP_ORDER 와 1:1 이어야 한다(1 노드 = 1 항목, 정확 일치로 조회된다). 항목이 빠지면 그 단계의 전제 검사가 사라지는 게 아니라 다음 단계 진입이 throw 된다(검증 불가 ≠ 통과). content 는 반드시 Step {id}: 로 시작한다 — 앞에 무엇도 붙이지 않는다.

TodoWrite([
  // Steps 0~0.6 — preflight. 이전에는 시드에 없어 전제 검사에서 통째로 빠져 있었다
  { content:  " Step 0: 도구 preflight + Degradation Contract " , status:  " completed " , activeForm:  " 도구 점검 중 "   },
  { content:  " Step 0.1: 진입 경로 판별 (startedFrom) " , status:  " completed " , activeForm:  " 진입 경로 판별 중 "   },
  { content:  " Step 0.4: 점유 가드 (--force-claim 시 skip) " , status:  " completed " , activeForm:  " 점유 상태 확인 중 "   },
  { content:  " Step 0.5: 중복 착수 preflight (--skip-dup-check 시 skip) " , status:  " completed " , activeForm:  " 중복 착수 확인 중 "   },
  { content:  " Step 0.6: 기존 sub-issue 발견 (컨테이너형만, 아니면 skipped) " , status:  " completed " , activeForm:  " 기존 sub-issue 확인 중 "   },
  { content:  " Step 1: 작업 내용 분석 " , status:  " in_progress " , activeForm:  " 작업 내용 분석 중 "   },
  { content:  " Step 1.5: PM 요구사항 정제 (--skip-pm 시 skip) " , status:  " pending " , activeForm:  " PM 요구사항 정제 대기 "   },
  { content:  " Step 2: ZenHub 이슈 생성 " , status:  " pending " , activeForm:  " 이슈 생성 대기 "   },
  // ⚠️ Step 3 은 startedFrom=== " issue_number "   경로에서 실행되지 않는다 → 그 경로에서는  " skipped " 
   //    로 표시한다(pending 으로 남기면 validateStepPrerequisites 가 Step 4 진입을 막는다).
  { content:  " Step 3: Product Backlog 이동 (기존 이슈 경로면 skip) " , status:  " pending " , activeForm:  " Pipeline 이동 대기 "   },
  { content:  " Step 4: 브랜치 생성 " , status:  " pending " , activeForm:  " 브랜치 생성 대기 "   },
  { content:  " Step 5: In Progress 이동 + 부모 체인 cascade (모든 경로 필수) " , status:  " pending " , activeForm:  " Pipeline 이동 대기 "   },
  { content:  " Step 6: BDD 시나리오 " , status:  " pending " , activeForm:  " BDD 작성 대기 "   },
  { content:  " Step 7: 구현 작업 (Agent Teams 시 S7f/S7j 포함) " , status:  " pending " , activeForm:  " 구현 대기 "   },
  // Step 7.1 은 비차단 참고(ADVISORY_STEPS) — pending 으로 남아도 다음 단계를 막지 않는다
  { content:  " Step 7.1: 디자인 레퍼런스·의사결정 (비차단) " , status:  " pending " , activeForm:  " 디자인 참고 중 "   },
  { content:  " Step 7.2: CoUI 패키지 변경 분리 (필요 시) " , status:  " pending " , activeForm:  " CoUI 분리 대기 "   },
  { content:  " Step 7.3: 디자인 검증 게이트 (screen feature 시) " , status:  " pending " , activeForm:  " 디자인 검증 대기 "   },
  { content:  " Step 7.5: Backend 코드 생성 (생성물 own:lead) " , status:  " pending " , activeForm:  " Backend 생성 대기 "   },
  { content:  " Step 7.7: 로컬 풀스택 통합 검증 " , status:  " pending " , activeForm:  " 로컬 통합 검증 대기 "   },
  { content:  " Step 8: 테스트 작성(S8w) + 직렬 실행(S8j) 게이트 " , status:  " pending " , activeForm:  " 테스트 대기 "   },
  { content:  " Step 8.3: BDD Coverage Gate " , status:  " pending " , activeForm:  " BDD 커버리지 검증 대기 "   },
  { content:  " Step 8.5: Pre-push 검증 + 린트 0건 + DCM 품질 개선 " , status:  " pending " , activeForm:  " 검증 대기 "   },
  { content:  " Step 8.7: Code Review Gate " , status:  " pending " , activeForm:  " 리뷰 게이트 대기 "   },
  // ⚠️ 9.0 이 9 보다 먼저다 (STEP_ORDER 와 같은 순서)
  { content:  " Step 9.0: 중복 착수 재확인 (--state all) " , status:  " pending " , activeForm:  " 중복 착수 재확인 대기 "   },
  { content:  " Step 9: PR 생성 (작업내역 아티팩트 발행 + 본문 링크, gate 0: 열린 자식 0건) " , status:  " pending " , activeForm:  " PR 생성 대기 "   },
  // ⚠️ Step 10 과 Step 11 을 한 항목( " Step 10-11 " )으로 묶지 않는다 — 정확 일치 조회가 둘 다 못 찾는다
  { content:  " Step 10: Review/QA 이동 " , status:  " pending " , activeForm:  " Pipeline 이동 대기 "   },
  { content:  " Step 10.5: CI 대기 ∥ 작업내역 아티팩트 CI 상태 갱신 " , status:  " pending " , activeForm:  " CI 대기 / 아티팩트 갱신 중 "   },
  { content:  " Step 11: 추가 리뷰 피드백 반영 " , status:  " pending " , activeForm:  " 리뷰 대기 "   },
  { content:  " Step 11.9: 머지 직전 열린-자식 재검증 " , status:  " pending " , activeForm:  " 열린 자식 재검증 대기 "   },
  { content:  " Step 12: 머지 승인 → S12g CI 재조회 → squash merge (머지 = Close) " , status:  " pending " , activeForm:  " 머지 승인 대기 "   },
  { content:  " Step 12.5: 이슈 Close 검증 + 열린-자식 불변식 확인 → base 브랜치 최신화 → 워크트리 반납 표시(12.6) " , status:  " pending " , activeForm:  " Close 검증 대기 "   },
]);

되돌림(back-edge) 시 상태 복구 — resetStepsToPending()#

enterRework() 이 호출하는 되돌림은 TodoWrite 조작 그 자체다. flow 블록의 invalidates: 목록에 있는 항목들을 completedpending 으로 되돌린다.

// invalidates: 목록의 항목만 pending 으로 되돌린다 (다른 항목은 건드리지 않는다)
async function resetStepsToPending(ids: string[]) {
  const todos = await getTodoList();
  TodoWrite(todos.map(t = >   {
    const id = t.content.match(/^Step ([0-9]+(?:\.[0-9]+)?)/)?.[1];
    return ids.includes(id) ? { ...t, status:  " pending "   } : t;
  }));
  console.warn(`♻️ 되돌림 ${ids.length}항목 → pending: ${ids.join( " · " )}`);
}

되돌리지 않은 back-edge 는 back-edge 가 아니다. validateStepPrerequisites()completed/skipped 를 통과시키므로, Step 7 로 돌아가도 되돌리지 않은 게이트는 재실행 없이 통과한다 — "Step 7로 돌아가서 수정"이라고 적혀 있는데 실제로는 아무 게이트도 다시 돌지 않는 상태가 그것이다.

Update Immediately on Step Completion#

// Step 4 complete 후 예시
TodoWrite([
  { content:  " Step 1: 작업 내용 분석 " , status:  " completed " , activeForm:  " 작업 분석 완료 "   },
  { content:  " Step 2: ZenHub 이슈 생성 " , status:  " completed " , activeForm:  " 이슈 생성 완료 "   },
  { content:  " Step 3: Product Backlog 이동 " , status:  " completed " , activeForm:  " Pipeline 이동 완료 "   },
  { content:  " Step 4: 브랜치 생성 " , status:  " completed " , activeForm:  " 브랜치 생성 완료 "   },
  { content:  " Step 5: In Progress 이동 " , status:  " in_progress " , activeForm:  " Pipeline 이동 중 "   },
  // ... 나머지 pending 유지
]);

State Tracking Rules#

RuleDescription
Only 1 in_progressOnly 1 in_progress state allowed at a time
Immediate updateChange to completed immediately on step completion
Skip markingN/A 단계는 status: "skipped" 로 둔다 — pending 으로 남기면 다음 단계 진입이 막힌다. content 의 Step {id}: 접두사는 유지한다(정확 일치 조회 대상)
Keep on failureKeep failed step as in_progress
Rework resetback-edge 진입 시 resetStepsToPending(invalidates) 로 해당 항목만 pending 으로 되돌린다 — 되돌리지 않으면 그 게이트들은 재실행되지 않는다
1:1 with STEP_ORDER항목 집합은 STEP_ORDER 와 1:1. 항목을 묶거나 빼면 validateStepPrerequisites() 가 throw 한다

Auto-Inference Details#

Type Inference Examples#

/cc-dev:run  " 저자 목록 화면 추가 " 
 → 키워드  " 추가 " ,  " 화면 "   감지 → feat

/cc-dev:run  " 로그인 버그 수정 " 
 → 키워드  " 버그 " ,  " 수정 "   감지 → fix

/cc-dev:run  " API 응답 캐싱 개선 " 
 → 키워드  " 개선 "   감지 → refactor

Screen Type Detection Examples#

/cc-dev:run  " 저자 목록 화면 추가 " 
 → 키워드  " 목록 "   감지 → ListBDD 자동 생성

/cc-dev:run  " 도서 상세 화면 구현 " 
 → 키워드  " 상세 "   감지 → DetailBDD 자동 생성

/cc-dev:run  " 저자 등록 폼 추가 " 
 → 키워드  " 등록 "   감지 → FormBDD 자동 생성

/cc-dev:run  " API 응답 캐싱 추가 " 
 → 화면 키워드 없음 → BDD 스킵

Error Handling#

Step-by-Step Recovery Strategy#

Failed StepStateRecovery Method
PM requirements refinement (Step 1.5)No changespm-spec-agent 실패 시 Step 1 키워드 추론 결과로 자동 폴백 + 경고 (파이프라인 계속 진행); 완전히 원치 않으면 --skip-pm
Issue creationNo changesRetry
Branch creationIssue existsRetry
BDD scenarioBranch exists--skip-bdd or manual writing
ImplementationBranch existsContinue work
CoUI change separation (Step 7.2)CoUI PR open/mergedCoUI PR 머지 대기 후 의존성 범프 재시도; 화면 diff에 CoUI 소스가 섞였으면 해당 커밋을 CoUI 저장소 브랜치로 분리 (git checkout -p / cherry-pick) 후 화면 브랜치에서 제거
Design verification (Step 7.3)Code complete도구/기기(figma-mcp/marionette-mcp/dart-mcp/device) 확인 후 재실행, 또는 --skip-design-verify(여전히 AskUserQuestion 확인 필요) — 잔존 diff는 pixel-loop:loop --mode=repair 1회 후 사용자 확인
TestsCode complete--skip-tests or manual fix
Local full-stack integration (Step 7.7)Code completeserverpod start (내장 Postgres) 재기동 후 dart test -t integration 재실행 (핫리로드 막히면 R 키로 재시작) · 3.x 폴백: docker compose up -d + dart run bin/main.dart · 또는 --skip-local-integration
Dart lint gateCode completedart fix --apply + melos run format (dcm format) + manual fix (up to 2 times) → 0 issues required
DCM quality gateCode completemelos run dcm:fix auto-fix → melos run dcm:analyze → manually fix remaining errors
Code Review GateCode complete--skip-review or manual fix
PR creationCode completeRun gh pr create manually
Code reviewPR exists--skip-review or manual application
작업내역 아티팩트 발행 (Step 9)PR 생성 전복구 불필요 — 비차단. PR 본문에 마크다운으로 인라인 폴백 후 PR 생성 계속 (rules/artifact-publishing.md §6). 나중에 수동 발행하려면 cc-dev:pr-work-artifact 스킬 직접 호출 후 PR 본문에 직접 링크 추가
작업내역 아티팩트 CI 상태 갱신 (Step 10.5)PR exists복구 불필요 — 비차단. 기존 URL·페이지 그대로 두고 계속
CI 실패 (Step 10.5)PR existspr-lifecycle-agent CI Check Handling — 수정 → runPrePushGate() → re-push (Step 10.5 재실행). L-10.6 3 라운드, 실패 check 집합이 줄어야 한다. 소진 시 BLOCKED('ci_exhausted')
CI 판정 불가 / 체크 0건 (Step 10.5 · S12g)PR existsgh pr checks {N} --json name,state,bucket 로 재확인. 통합 base 대상인데 0건이면 변경 파일이 워크플로 paths 안인지부터 본다 — 안이면 트리거를 고치고, 밖이면(문서 전용 등) 고칠 것이 없으므로 사람 승인 후 gh pr merge {N} --squash --admin. "체크 없음"을 통과로 넘기지 말 것이지만, 반대로 고칠 수 없는 것을 고치라고 무한 반복하지도 말 것
재작업 예산 소진 (L-7R/L-6R)PR 없음/있음blockIssue(issue, 'rework_exhausted')/('bdd_coverage_exhausted') — holding 이동 + 사유 코멘트 + 점유 해제 후 사람 판단. 카운터는 사이클 전역이라 같은 세션에서 재시도로 늘어나지 않는다
점유 중 이슈 (Step 0.4 other-live)착수 전 — 브랜치·보드 모두 손대지 않음복구할 것이 없다. 상대 세션이 끝난 뒤 /cc-dev:run {N} 재실행. 상대가 죽은 것이 확인되면 TTL(4h) 경과 후 자동 인수되며, 그 전에 강제로 이어받으려면 --force-claim
착수 후 중단(그 밖의 [INCOMPLETE: *])브랜치·PR 은 그대로reflectBoardState(issue, "aborted")(holding 이동) + 사유 코멘트 + releaseClaim(). ⛔ In Progress 에 남긴 채 끝내지 않는다 — 그 잔류가 다음 세션에게는 "누가 하고 있다"로 읽힌다
MergePR existsWait for CI pass then manual merge
무인 머지 정지 (Step 12, --unattended without --merge=pre-authorized)PR exists[INCOMPLETE: merge_approval_unavailable]. PR·브랜치는 그대로 — --merge=pre-authorized 와 함께 재실행하거나 사람이 PR을 직접 머지
열린 자식 게이트 (Step 9 gate 0 / Step 11.9)Pre-PR / Pre-merge열린 자식을 먼저 완료(/cc-dev:batch {N}) 후 재실행. unknown(조회 실패)이면 gh auth status 확인 후 재실행 — "자식 없음"으로 넘기지 말 것
Issue Close 검증 (Step 12.5)MergedGitHub 미close 시 gh issue close {N} --reason completed; ZenHub 미동기화 시 updateIssue state:CLOSED
열린 자식 불변식 위반 (Step 12.5-3)Merged & closed자동 복구: gh issue reopen {N} + 코멘트 + In Progress 복구(reopen 이후). 남은 자식이 의도적으로 범위 밖이면 부모-자식 링크를 끊거나 그 자식들을 닫고 재실행
워크트리 반납 표시 실패 (Step 12.6)Merged & closed⚠️ 경고만 남기고 사이클은 성공으로 끝낸다 — 표시는 힌트일 뿐이고, 상위 batch 의 회수 판정은 PR/워킹트리 실측(안전 판정 5종)으로 독립적으로 선다. ⛔ 여기서 워크트리를 직접 지우지 말 것

  • /cc-dev:go "요청" - 원스톱 상위 오케스트레이터: breakdown→계획 승인(1회)→batch/run→머지. 이 커맨드에 --merge=pre-authorized·--unattended를 전파한다
  • /cc-dev:batch {number} - Epic 등 계층 배치 오케스트레이터. leaf 이슈는 이 커맨드로 위임하며 자신이 받은 --merge=pre-authorized·--unattended를 그대로 전파한다
  • /cc-dev:run {number} - 기존 이슈번호로 사이클 시작 (Steps 1–3 생략)
  • 이 커맨드를 직접 헤드리스로 디스패치하는 다른 오케스트레이터(자체 Orca 코디네이터 등)도 동일하게 --merge=pre-authorized --unattended 를 넘겨야 한다 — 위 "Unattended & Approval Contract" 참고
  • /cc-dev:bugfix - Bug fix dedicated cycle
  • /cc-quality:bug-report - Bug report creation
  • /cc-quality:review - Run code review
  • /cc-dcm:quality - DCM code quality analysis and auto-fix
  • /cc-quality:checklist:feature-complete - Completion checklist
  • cc-pixel-loop:loop - Step 7.3의 저작/수렴 엔진 (Figma↔Flutter 픽셀 대조 반복)
  • cc-pixel-loop:visual-verify - Step 7.3의 읽기 전용 최종 판정 도구
  • cc-marionette:smoke - Step 7.3의 선택적 위젯 조립 스모크 체크
  • cc-flutter:flutter-dev-pixel-loop 스킬 - Figma URL/라우트 자동 추론 규칙 (수동 재검증은 /cc-pixel-loop:loop 직접 호출)
  • cc-dev:pr-work-artifact 스킬 - Step 9의 작업내역 아티팩트 발행 + PR 본문 링크 프로토콜 (Step 10.5는 같은 URL의 CI 상태 갱신만 수행)
  • orca-worktree-lifecycle 스킬 - Step 12.6의 SoT. 이 커맨드는 표시만 하고 자기 워크트리를 제거하지 않는다 — 실제 회수는 워크트리를 만든 /cc-dev:batch 의 머지 큐가 수행한다
  • stacked-prs - GitHub 네이티브 Stacked PR 선택형 실행 모드 (public preview). Step 4 base 결정 · Step 9 gh stack submit · Step 12 gh stack merge 의 접점 SoT. 기본값은 기존 수동 계층 그대로다
  • rules/artifact-publishing.md - 아티팩트 발행 메커니즘 SoT (공개 범위·URL 유지·폴백·민감정보 금지)
  • pm-spec-agent - PM requirements refinement (Step 1.5, non-interactive)
  • issue-branch-agent - Branch creation
  • issue-state-agent - Issue state management
  • bdd-scenario-agent - BDD scenario generation (cc-flutter 제공 — cross-plugin 선택 의존, 미설치 시 BDD 단계 skip)
  • implementation-agent - Code implementation
  • test-runner-agent - Test execution
  • pr-lifecycle-agent - PR management