ZenHub Conventions
Issue management rules using ZenHub.
Issue management rules using ZenHub.
Per-Project Configuration#
ZenHub workspace information is managed in .mcp.json at the project root.
Pipeline ID, Repository ID, Issue Type ID, and Organization ID are not hardcoded but
dynamically queried via MCP tools.
Workspace Info Query (required on first call)#
// 1. Pipeline ID + Repository ID query
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
// → pipelines[]: { id, name } (New Issues, Icebox, Product Backlog, ...)
// → githubRepositories[]: { id, name }
// → zenhubOrganization: { id, name }
const repositoryId = workspace.githubRepositories.find(r => /* select GitHub repo */).id;
// 2. Issue Type ID query
const issueTypes = await mcp__zenhub__getIssueTypes({ repositoryId });
// → issueTypes[]: { id, name, level } (Initiative, Project, Epic, Feature, Bug, Task, Sub-task, ...)
Rule: Always query the current workspace IDs via the above two calls before creating/moving ZenHub issues. IDs can be cached and reused within the same session.
⚠️ Token / Workspace Scope Pitfall (토큰이 다른 워크스페이스에 기본 연결된 경우)#
ZenHub API 토큰(.mcp.json 의 Authorization 헤더)은 단일 기본 ZenHub 워크스페이스에 스코프된다. 한 조직(Organization) 안에 여러 저장소·여러 워크스페이스가 있을 때(예: 프로젝트 전용 워크스페이스 + 범용/공용 워크스페이스), 토큰이 의도한 프로젝트의 워크스페이스가 아니라 다른 워크스페이스에 기본 연결돼 있으면
일부 호출만 조용히 성공하는 혼란스러운 상태가 된다:
| 호출 | 워크스페이스 의존 | 대상 저장소가 다른 워크스페이스에 있어도 |
|---|---|---|
createGitHubIssue({repositoryId, issueTypeId, parentIssueId}) |
없음(노드 ID 직접 지정) | ✅ 정상 동작 |
getIssueTypes({repositoryId}) | 없음 | ✅ 정상 동작 |
setIssueEstimate({issueId, estimate}) |
없음(이슈 노드 ID 직접 지정) | ✅ 정상 동작 |
setParentForIssues({parentIssueId, childIssueIds}) | 없음 | ✅ 정상 동작 |
getWorkspacePipelinesAndRepositories() |
있음 — 토큰의 기본 워크스페이스만 반환 | ⚠️ 대상 저장소가 목록에 아예 없음 |
searchLatestIssues(query) |
있음 — 토큰의 기본 워크스페이스만 검색 | ⚠️ 대상 저장소 이슈는 검색되지 않음(빈 배열) |
moveIssueToPipeline({issueId, pipelineId}) |
있음 — Pipeline 은 워크스페이스 종속 객체 | ❌ 실패: null is not an object (evaluating 'resp.moveIssue.issue.pipelineIssue.pipeline') |
함정: getIssueTypes({repositoryId: X}) 가 정상 응답한다고 해서 워크스페이스가 맞다고 판단하지 말 것 — 이 호출은 repositoryId 를 직접 지정하므로 워크스페이스와 무관하게 항상 성공한다. 이슈 생성까지 성공적으로 끝나서 문제가 없어 보이다가, 파이프라인 이동 단계에서 처음으로 실패가 드러난다.
진단#
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const hasTarget = workspace.githubRepositories.some(r => r.name === "<target-repo>");
if (!hasTarget) {
// 토큰이 잘못된 워크스페이스에 스코프됨. 이슈 생성/estimate 는 되지만
// moveIssueToPipeline·searchLatestIssues 등 워크스페이스 의존 호출은 신뢰 불가.
}
검증 (workspace-scoped 호출을 못 믿을 때)#
이슈가 실제로 대상 저장소에 반영됐는지는 ZenHub 도구가 아니라 GitHub 자체로 확인한다:
gh issue view <number> --repo <owner>/<repo> --json number,title,parent,issueType,subIssuesSummary
해결#
- 대상 프로젝트의 ZenHub 워크스페이스에 정식으로 스코프된 API 토큰을 발급/확인해
.mcp.json에 설정한다. -
.mcp.json은${VAR}/${VAR:-default}환경변수 치환을command/args/env/url/headers전 필드에서 지원한다(Claude Code 공식 기능, MCP 문서 "Environment variable expansion" 참조)..mcp.json은 대개 git 에 커밋되는 파일이므로 raw 토큰 하드코딩 대신 환경변수 참조를 쓴다:"--header", "Authorization:${ZENHUB_API_TOKEN}" -
⚠️ 셸 프로필(
~/.zshrc등)에 새로export한 환경변수는 이미 실행 중인 프로세스에 반영되지 않는다./mcp재연결만으로는 부족하다 — 완전히 새 터미널 창에서 Claude Code 를 재시작해야 한다(같은 탭/세션 재사용 금지, 필요시 먼저source ~/.zshrc). GUI 로 실행 중이면~/.zshrc자체가 로드되지 않을 수 있어 CLI 실행을 권장한다. -
여러 워크스페이스에 걸쳐 작업해야 한다면(예: 범용 워크스페이스 + 프로젝트별 워크스페이스), 프로젝트마다
.mcp.json의 zenhub 서버 항목을 프로젝트 전용 환경변수 이름으로 분리한다.
Issue Type Hierarchy#
Issue types are hierarchical (level field, lower = higher level). Standard workspace hierarchy:
| Level | Type | Scope | Parent |
|---|---|---|---|
| 1 | Initiative | Multi-quarter strategic theme | — |
| 2 | Project | Product/milestone unit (multiple features) | Initiative (optional) |
| 3 | Epic | Single feature unit | Project (optional) |
| 4 | Feature / Bug / Task | Story (screen/work unit) | Epic |
| 5 | Sub-task | Detailed task | Story |
Hierarchy rules:
-
Parent-child links use
parentIssueId(at creation) orsetParentForIssues(after creation) -
A child's type
levelmust be greater than its parent's level (e.g., Epic(3) under Project(2) ✅, Epic under Epic ❌) -
Project/Initiative types may not exist in every workspace — always check via
getIssueTypes()first; if absent, fall back to Epic as the top level and warn the user
const projectType = issueTypes.find(t => t.name === "Project");
if (!projectType) {
// Fallback: skip Project level, create Epic(s) without parent + warn
}
Child Enumeration Contract (fail-closed) ⚠️#
mcp__zenhub__searchLatestIssues({ query: "parent:{graphqlId}" }) is not
a reliable way to
enumerate an issue's children. It is fine for read-only prioritization views, but never
as the
input to a gate that decides whether a parent may close:
| Limitation | Consequence |
|---|---|
| Returns at most the latest 20 issues (tool contract: "Get the latest 20 issues") | Older children fall outside the window and silently disappear from the result |
Closed issues are served by a separate tool (searchClosedIssues) |
A children list built from it is open-biased and structurally incomplete |
An unsupported/failed filter returns [], not an error |
"the lookup broke" and "there are no children" become indistinguishable |
GitHub sub-issues are the source of truth for parent/child — that is the same link ZenHub's
board renders as the Sub-Issues % ring, and the same GD-04 conclusion as
run.md Step 0.6. Enumerate there, and treat an unreadable result as
UNKNOWN, never as "no children":
// GD-04 의 `ghNativeSubIssues()` = 이 함수의 조회 부분. 이 절이 그 **단일 정의**이며,
// 다른 문서는 여기를 참조한다(재구현 금지).
// → { children: [{number, state}], known: boolean }
async function listChildren(issueNumber) {
const repo = (await Bash(`gh repo view --json nameWithOwner -q .nameWithOwner`)).trim();
const [owner, name] = repo.split("/");
// recency 창 없음 — 존재하는 모든 sub-issue 를 반환한다. totalCount 로 잘림까지 검출한다.
const raw = await Bash(
`gh api graphql -f query='{ repository(owner:"${owner}", name:"${name}") ` +
`{ issue(number:${issueNumber}) { subIssues(first:100) { totalCount nodes { number state } } } } }' ` +
`2>/dev/null || echo "__LOOKUP_FAILED__"`
);
// ⚠️ 실패 시 gh 는 에러 JSON 을 **stdout** 으로 내보내므로 마커는 **부분일치**로 본다
// (완전일치 비교는 앞에 붙은 에러 JSON 때문에 매칭에 실패해 UNKNOWN 을 놓친다)
if (raw.includes("__LOOKUP_FAILED__")) return { children: [], known: false }; // ⛔ UNKNOWN
const sub = JSON.parse(raw)?.data?.repository?.issue?.subIssues;
if (!sub) return { children: [], known: false }; // ⛔ UNKNOWN (구조 불일치)
const children = sub.nodes.map(n => ({ number: n.number, state: n.state }));
// 100건 초과면 nodes 가 잘렸다 — "전부 닫혔다" 판정의 근거로 쓸 수 없으므로 UNKNOWN.
// (페이지네이션(`after: endCursor`)을 구현했다면 전량을 모은 뒤 known: true)
if (sub.totalCount > children.length) return { children, known: false };
return { children, known: true }; // [] = verified leaf
// 실측(2026-07-31): 자식 있는 Epic → totalCount 10 / nodes 10 (open 5 + closed 5),
// 자식 없는 이슈 → totalCount 0 + nodes [] (검증된 leaf),
// 없는 이슈 번호 → issue: null → UNKNOWN
}
// tri-state — only "none" may ever close a parent
// "open" → has at least one open child → block
// "none" → verified zero open children → may close
// "unknown" → lookup failed / no gh access → block (판정 불가 ≠ 통과)
//
// ⚠️ 이 워크스페이스의 계층은 ZenHub `parentIssueId`/`setParentForIssues` 로 만든다. 실측(#3451)
// 으로 그 링크가 GitHub sub-issue 로도 나타남을 확인했지만(그래서 GitHub 이 primary), 링크가
// 누락된 자식이 있을 수 있으므로 ZenHub `parent:` 결과를 **합집합**으로 얹는다. 합집합은 자식을
// 더 찾을 뿐 빼지 않으므로 fail-closed 방향으로만 작동한다.
async function openChildrenStatus(issueNumber, opts = {}) {
const { children, known } = await listChildren(issueNumber);
// ZenHub union (best-effort — 실패해도 GitHub 결과를 무효화하지 않는다)
let zhKids = [];
try {
const self = (await mcp__zenhub__searchLatestIssues({ query: `#${issueNumber}` }))
.find(i => i.number === issueNumber);
if (self?.id) zhKids = await mcp__zenhub__searchLatestIssues({ query: `parent:${self.id}` }) ?? [];
} catch { /* union 실패는 무시 — primary 판정을 바꾸지 않는다 */ }
const merged = new Map();
for (const c of children) merged.set(c.number, c);
for (const c of zhKids) if (!merged.has(c.number)) merged.set(c.number, c); // 추가만, 제거 없음
const all = [...merged.values()];
const open = all.filter(c => (c.state || "").toUpperCase() !== "CLOSED");
if (open.length) return { status: "open", open, all }; // 어느 쪽이든 열린 자식을 봤으면 확정
if (!known) return { status: "unknown", open: [], all }; // GitHub 조회 실패 + 열린 자식 못 봄 → 판정 불가
return { status: "none", open: [], all };
}
unknown 의 degradation (Step 0 계약 준용): unknown 은 원칙적으로 차단이지만,
구조적으로 자식을 가질 수 없는 이슈(issueType === "Sub-task" — 계층 최하위)에서는 조회 실패를 경고 후 통과로 처리한다. 그렇지 않으면
subIssues 조회를 못 쓰는 환경(GHES·제한 토큰·구버전 gh)에서 이 불변식과 무관한 leaf 작업까지 전부 멈춘다. 컨테이너 타입(Initiative/Project/Epic/Feature/Bug/Task)에서는 degradation 없이 차단한다 — 사고가 난 지점이 정확히 거기다.
// 호출부 공통 판정 헬퍼
function mayClose(kids, issueType) {
if (kids.status === "none") return true;
if (kids.status === "open") return false; // 항상 차단
return issueType === "Sub-task"; // unknown: leaf 만 경고 후 통과
}
예외 1곳 — 머지 이후 지점: 3번째 체크포인트(종료 직후)에서는
unknown을 차단하지 않고 경고한다. 머지가 이미 끝나 차단할 대상이 없고, 조회 실패를 근거로 정상 종료된 이슈를 되돌리면 오탐 피해가 더 크다. 그 지점의open판정만 재오픈을 유발한다.
-
[]from a successful call is a verified leaf.[]from a failed call must block. -
⛔ Never write
const open = kids.filter(...); if (open.length > 0) throwagainst a query that can silently return[]— that construct passes the gate exactly when the lookup broke. -
ZenHub's
parent:search may be added as a union (belt-and-braces for children whose GitHub sub-issue link was never created), never as a substitute.
Issue Creation Rules#
Use GitHub Issues (required)#
All issues must be created as GitHub issues (ZenHub issues are prohibited)
// ✅ CORRECT: Create GitHub issue
mcp__zenhub__createGitHubIssue({
title: "Feature development",
repositoryId: repositoryId, // From getWorkspacePipelinesAndRepositories()
issueTypeId: epicTypeId, // From getIssueTypes()
})
// ❌ WRONG: Create ZenHub issue (prohibited)
mcp__zenhub__createZenhubIssue({...})
Reasons:
- GitHub issues support pipeline moves
- Timeline settings work correctly
- Auto-link with GitHub PRs
- Easy search and filtering
Existing-Children Reuse (중복 재발행 금지) ⚠️#
컨테이너 이슈(Initiative/Project/Epic/Story)를 착수하면서 그 아래 작업 이슈를 만들려 할 때는, 항상 먼저 Child Enumeration Contract 로 기존 자식을 전수 조회한다. 이미 그 작업을 담고 있는 자식이 있으면:
| 상황 | 행동 |
|---|---|
| 같은 작업을 담은 자식이 이미 있다 | 그 이슈를 그대로 처리한다 — 새 이슈를 만들지 않는다 |
| 기존 자식의 본문·AC 가 낡았다 | updateIssue 로 그 자식을 갱신한다 (새로 만들어 대체하지 않는다) |
자식 조회가 실패했다(known: false) |
새 자식을 만들기 전에 멈추고 확인받는다 — 조회 실패를 "자식 없음"으로 읽으면 중복이 생긴다 |
⛔ 금지: 기존 자식과 내용이 겹치는 새 이슈를 만들어 그것만 처리하고 닫는 것. 원본 자식은 열린 채
남고, 부모는 Parent Closure Invariant
위반 상태(닫힌 부모 + 열린 자식 + 진행률 0%)로 착지한다. 2026-07-31 #3451
사고의 실제 발생 경로가
이것이다 — #3478–#3482
를 처리하는 대신 #3514–#3518
을 새로 만들어 처리했다.
/cc-dev:runStep 0.5 의 중복 착수 preflight 는 이슈 번호·브랜치·PR 기준이라 이 경로를 못 잡는다 ("같은 작업의 새 이슈"는 번호가 다르므로). 그래서 이 규칙은 생성 시점의 별도 게이트다.
Issue Body HTML Contract (본문 = 계약, 서술 = 저장소 HTML)#
이 절은 이슈 본문 표면에 무엇을 싣는지를 정한다. 저장 메커니즘(경로·커밋 전 점검·링크 형식·대기 중인 HTML·Degradation·민감정보 금지)은
rules/artifact-publishing.md가 SoT 이며 여기서 복제하지 않는다. 같은 SoT 를 쓰는 다른 표면: PR 작업내역(skills/pr-work-artifact/SKILL.md).
이슈 본문에 기획서를 통째로 붓지 않는다. 긴 서술은 저장소 HTML(.claude/docs/{scope}/issue-{number}.html)로 남겨 링크만 걸고, 자동 파이프라인이 파싱하는 계약 블록과 짧은 요약은 본문에 유지한다.
판정 기준은 artifact-publishing.md §3 그대로다 — 기계가 읽으면 본문, 사람만 읽으면 HTML. 애매하면 본문에 남긴다. 그리고
본문만으로도 이슈가 이해돼야 한다 — HTML 은 그 이슈의 작업 브랜치가 생기기 전까지 로컬에만 있어 아무도 열 수 없다.
| 섹션 | 위치 | 소비자 / 이유 |
|---|---|---|
## 📋 개요 (짧은 요약 2~3줄) |
본문 | HTML 이 링크되기 전에도 이슈가 읽혀야 한다 |
## ✅ Acceptance Criteria |
본문 | agents/dev/implementation-agent.md · agents/dev/test-runner-agent.md 가 파싱 |
## 📊 범위 (포함/제외) | 본문 | 스코프 드리프트 판정 기준 |
## 🏁 Definition of Done | 본문 | PR 게이트 체크리스트 |
## 🔢 우선순위 표 (Epic/Project) |
본문 | 이 문서 Within-Pipeline Ordering 이 SoT 로 규정 |
Closes #N · 부모/자식 참조 | 본문 | GitHub·ZenHub 자동 연결 |
## 📄 상세 기획 링크 블록 |
본문 — 작업 브랜치가 생긴 뒤 1회 수정으로 추가 | HTML 진입점 |
| 배경 · 문제 정의 전문 | HTML | 사람이 읽는 서술 |
| 비즈니스 가치 서술 | HTML | 사람이 읽는 서술 (요지 한 줄은 개요에) |
| 화면·상태 설계 표, 와이어프레임 | HTML | 표·이미지가 HTML 에서 훨씬 읽힘 |
| 기술 설계 · 아키텍처 다이어그램 | HTML | 인라인 SVG 로 그린다 — 외부 렌더러를 싣지 않는다(SoT §4) |
| DDR 전문(근거 사다리 서술) | HTML | 본문에는 결정 한 줄만 남긴다 |
| 참고 링크 · 선례 조사 기록 | HTML | 사람이 읽는 서술 |
계층별 적용
| 계층 | 상세 기획 HTML | 비고 |
|---|---|---|
| Initiative · Project | 항상 | 전략 서술 비중이 가장 큼 |
| Epic | 항상 | 우선순위 표는 본문에 유지 |
| Feature · Bug · Task | 서술이 20행 초과일 때만 | AC·범위·DoD 는 본문에 유지. 20행 이하면 서술을 본문에 그대로 붙인다 |
| Sub-task | 만들지 않음 | 본문이 3~5행 — 링크 오버헤드가 내용보다 크다 |
순서 — 이슈 먼저, HTML 커밋과 링크는 작업 브랜치가 생긴 뒤
artifact-publishing.md §5·§6 을 이슈 표면에 적용한 것이다. 파일 경로의 이슈 번호와 커밋할 브랜치는
이슈를 만든 뒤에야 생기므로, 링크는 이슈 생성 시점이 아니라 브랜치가 생긴 뒤 본문을 한 번 고쳐 붙인다.
// 1) 서술과 계약 블록을 위 판정 기준대로 분리
const { contract, summary, narrative } = splitBody(item);
// 2) 계약 블록 + 짧은 요약으로 이슈를 만든다 — 이 시점엔 링크 블록이 없다
const issue = await mcp__zenhub__createGitHubIssue({ title, body: buildBody(contract, summary), ... });
// 3) HTML 을 이슈 번호 경로에 쓴다: .claude/docs/{scope}/issue-{issue.number}.html
// 계획 단계(breakdown · --plan-only · triage)면 여기서 멈춘다 — 로컬에 대기 (SoT §6)
// 4) 그 이슈의 작업 브랜치를 확보한 직후 (run.md Step 4 · batch.md Phase 1.5):
// 첫 커밋 → push → 이슈 본문 1회 수정으로 링크 블록을 덧붙인다 (H1 바로 아래 — 통일 템플릿 1번 자리)
본문 링크 블록
형식의 SoT 는 artifact-publishing.md §7 이다. 이슈 표면에 적용하면:
## 📄 상세 기획
- 경로: `.claude/docs/{scope}/issue-{number}.html`
- 링크: https://github.com/{owner}/{repo}/blob/{work_branch}/.claude/docs/{scope}/issue-{number}.html
↳ {HTML 에 담긴 섹션 목록}
> GitHub 은 HTML 을 소스로 보여 준다 — 렌더된 화면은 체크아웃에서 `open .claude/docs/{scope}/issue-{number}.html` 로 연다.
⚠️ 저장소 권한이 곧 공개 범위다 (
artifact-publishing.md§1). 공개 저장소의 이슈라면 HTML 도 공개되고, 이력은 영구다. 고객 원문·로그·스크린샷을 옮길 때 개인정보·토큰을 지운다.
갱신 = 같은 파일
artifact-publishing.md §9 그대로. 이슈 표면에서의 귀결: 스펙이 바뀌면 그 이슈의 작업 브랜치에서
같은 파일을 고치고(열린 PR 이 있으면 다음 코드 커밋에 함께), 링크 블록은 건드리지 않는다. 계약(AC·범위)이 바뀌면
그것은 본문을 updateIssue 로 고친다 — HTML 만 고치면 파이프라인은 옛 AC 를 읽는다.
Degradation Contract
artifact-publishing.md §6·§10 그대로 — 계획 단계에 브랜치가 없는 것은 대기이지 실패가 아니다.
커밋할 수 없으면(ignore 대상·줄여도 과대·push 실패) 서술을 본문에 마크다운으로 인라인하고 로그 한 줄을
남긴다. 어느 경우에도 이슈 생성·착수를 중단하지 않는다.
소비자 측 (본문을 읽는 쪽)
-
AC 파싱 대상은 언제나 이슈 본문이다. HTML 은 보조 컨텍스트다 — 로컬에 있으면 배경·설계를
읽는 데 써도 되지만, AC 를 찾으려고 HTML 을 뒤지거나 링크를
WebFetch하지 않는다(다른 워크트리· 머신에는 없고, 브랜치가 지워지면 브랜치 링크는 404 다). - 본문에 AC 가 없으면 그것은 "HTML 을 열어야 한다"는 신호가 아니라 이슈가 이 규약을 위반한 것이다. HTML 을 뒤지는 우회 대신 이슈를 고친다.
ID Query Patterns#
Finding Pipeline ID#
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const pipelines = workspace.pipelines;
// Find by name
const inProgress = pipelines.find(p => p.name === "In Progress");
const reviewQA = pipelines.find(p => p.name === "Review/QA");
// Note: do NOT route completed issues to "Done" — Done ≠ closed (see Issue Closure Policy).
Finding Issue Type ID#
const types = await mcp__zenhub__getIssueTypes({ repositoryId });
// Find by name (each type has { id, name, level })
const projectType = types.find(t => t.name === "Project");
const epicType = types.find(t => t.name === "Epic");
const featureType = types.find(t => t.name === "Feature");
const bugType = types.find(t => t.name === "Bug");
const subtaskType = types.find(t => t.name === "Sub-task");
Finding Repository ID#
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const repos = workspace.githubRepositories;
// Find GitHub repo (by name)
const githubRepo = repos.find(r => r.name === "my-repo");
Issue Creation Examples#
Project Creation (top-level)#
const projectTypeId = issueTypes.find(t => t.name === "Project").id;
// Create Project (GitHub issue) — wraps multiple Epics
const project = await mcp__zenhub__createGitHubIssue({
title: "Service name/milestone name",
body: "Project description, goals, Epic list...",
repositoryId: repoId,
issueTypeId: projectTypeId,
labels: ["project", "p0"], // Priority label decided BEFORE creation
});
// Link Epics created later as children
await mcp__zenhub__setParentForIssues({
parentIssueId: project.id,
childIssueIds: [epic1.id, epic2.id],
});
Epic Creation#
// 1. Dynamic ID query
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const repoId = workspace.githubRepositories[0].id; // Or find by name
const issueTypes = await mcp__zenhub__getIssueTypes({ repositoryId: repoId });
const epicTypeId = issueTypes.find(t => t.name === "Epic").id;
const orgId = workspace.zenhubOrganization.id; // Organization ID
// 2. Create Epic (GitHub issue)
const epic = await mcp__zenhub__createGitHubIssue({
title: "Feature name",
body: "Epic description...",
repositoryId: repoId,
issueTypeId: epicTypeId,
});
// 3. Set timeline
await mcp__zenhub__setDatesForIssue({
issueId: epic.id,
startDate: "2026-01-27",
endDate: "2026-02-17",
zenhubOrganizationId: orgId,
});
// 4. Move pipeline
const backlogPipeline = workspace.pipelines.find(p => p.name === "Product Backlog");
await mcp__zenhub__moveIssueToPipeline({
issueId: epic.id,
pipelineId: backlogPipeline.id,
});
Story Creation#
const featureTypeId = issueTypes.find(t => t.name === "Feature").id;
// Create Story (under Epic)
const story = await mcp__zenhub__createGitHubIssue({
title: "Feature name",
body: "Story description...",
repositoryId: repoId,
issueTypeId: featureTypeId,
parentIssueId: epic.id, // Link to Epic
});
// Set Story Points
await mcp__zenhub__setIssueEstimate({
issueId: story.id,
estimate: 5,
});
Priority Review and Pipeline Sorting (required after creation)#
After creating Project/Epic/Story issues, always run a priority review and place each issue in the appropriate pipeline.
Priority Scoring Criteria#
Score each issue on 4 axes and assign a priority tier:
| Axis | Question | Weight |
|---|---|---|
| Dependency (blocker) | Do other Stories depend on this? (e.g., Entity/API foundation) | High |
| Business value | Is it on the Epic's core user path? | High |
| Risk/uncertainty | Technically uncertain → tackle early to reduce risk | Medium |
| Effort (points) | Among equals, smaller points first (fast feedback) | Low |
판단은 Jev, 계산은 코드. 항목마다
cc-dev:jev-reflex세트priority-axes(state:item+ 같은 계획의siblings)로 의존(others_dependnoul)·가치(valuescore 0 nice-to-have · 1 supporting · 2 core path)· 위험(riskscore 0 known · 1 some unknowns · 2 uncertain)을 받고, 아래 표로 티어와 impact 를 코드가 정한다. 형제 쌍의depends-on판정(아래 Epic Dependency)이 있으면others_depend는 그것에서 계산하고, 이 noul 은 쌍 판정이 없을 때만 쓴다. reflex 가 아닌 축은 Claude 가 같은 기준으로 판단한다. Effort 는 Story Point 가 맡는다. 등급은 가장 가까운 단계로 읽되 x.5 는 낮은 쪽이다(Math.ceil(v - 0.5)— 애매하면 올리지 않는다).
결과 규칙 (아래 Tier 표와 같은 뜻 — 팀이 바꿀 수 있다) P0 다른 항목이 의존(blocker) — 먼저 해야 한다 P2 의존 없음 그리고 가치 0 (nice-to-have) P1 그 밖 — 의존하는 항목이 없는 핵심 경로·보조 기능 impact 가치 + (의존 ? 1 : 0) + (위험 2 ? 1 : 0)→ 3 이상 high · 1~2 med · 0 low (effort 는 넣지 않는다 — Matrix 이중 계산 방지)
| Tier | Label | Criteria |
|---|---|---|
| P0 | p0 | Blocker — other Stories depend on it; must be done first |
| P1 | p1 | Core-path or supporting feature in scope, no dependents |
| P2 | p2 | Improvement/nice-to-have, deferrable |
⚠️ Labels must be set at creation time —
updateIssuedoes not support label changes. Therefore, the priority review happens before issue creation; labels are passed tocreateGitHubIssue.
Priority → Pipeline Placement#
| Target | Pipeline |
|---|---|
| Project / Epic | Product Backlog |
Story P0 (with --sprint) |
Sprint Backlog + addIssuesToSprints |
| Story P0/P1 (no sprint) | Product Backlog |
| Story P2 | Icebox |
| Sub-task | Follows parent Story (no separate move) |
Within-Pipeline Ordering#
moveIssueToPipeline does not support a position parameter, so exact in-pipeline ordering cannot be set via MCP. Approximate it with:
-
Move in descending priority order — move P0 issues first, then P1, then P2 (one
moveIssueToPipelinecall each, sequentially) -
Record the priority table in the parent issue body — Epic/Project body includes a
## 🔢 우선순위table (rank / issue # / tier / rationale) as the source of truth - Fine-grained drag ordering, if needed, is adjusted manually on the ZenHub board (guide the user)
Sprint Selector Resolution#
--sprint 값을 스프린트로 해석하는 표준 규칙 — 모든 커맨드(/cc-dev:run, /cc-dev:zenhub:breakdown, …)가 동일하게 따른다:
| 셀렉터 | 해석 | MCP |
|---|---|---|
current (기본) |
활성 스프린트 | getSprint() (id 없이 호출 = 활성) |
next | 다음 스프린트 | getUpcomingSprint() |
| 숫자 / 이름 일부 | 매칭되는 열린 스프린트 | listRecentSprints().openSprints.find(s => s.name.includes(sel)) |
⚠️
getUpcomingSprint()는 '다음' 스프린트다 — '활성'이 아니다.current를getUpcomingSprint로 해석하면 이슈가 한 스프린트 뒤로 밀려 활성 스프린트/번다운에서 비게 된다.current는 반드시getSprint()를 쓴다.
async function resolveSprint(selector) {
if (selector === "next") return await mcp__zenhub__getUpcomingSprint();
if (!selector || selector === "current") return await mcp__zenhub__getSprint(); // 활성
const { openSprints } = await mcp__zenhub__listRecentSprints();
return openSprints.find(s => s.name.includes(String(selector)));
}
Roadmap Visibility Contract (모든 이슈를 타임라인에 노출)#
로드맵 타임라인은 setDatesForIssue 의 start/end 로 렌더되며, setDatesForIssue 는 상위 타입(Epic/Project/Initiative) 전용이다(MCP 스키마 제약). 따라서
2-레인 규칙으로 모든 추적 이슈가 보이게 한다:
| 이슈 레벨 | 타임라인 노출 방법 | MCP |
|---|---|---|
| Project / Epic / Initiative | 명시적 기간(start/end) | setDatesForIssue (+ zenhubOrganizationId) |
| Story / Feature / Bug / Task | 활성 스프린트 멤버십 (날짜 설정 ❌) | addIssuesToSprints(resolveSprint("current")) |
저수준 타입에
setDatesForIssue를 쓰지 말 것 — 스키마상 off-spec 이며 로드맵 렌더가 비정상일 수 있다. 스프린트 레인으로 노출한다.
Matrix Signals (impact × effort)#
ZenHub Matrix 는 impact(가치) × effort(노력) 2축으로 이슈를 plot 한다. 두 축을 모두 영속화해야 매트릭스가 채워진다:
| 축 | 신호 | 저장 방법 |
|---|---|---|
| effort (X) | Story Point | setIssueEstimate |
| impact (Y) | Business value + Dependency + Risk (effort 제외) | 불변 라벨 impact-high / impact-med / impact-low |
⚠️ 라벨은 생성 후 변경 불가(
updateIssue미지원) → 생성 시점에createGitHubIssue의 labels 로 확정한다. impact 에 effort 를 섞으면 매트릭스에서 이중 계산되므로 분리한다. P0/P1/P2 티어는 정렬용이고, impact 라벨은 매트릭스 축용으로 별개다.
// Example: priority-ordered placement after creation
const backlog = workspace.pipelines.find(p => p.name === "Product Backlog");
// fail-closed: 못 찾으면 throw (이름 불일치 시 부분 배치 후 무음 중단 방지)
if (!backlog) throw new Error(`'Product Backlog' 파이프라인 없음. 라이브: ${workspace.pipelines.map(p => p.name).join(", ")}`);
const sorted = stories.sort((a, b) => a.priorityRank - b.priorityRank); // P0 → P1 → P2
for (const s of sorted) {
await mcp__zenhub__moveIssueToPipeline({ issueId: s.id, pipelineId: backlog.id });
}
Mandatory Story Point Estimate (모든 이슈, 스프린트 배정과 무관)#
setIssueEstimate 는 스프린트에 넣는지와 무관하게 이슈 생성 시점에 항상 호출한다 — Product
Backlog/Icebox 에 남아 아직 스프린트에 배정되지 않은 이슈도 예외가 아니다. 포인트가 없으면 이후
"Epic 롤업" · "팀 속도"(아래 Epic Velocity Calculation) 계산에 쓸 실제 데이터가 없다는 뜻이고,
이 워크스페이스에서 실제로 그 문제가 발생했다 — 완료된 Epic 을 포함해 estimate 가 전부 null 이라
롤업이 불가능했다(사고 사례: Project #11346, 2026-08-19).
| 상황 | 포인트 출처 |
|---|---|
--points로 명시 | 그대로 사용 |
| Story/Feature/Bug/Task — 하위 항목 없음 | Fibonacci 판단(1/2/3/5/8/13/21…)으로 즉시 추정 — "나중에 채운다"는 없음 |
| Epic — 하위 Story 가 같은 실행에서 함께 생성됨 | 하위 포인트 합(롤업) |
| Epic — 하위 분해가 아직 없음(Project/Initiative 하위 Epic 다수 생성 시 흔함) | 아래 T-shirt 매핑으로 하향식(top-down) 추정 — 0 이나 null 로 남기지 않는다 |
T-shirt → Fibonacci 매핑 (Epic 하향식 추정 전용 — 아직 Story 가 없을 때):
| 크기 | 포인트 | 기준 |
|---|---|---|
| XS | 3 | 화면/엔드포인트 1개 수준 |
| S | 5 | 화면/엔드포인트 2~3개, 새 의존성 없음 |
| M | 8 | 화면/엔드포인트 4~6개, 또는 새 Entity/API 1개 |
| L | 13 | 여러 화면 + 새 Entity/API, 외부 연동 1개 |
| XL | 21 | 하위 시스템 하나(여러 Entity, 여러 화면, 배치/알림 등 보조 흐름 포함) |
| XXL | 34 | 위 이상 — 대개 이 크기는 Epic 을 더 쪼개야 한다는 신호이기도 하다 |
function estimateTshirtFibonacci(epicScopeText) {
// LLM-SEMANTIC — 위 표의 "기준" 열에 맞춰 판단. 애매하면 큰 쪽으로 반올림하지 않고
// 있는 신호(화면 수·새 Entity/API 유무·외부 연동 유무)만으로 가장 가까운 단계를 고른다.
// 먼저 cc-dev:jev-reflex 세트 tshirt(score 0 XS … 5 XXL)에 묻고, reflex 면 가장 가까운 등급, 아니면 직접 판단한다.
const d = reflexJudge("tshirt", { scope: epicScopeText });
if (d.size?.route === "reflex") {
const size = ["XS", "S", "M", "L", "XL", "XXL"][Math.ceil(d.size.value - 0.5)]; // x.5 는 작은 쪽 — 큰 쪽으로 올리지 않는다
return { size, points: { XS: 3, S: 5, M: 8, L: 13, XL: 21, XXL: 34 }[size] };
}
return matchTshirtSize(epicScopeText); // → {size: "M", points: 8}
}
Epic 이 나중에 실제 Story 로 쪼개지면
setIssueEstimate를 롤업 값으로 갱신한다(불변 아님 — 라벨과 달리 estimate 는 언제든 재설정 가능). 하향식 추정은 "일단 숫자를 채워 로드맵/속도 계산을 가능하게 하는" 잠정치이지 확정치가 아니다.
Epic Velocity Calculation (실측 속도 — 고정 상수 대체)#
Epic 기간(setDatesForIssue 의 end - start)을 정할 때 "포인트 ÷ 속도" 의 속도는 고정 상수가
아니라 이 워크스페이스의 최근 완료 스프린트 실측치를 쓴다. 팀마다·시기마다 실제 처리량이
다르고, 고정 상수(예: 이전 방식의 "5pt ≈ 2주")는 하락 추세를 반영하지 못해 낙관적인 타임라인을
만든다(관측 사례: 2026-0508 6스프린트 195→165→183→194→146→128 로 최근 3개가 뚜렷이 하락).
async function computeVelocity() {
const { closedSprints } = await mcp__zenhub__listRecentSprints();
const recent = closedSprints.slice(0, 6); // 최근 6개 완료 스프린트
if (recent.length < 2) {
// 워크스페이스가 새것이거나 완료 스프린트가 거의 없음 — 실측 불가
return { velocityPerSprint: null, sprintLengthWeeks: 2, reason: "insufficient_history" };
}
const samples = [];
for (const s of recent) {
const sprint = await mcp__zenhub__getSprint({ sprintId: s.id });
const closed = sprint.issues.filter(i => i.state === "CLOSED");
const points = closed.reduce((sum, i) => sum + (i.estimate ?? 0), 0);
const pointed = closed.filter(i => i.estimate != null).length;
samples.push({
name: s.name, points,
coverage: closed.length ? pointed / closed.length : 0,
weeks: sprintWeeks(sprint.startAt, sprint.endAt), // 실측 스프린트 길이, 산출 불가 시 2
});
}
const recent3 = samples.slice(0, 3);
const avg = xs => xs.reduce((a, b) => a + b.points, 0) / xs.length;
const allAvg = avg(samples);
const recentAvg = avg(recent3);
// 추세 하락(최근 3개 평균이 전체 평균의 85% 미만) → 보수적으로 최근 값을 쓴다.
// 상승 추세에서 recentAvg 를 우선하지 않는 이유: 상승은 1~2개 스프린트의 우연일 수 있어
// 과소평가(보수적) 쪽 오차가 과대평가보다 낫다 — 로드맵이 못 지킬 약속을 하는 것보다 낫다.
const declining = recentAvg < allAvg * 0.85;
const velocityPerSprint = declining ? recentAvg : allAvg;
const avgCoverage = samples.reduce((a, b) => a + b.coverage, 0) / samples.length;
const sprintLengthWeeks = Math.round(
samples.reduce((a, b) => a + b.weeks, 0) / samples.length
) || 2;
return { velocityPerSprint, sprintLengthWeeks, declining, avgCoverage, samples };
}
| 필드 | 의미 | 사용처 |
|---|---|---|
velocityPerSprint | 스프린트당 실측(또는 추세 반영) 완료 포인트 | 기간 계산의 분모 |
declining |
최근 3개 평균이 전체 평균의 85% 미만 | 그대로 보고에 표기(⚠️ 하락 추세) — 값을 더 낮추지는 않는다 |
avgCoverage |
CLOSED 이슈 중 실제로 포인트가 매겨진 비율 | < 50% 면 속도는 하한값 — 자동으로 부풀리지 않고 캡션에 경고로 남긴다(왜곡된 낙관적 숫자보다 "낮게 잡힌 진짜 숫자"가 낫다) |
reason: "insufficient_history" |
완료 스프린트 2개 미만 | 이전 고정 휴리스틱(1 Epic = 1 스프린트)으로 폴백, 폴백 사용 사실을 로그로 남긴다 |
--velocity로 사용자가 직접 스프린트당 포인트를 지정하면 이 계산 전체를 건너뛰고 그 값을 쓴다 (예: 다음 스프린트부터 인원이 바뀌어 과거 실측이 더 이상 대표성이 없는 경우).
Epic Dependency & Parallel Scheduling#
같은 Project/Initiative 아래 여러 Epic을 한 번에 만들 때(--epics 2개 이상), 모든 Epic을
우선순위 순서로 무조건 순차 배치하면 서로 관계없는 Epic까지 뒤로 밀려 로드맵이 불필요하게
길어진다. 반대로 전부 병렬로 놓으면 실제로 순서가 있는 작업(예: 정산 엔진 API 없이는 정산 리포트
화면을 만들 수 없음)이 근거 없이 동시에 끝난다고 표시된다. 그래서 의존 엣지가 있는 쌍만 순차,
그 외는 병렬로 계산한다.
1) 의존 엣지 결정
| 신호 | 판정 |
|---|---|
--epic-deps "B:A,C:A" (B, C가 A에 의존) 로 명시 | 그대로 사용 — 최우선 |
| 요구사항/브레인스토밍 원문에 "A 완료 후", "A 기반으로", "A API 를 사용" 등 명시적 순서 언급 | A → B 의존 |
| B의 범위가 A의 범위를 구조적으로 소비함 — A가 엔진/코어/기반/API/데이터모델을 만들고, B가 그 위의 화면/리포트/알림 등으로 서술됨 |
A → B 의존 (쌍마다
두 방향 모두
cc-dev:jev-reflex
세트
depends-on
에 먼저 묻는다 —
{a: A, b: B}
는 "B 가 A 위에 지어지나". reflex 만 채택, 그 밖은 Claude 판단)
|
| 위 신호가 전혀 없음 | 독립(병렬 후보) — 기본값 |
⚠️ 이 기본값(불확실 → 독립)은
commands/go.mdD-2.5 의 실행 시점 기본값 (불확실 → 차단)과 의도적으로 다르다. 여기는 로드맵 표시일 뿐 실제 착수를 강제하지 않는다 — 실제 착수 순서와 "없는 base 위에서 시작하지 않기"는 D-2.5 가 실행 시점에 별도로, 더 보수적으로 판단한다. 로드맵을 과도하게 순차로 그리는 쪽의 비용(불필요하게 늦어 보이는 일정)과 실행을 과도하게 병렬로 허용하는 쪽의 비용(빈 diff · 잘못된 base)은 서로 다르므로 기본값도 다르다.
의존이 확정되면 즉시 ZenHub에 실제 관계로 남긴다 — 로드맵에만 있고 보드엔 없는 의존은 다음 세션이 다시 추론해야 한다:
for (const { blocked, blocking } of dependencyEdges) {
await mcp__zenhub__createBlockage({
blockedIssueId: blocked.id,
blockingIssueId: blocking.id,
});
}
이렇게 기록된 엣지는
commands/go.mdPhase D 의 D-2.5 Dependency Invariant 가 실행 시점에 우선순위 축을 대신 추론하지 않고 직접 조회할 수 있는 실제 데이터가 된다.
2) 위상 레벨(topological levels)로 시작일 계산
function scheduleEpics(epics, edges, kickoffStart, velocityPerSprint, sprintLengthWeeks) {
// level 0 = 의존 없음. level N = 선행 Epic이 모두 level 0..N-1 안에 있음.
const levelOf = topoLevels(epics, edges); // Kahn's algorithm — 사이클 발견 시 throw(무음 순환 금지)
const endDateByEpic = new Map();
const schedule = [];
for (let level = 0; level <= Math.max(...levelOf.values()); level++) {
const inLevel = epics.filter(e => levelOf.get(e.id) === level);
for (const epic of inLevel) {
const preds = edges.filter(x => x.blocked.id === epic.id).map(x => x.blocking.id);
const start = preds.length
? maxDate(preds.map(id => endDateByEpic.get(id))) // 모든 선행이 끝난 뒤
: kickoffStart; // level 0 은 킥오프 스프린트 시작
const weeks = epicDurationWeeks(epic, velocityPerSprint, sprintLengthWeeks);
const end = addWeeksISO(start, weeks);
endDateByEpic.set(epic.id, end);
schedule.push({ epic, start, end, level, parallelWith: inLevel.filter(x => x !== epic).map(x => x.title) });
}
}
return schedule;
}
function epicDurationWeeks(epic, velocityPerSprint, sprintLengthWeeks) {
// epic.points 는 Mandatory Story Point Estimate 에 따라 항상 존재한다(하향식 추정 포함).
if (!velocityPerSprint) return sprintLengthWeeks; // insufficient_history 폴백: 1스프린트
return Math.max(sprintLengthWeeks, Math.ceil(epic.points / velocityPerSprint) * sprintLengthWeeks);
}
같은 level 의 Epic들은 같은 시작일을 갖는다(로드맵에서 나란히 표시 = 병렬 가능). level 이
다르면 뒤 level 은 자신의 모든 선행 Epic이 끝난 뒤에 시작한다. level(의존 순서)과 P0/P1/P2 티어는
서로 다른 축이다 — P2 Epic이 아무 의존이 없으면 level 0(가장 먼저 시작)일 수 있고, P0 Epic이
다른 P0 에 의존하면 level 1 일 수 있다(commands/go.md D-2.5 의 "우선순위는 의존성이 아니다"와
동일 원칙). 병렬로 배치된 Epic들이 실제로 동시에 처리될지(별도 서브팀 유무)는 이 계산의 범위 밖이다 —
이건 의존이 없다는 사실을 로드맵에 정직하게 반영하는 것이지, 팀 용량을 늘려주는 것이 아니다.
Pipeline State Contract#
⚠️ 보드는 사이클의 상태를 항상 따라간다. 파이프라인(보드 칼럼)은 "이 이슈가 지금 어디 있는가"의 유일한 공개 신호다. 자동화가 이슈를 진행하는 동안 보드가 따라오지 않으면 두 가지가 같이 깨진다 — 사람은 진행 상황을 잘못 읽고, 다른 세션은 그 이슈가 비어 있다고 읽는다(Work Claim Contract).
원칙 3개
-
선언이 아니라 호출이다. 흐름 블록의
record:BLOCKED(...)+board, 문서의 "보드에 반영하고 중단" 은 전부 아래reflectBoardState()호출을 뜻한다. 문구만 있고 호출이 없으면 그 경로는 보드를 갱신하지 않는다 — 실제로 비어 있던 자리가commands/run.md의 BLOCKED 종료 6곳과commands/batch.md의record:...+board표기 5종이었다. -
비정상 종료도 상태다. 정상 경로(착수 → PR → 머지=Close)만 갱신하면 보드는 실패를 감춘다. 막힌 이슈·중단된 이슈·머지 없이 닫힌 PR 은 전부 전이표에 행을 갖는다. 갱신하지 않으면 그 이슈는
In Progress에 영구 잔류하고, 그 상태가 다시 다음 세션의 점유 오판으로 이어진다. -
쓰고 나서 읽는다.
moveIssueToPipeline은pipelineId가undefined여도 예외를 내지 않고 조용히 아무 일도 하지 않을 수 있다(C3 사고 — 이름이 라이브 파이프라인과 어긋난 채 전 구간이 무음 no-op 이었다). 모든 이동은 read-back 으로 확인하고 불일치는 경고로 남긴다 — 비차단이다(보드 갱신 실패가 개발을 멈출 이유는 아니지만, 조용히 넘어갈 이유도 없다).
전이표 — 사건 → 컬럼 (규범)#
컬럼 이름은 워크스페이스마다 다를 수 있으므로 역할로 읽는다: 착수 전(New Issues / Icebox / Product Backlog / Sprint Backlog) ·
진행(In Progress) · 검수(Review/QA) · holding(= Sprint Backlog, 차단·중단 이슈의 대기 자리).
| # | 사이클 사건 | 목표 컬럼 | 점유 대장 | 주 호출부 |
|---|---|---|---|---|
| 1 | 이슈 생성 직후 | 착수 전 — Priority → Pipeline Placement 표가 정한 자리 | — | breakdown · run Step 3 |
| 2 | 착수 (브랜치 생성/첫 커밋) | In Progress |
acquire | run Step 5 · batch Phase 1.5 |
| 3 | 부모 체인 cascade | In Progress (부모들) |
⛔ 쓰지 않는다 | cascadeStartToParents() |
| 4 | PR 생성 | Review/QA |
유지 (heartbeat) | run Step 10 · batch Phase 3-3.2 |
| 5 | 변경 요청 / CI 실패로 재작업 | In Progress |
유지 | pr-lifecycle-agent |
| 6 | PR 이 머지 없이 닫힘(폐기·대체) | In Progress |
release | pr-lifecycle-agent |
| 7 | BLOCKED(*) — 예산 소진·타임아웃·충돌 |
holding | release | blockIssue() |
| 8 | INCOMPLETE(*) — 도구 부재·승인 불가·세션 중단 |
착수했으면 holding, 착수 전이면 이동 없음 | release | 각 커맨드 종료 경로 |
| 9 | SKIPPED-BLOCKED — 선행이 미완이라 미착수 |
이동 없음 | — | go Phase D-2.5 |
| 10 | SKIPPED-OCCUPIED — 남이 점유 중이라 미착수 |
⛔ 이동 없음 | ⛔ 건드리지 않는다 | 점유 가드 |
| 11 | 머지 = Close | GitHub closed (+ 닫힘 검증) |
release | run Step 12.5 |
| 12 | 잘못 닫힌 부모 복구 | gh issue reopen 이후에만 In Progress |
— | zenhub:manage sync-closed |
⚠️ 4행의 예외 — 배포 게이트형 트래커(Jira/Unibook) 는 PR 생성 시점에 검수로 옮기지 않고 스테이징 배포 시점에 옮긴다. deployment-gated-status 가 그 규범이며, 이 표는 그 규칙을 덮지 않는다.
⛔ 3행이 이 표에서 가장 자주 틀리는 자리다. cascade 는 "부모도 일이 돌고 있다"를 보여주는 것이지 부모를 잡는 것이 아니다. cascade 가 점유 대장을 쓰면 Epic 하나가 In Progress 로 올라가는 순간 그 아래 모든 형제 작업이 서로를 점유자로 오인한다.
reflectBoardState() — 단일 정의#
// 이 절이 유일한 정의다. 커맨드/에이전트 문서는 호출만 하고 재구현하지 않는다.
const HOLDING_PIPELINE = "Sprint Backlog"; // 차단·중단 이슈의 대기 자리 (활성 In Progress 와 구분)
const PIPELINE_BY_STATE = {
started: "In Progress",
review: "Review/QA",
rework: "In Progress",
abandoned: "In Progress",
blocked: HOLDING_PIPELINE,
aborted: HOLDING_PIPELINE,
};
// state: "started" | "review" | "rework" | "abandoned" | "blocked" | "aborted"
// 반환: true = 목표 컬럼 확인됨 / false = 미확인(경고 기록됨). 예외를 던지지 않는다.
async function reflectBoardState(issue, state, opts = {}) {
const target = PIPELINE_BY_STATE[state];
if (!target) throw new Error(`알 수 없는 보드 상태: ${state}`); // 오타는 조용히 넘기지 않는다
const ws = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const p = ws.pipelines.find(p => p.name === target);
// fail-closed: 이름이 라이브와 다르면 throw — undefined pipelineId 로 무음 no-op 하지 않는다
if (!p) throw new Error(`파이프라인 '${target}' 없음. 라이브: ${ws.pipelines.map(p => p.name).join(", ")}`);
await mcp__zenhub__moveIssueToPipeline({ issueId: issue.id, pipelineId: p.id });
// read-back — 이동이 실제로 반영됐는지 확인한다 (원칙 3)
const after = (await mcp__zenhub__searchLatestIssues({ query: `#${issue.number}` }))
.find(i => i.number === issue.number);
const now = after?.pipelineIssue?.pipeline?.name;
if (now !== target) {
console.warn(`⚠️ #${issue.number} 보드 반영 미확인 (목표: ${target} / 현재: ${now ?? "조회 실패"})`);
return false; // 비차단 — 호출부는 계속 진행하되 이 사실을 로그/보고에 남긴다
}
console.log(`📍 #${issue.number} → ${target}`);
return true;
}
blockIssue() — 차단의 3종 세트#
BLOCKED(*) 로 끝나는 모든 경로는 이 함수 하나를 부른다. 세 가지가 한 묶음이며 따로 떼면 보드·이력·점유 중 하나가 반드시 어긋난다.
// reason: 이유 슬러그 (rework_exhausted · ci_exhausted · worker_timeout · merge_conflict · …)
async function blockIssue(issue, reason, opts = {}) {
await reflectBoardState(issue, "blocked"); // ① 보드 — holding 으로
// ② 차단 의존 — **차단자가 이슈일 때만**. CI 실패·예산 소진처럼 이슈가 아닌 원인에는
// 가짜 이슈를 만들지 않는다 (createBlockage 는 이슈-이슈 관계 전용이다).
if (opts.blockingIssueId) {
await mcp__zenhub__createBlockage({ blockedIssueId: issue.id, blockingIssueId: opts.blockingIssueId });
}
// ③ 사유 + 재개 명령 — 코멘트가 내구 기록이다 (콘솔은 내구가 아니다)
await Bash(`gh issue comment ${issue.number} --body "$(cat <<'EOF'
⛔ BLOCKED(${reason})
- 무엇이 막았나: ${opts.detail ?? "(상세 없음)"}
- 재개: 원인 해결 후 \`/cc-dev:run ${issue.number}\` (멱등 — 이미 끝난 단계는 건너뜀)
EOF
)"`);
await releaseClaim(issue, `blocked:${reason}`); // ④ 점유 해제 — 아래 계약
}
-
차단 해제는 역순이다 — holding →
In Progress(reflectBoardState(issue, "started")) + 점유 재획득 후 작업 재개. -
차단된 이슈를
In Progress에 방치하지 않는다. 활성 작업과 구분되지 않으면 보드의 "진행 중" 숫자가 거짓이 되고, 점유 가드가 그 이슈를 영원히 남의 작업으로 읽는다.
Blocked Issue Contract#
위
blockIssue()가 이 계약의 구현이다. 아래 표는 그 정책 요약이며, 두 곳이 어긋나면blockIssue()가 이긴다.
| 시점 | 동작 | MCP |
|---|---|---|
차단 발생 (예: stall ladder Rung 3 BLOCKED) |
차단 의존 기록 + holding 컬럼 이동 + 사유 코멘트 + 점유 해제 |
createBlockage({blockedIssueId, blockingIssueId})
(차단자가 이슈일 때만) +
moveIssueToPipeline("Sprint Backlog")
|
| 차단 해제 | holding → In Progress 복귀 + 점유 재획득 후 작업 재개 |
moveIssueToPipeline("In Progress") |
createBlockage는 차단 의존 관계를 기록하고, 파이프라인 이동은 차단 상태를 보드에 가시화한다 — 둘은 보완 관계다.
Work Claim Contract#
⛔ 진행 중인 이슈는 다른 세션·에이전트가 집어들지 않는다. 병렬 세션(터미널 탭 여럿 · 워크트리 여럿 · 디스패치된 워커)은 같은 GitHub 계정으로 동작하므로 assignee 로는 구분되지 않는다. 두 세션이 같은 이슈를 각자 완주하면 한쪽의 작업 전부가 폐기된다(실사고: 폐기한 쪽 5커밋 44파일).
점유는 파이프라인 단독으로 판정하지 않는다#
In Progress 하나만 보고 "누가 잡고 있다"고 판정하면 세 경우가 전부 오판이다:
| 오판 | 무슨 일이 벌어지나 |
|---|---|
| cascade 로 올라간 부모 | Epic 이 In Progress 인 순간 그 아래 모든 자식 작업이 차단된다 — 파이프라인이 자기 자신을 막는다 |
| 크래시·강제 종료로 잔류한 In Progress | 아무도 안 하는 이슈가 영구 잠금된다. 사람이 손으로 칸을 옮기기 전까지 어떤 에이전트도 못 집는다 |
| 사람이 보드에서 직접 옮긴 이슈 | 자동화가 착수를 거부한다 — 사람이 "이제 해라"는 뜻으로 옮겼을 때와 구분이 안 된다 |
그래서 점유 = In Progress + 점유 대장 코멘트다. 대장이 소유자와 시각을 들고 있어야 위 셋을 구분할 수 있다.
점유 대장 (claim ledger)#
- 위치: 그 이슈의 마커 코멘트 ``
-
upsert 방식은
skills/branch-hierarchy/SKILL.mdR3(브랜치 대장) 의 마커 코멘트 프로토콜을 그대로 쓴다 — 복제 금지. 읽기는| last, 쓰기는| tail -1로 같은 코멘트를 가리켜야 한다(마커가 둘 생기면 그 순간부터 쓰기가 영영 읽히지 않는다). -
대장 읽기 실패와 대장 없음은 다른 값이다 —
readMarkerComment()는 조회 실패에null, 마커 부재에 빈 값을 돌려준다. 둘을 섞으면unknown이none으로 둔갑해 가드가 통째로 무력해진다.
<!-- cc-dev:work-claim -->
state: held # held | released
owner: {host}:{워크트리 절대경로} # 같은 계정·같은 머신의 형제 워커까지 구분하려면 워크트리가 필요하다
run: {실행 식별자} # 사이클 진입 1회 생성, 그 사이클 내내 고정
branch: {브랜치 이름 또는 -}
started: 2026-08-19T04:15:00Z
heartbeat: 2026-08-19T05:02:11Z
note: {선택 — 인수/해제 사유}
# 식별자 — 사이클 진입(Step 0) 시 1회 계산해 그 사이클 내내 재사용한다
CLAIM_OWNER="$(hostname -s):$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
CLAIM_RUN="$(date -u +%Y%m%dT%H%M%SZ)-$$"
# 읽기 — 마커가 없으면 빈 출력, 조회가 실패하면 exit != 0 (호출부는 그때만 unknown 으로 읽는다)
gh issue view {n} --json comments \
-q '[.comments[] | select(.body | contains("<!-- cc-dev:work-claim -->"))] | last | .body'
판정 — claimStatus()#
const CLAIM_TTL_MS = 4 * 60 * 60 * 1000; // 4h — commands/batch.md `--worker-timeout` 기본값과 같은 값.
// 새 상수를 만들지 않는다: 워커 1개의 최대 수명 = 점유의 최대 수명.
const HEARTBEAT_MIN_INTERVAL_MS = 20 * 60 * 1000; // 갱신 하한 — 스텝마다 API 를 때리지 않는다
// 반환: "mine" | "other-live" | "stale" | "none" | "unknown"
async function claimStatus(issueNumber, me /* {owner, run} */) {
const ledger = await readMarkerComment(issueNumber, "cc-dev:work-claim"); // 실패 시 null
if (ledger === null) return "unknown"; // 조회 자체가 실패 — 아래 degradation 참조
if (!ledger) return "none"; // 대장 없음: cascade·구버전 세션·사람의 수동 이동
if (ledger.state === "released") return "none";
if (ledger.owner === me.owner && ledger.run === me.run) return "mine"; // 내 사이클의 재개
const age = Date.now() - Date.parse(ledger.heartbeat ?? ledger.started);
if (age <= CLAIM_TTL_MS) return "other-live";
// TTL 초과 — 하트비트가 끊겼다고 죽은 것은 아니다. GitHub 원본으로 생존을 교차 확인한다.
// (구버전 세션은 하트비트를 아예 안 쓴다 — 커밋/PR 이 그쪽의 유일한 생존 신호다)
const alive = await hasRecentActivity(issueNumber, ledger.branch, CLAIM_TTL_MS);
return alive ? "other-live" : "stale";
}
hasRecentActivity() 는 이미 있는 신호를 본다(새 조회 축을 만들지 않는다 — commands/run.md
Step 0.5 와 같은 것들이다): 그 이슈의 계층/기능 브랜치 최신 커밋 시각(git log -1 --format=%cI origin/{branch}), 그 이슈를 참조하는 열린 PR 의
updatedAt. 둘 중 하나라도 TTL 이내면 살아 있는 것으로 본다.
획득 · 갱신 · 해제 — 함수 정의#
// me — 사이클 진입 1회 계산해 그 사이클 내내 고정 (위 CLAIM_OWNER/CLAIM_RUN 과 같은 값)
const me = { owner: CLAIM_OWNER, run: CLAIM_RUN };
async function acquireClaim(issue, { branch = "-", note } = {}) {
await upsertMarkerComment(issue.number, "cc-dev:work-claim", {
state: "held", owner: me.owner, run: me.run, branch,
started: nowIso(), heartbeat: nowIso(), ...(note ? { note } : {}),
});
}
// 스텝 경계에서 호출 — 마지막 기록이 최근이면 아무것도 하지 않는다(4h 사이클에서 12회 이하)
async function heartbeat(issue) {
const l = await readMarkerComment(issue.number, "cc-dev:work-claim");
if (!l || l.state !== "held" || l.run !== me.run) return; // 남의 대장을 갱신하지 않는다
if (Date.now() - Date.parse(l.heartbeat) < HEARTBEAT_MIN_INTERVAL_MS) return;
await upsertMarkerComment(issue.number, "cc-dev:work-claim", { ...l, heartbeat: nowIso() });
}
// 지우지 않고 released 로 남긴다 — 누가 언제 잡았다 놓았는지가 다음 세션의 판단 재료다
async function releaseClaim(issue, reason) {
const l = await readMarkerComment(issue.number, "cc-dev:work-claim");
if (!l) return; // 애초에 안 잡았으면 no-op
if (l.run !== me.run && l.state === "held") return; // ⛔ 남의 살아 있는 점유를 놓아주지 않는다
await upsertMarkerComment(issue.number, "cc-dev:work-claim", { ...l, state: "released", note: reason });
}
-
upsertMarkerComment/readMarkerComment는 위 점유 대장 의 마커 코멘트 프로토콜(branch-hierarchy R3)이며, 이 두 함수 밖에서 코멘트를 직접 만들지 않는다 — 마커가 둘 생기는 순간 대장은 읽히지 않는다. -
세 함수 모두 비차단이다. 대장 쓰기 실패는 경고로 남기고 작업을 계속한다(가드가 없는 것은
종전 상태이지, 새로운 위험이 아니다). 단 실패 사실은 로그에 남겨
unknown판정의 근거가 되게 한다.
판정 → 행동#
| 판정 | 행동 | 무인(--unattended) 기본값 |
|---|---|---|
mine | 재개 — 대장을 새로 만들지 않고 heartbeat 만 갱신 | 동일 |
other-live |
⛔ 착수 금지 — SKIPPED-OCCUPIED 로 기록하고 다음 항목으로. 형제/큐는 계속한다 |
동일하게 금지 — 무인이라고 완화하지 않는다. 이중 완주가 이 가드의 존재 이유이고, 무인 환경이 바로 그게 일어나는 곳이다 |
stale |
인수(takeover) — 대장을 내 소유로 덮고 note: 에 인수 사유(직전 소유자·마지막 하트비트)를 남긴다 |
동일 |
none | 통과 → 착수 시점에 acquire | 동일 |
unknown | 대장을 못 읽었다 → 브랜치/PR 신호로 강등 판정(아래) | 동일 |
unknown 의 degradation. 대장 조회 실패(gh 부재·미인증·API 장애)에서 전부 차단하면 도구 장애가 파이프라인 전면 정지가 된다 —
commands/run.md Step 0 Degradation Contract 가 "도구 부재는 안전 위반이 아니다"라고 정한 그 자리다. 대신 맹목 통과도 아니다:
hasRecentActivity() 가 참이면 other-live 로 취급해 착수를 막고, 거짓이면 경고 + claim_check_unavailable
을 기록하고 진행한다.
⚠️ 이 fail-open 은 점유 가드에만 적용된다. 머지·종료 게이트의
undetermined는 여전히fail이다(orchestration-graph §3) — 그쪽은 잘못 통과하면 되돌릴 수 없고, 이쪽은 잘못 막으면 아무 일도 못 한다.
획득 · 하트비트 · 해제#
| 시점 | 호출 | 비고 |
|---|---|---|
착수 (In Progress 이동과 같은 자리) |
acquireClaim(issue, {branch}) |
이동만 하고 대장을 안 쓰면 다음 세션이 cascade 와 구분하지 못한다 |
| 각 스텝 경계 | heartbeat(issue) |
마지막 기록이 HEARTBEAT_MIN_INTERVAL_MS 이내면 생략한다 (4h 사이클에서 12회 이하) |
머지=Close · BLOCKED · INCOMPLETE/중단 · PR 미머지 폐기 |
releaseClaim(issue, reason) |
대장을
state: released
+
note: {reason}
으로 갱신한다.
지우지 않는다
— 누가 언제 잡았다 놓았는지가 다음 세션의 판단 재료다
|
-
컨테이너의 점유:
/cc-dev:batch는 자기 레벨(Epic/Project/…)에 대해 점유를 획득한다 — 같은 Epic 에 batch 가 두 번 도는 것을 막는 유일한 신호다. 그 점유는 자식 착수를 막지 않는다(자식은 그 batch 자신이 디스패치한다). - cascade 는 대장을 쓰지 않는다 — Pipeline State Contract 전이표 3행.
-
우회는
--force-claim전용이며 인수와 동일하게note:에 강제 사유를 남긴다. ⛔--skip-dup-check는 점유 가드를 끄지 않는다 — 중복 조회(같은 작업이 이미 있는가)와 점유 판정(지금 누가 잡고 있는가)은 다른 질문이다. -
낡은 점유의 사후 청소는
/cc-dev:zenhub:manage sweep-stale-claims가 담당한다 — TTL 초과 그리고 활동 없음이 둘 다 성립할 때만 holding 으로 되돌린다.
Issue Title Conventions#
| Type | Prefix | Example |
|---|---|---|
| Project | (없음 — Issue Type으로 구분) | Admin console v2 milestone |
| Epic | (없음 — Issue Type으로 구분) | API integration and SWR caching strategy |
| Story | (없음 — Issue Type으로 구분) | classroom API integration |
| Bug | fix: | fix: Login token refresh error |
| Feature | feat: | feat: Add user profile page |
| Task | chore: | chore: Dependency update |
| Jira-sourced issue (any type above) | {JIRA_PROJECT_KEY}-{ISSUE_NUMBER}: prepended before the type prefix |
UB-123: fix: Login token refresh error |
Note: Project/Epic/Story는 ZenHub Issue Type으로 이미 구분되므로 제목에
[Project],[Epic],[Story]접두사를 붙이지 않습니다. 같은 원칙이 본문에도 적용됩니다 — 아래 Unified Issue Body Template 참고.
Jira 접두사: 이슈가 Jira 티켓에서 시작된 경우(예:
/cc-dev:run UB-123)에만 붙입니다 — 일반 한 줄 설명이나 기존 ZenHub 이슈 번호로 시작한 경우에는 붙이지 않습니다. 전체 Jira 키({프로젝트 키}-{번호})를 그대로 쓰며, 프로젝트 키만 쓰지 않습니다(예:UB-❌,UB-123-❌). 다른 타입 접두사(fix:/feat:/chore:)보다 항상 앞에 옵니다.
Unified Issue Body Template#
ZenHub Issue Type 배지가 이미 종류를 보여주므로, 본문은 그것을 다시 말하지 않는다. 대신 모든 계층(Project·Epic·Feature/Bug/Task)이 같은 섹션 순서를 쓴다 — 리뷰어가 타입마다 다른 구조를 새로 학습하지 않게 하기 위함이다. 실제 마크다운 예시는
commands/zenhub/breakdown.md→ Issue Templates 가 SoT다 — 여기서는 계약(어떤 섹션이 있고 어느 순서인지)만 정의한다.
H1 규칙#
첫 줄은 # {title} — 타입 단어를 다시 쓰지 않는다.
| ❌ 금지 | ✅ 사용 |
|---|---|
# Epic: {Feature} 기능 구현 | # {Feature} 기능 구현 |
# Feature: {Screen Name} | # {Screen Name} |
# Bug: {defect_summary} | # {defect_summary} |
# Task: {work_summary} | # {work_summary} |
# Project: {name} | # {name} |
공통 섹션 순서#
| 순서 | 섹션 | Project/Initiative | Epic | Feature | Bug | Task | Sub-task |
|---|---|---|---|---|---|---|---|
| 1 | ## 📄 상세 기획 (저장소 HTML 링크 — 작업 브랜치 생성 후 추가) |
✅* | ✅* | 조건부* | 조건부* | 조건부* | — |
| 2 | ## 📋 개요 |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 3 | 타입별 핵심 섹션 (아래 표) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 4 | ## 🛠️ 기술 노트/기술 작업 |
선택 | ✅ | ✅ | 선택 | ✅ | — |
| 5 | ## 🔢 우선순위 (표) |
✅ | ✅ | — | — | — | — |
| 6 | ## 📎 관련 이슈 |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 7 | ## 📐 예상 Story Point |
— | ✅ (롤업) | ✅ | ✅ | ✅ | — |
| 8 | footer (🤖 Generated by) |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
* Issue Body HTML Contract(위) 판정에 따름 — Sub-task 는 항상 제외, Feature/Bug/Task 는
서술 20행 초과일 때만. 이 섹션은 이슈를 만들 때가 아니라 그 이슈의 작업 브랜치가 생긴 뒤 본문 1회
수정으로 들어간다. HTML 을 쓰는 이슈는 서술 섹션(비즈니스 가치 전문·기술 노트)을 본문에서 빼고
## 📋 개요 에 요지만 남긴다.
타입별 3번 섹션 (핵심 내용 — 유일하게 갈라지는 지점)#
| 타입 | 섹션명 | 내용 |
|---|---|---|
| Project / Initiative | ## 💼 비즈니스 가치 + ## 📊 범위 |
왜 필요한지 + 포함/제외 |
| Epic | ## 💼 비즈니스 가치 + ## 📊 범위 | 왜 필요한지 + 포함/제외 |
| Feature | ## ✅ 인수 기준 | Gherkin AC (BDD) |
| Bug | ## 🔁 재현 절차 + ## 🎯 기대 결과 vs 실제 결과 |
재현 단계 + 기대/실제 |
| Task | ## ☑️ 완료 정의 (Definition of Done) | 체크리스트 |
내용이 갈라지는 이유는 타입이 다른 질문에 답하기 때문이다(사업 가치 vs 검증 가능한 동작 vs 결함 재현 vs 완료 조건) — 여기까지 하나로 합치면 각 타입에서 실제로 필요한 정보가 사라진다. 섹션 "순서"와 "존재 여부"만 통일하고, 내용은 타입에 맞게 유지한다.
## 📎 관련 이슈 (전 타입 공통 — 신설)#
이전에는 Epic 만 ## 📎 관련 Work Item 섹션을 갖고 Feature/Bug/Task는 footer 에 📎 Epic: #{number}
한 줄만 있었다. 통일 후에는 모든 타입이 같은 헤딩 아래 부모/자식을 나열한다:
## 📎 관련 이슈
- 상위: #{parent_number} ({parent_type}) ← 부모가 있을 때만
- [ ] #{child_1_number} - {child_1_title} ← 자식이 있을 때만 (Epic/Project/Initiative)
- [ ] #{child_2_number} - {child_2_title}
PR 본문과의 정렬#
PR 은 이슈가 아니지만 같은 원칙(타입 라벨 반복 금지 + 섹션 순서 통일)을 따른다 — 상세 섹션은
commands/run.md Step 9(leaf PR) · commands/batch.md → PR Creation Template(컨테이너 PR)
가 SoT다. 공통 순서:
## Summary → (레벨별 확장 섹션 — Included Children/Dispatch/Waived/Seed Alignment 는 컨테이너,
Verification Environment/Design Decisions 는 leaf) → ## Related Issue(Closes #N
+
Parent: #N — Closes 는 GitHub 자동 연결 키워드이므로 문구를 바꾸지 않는다) → ## Test Plan
→
## 📄 작업내역(저장소 HTML 링크) → ## Skipped Gates ⚠️ (있을 때만) → footer.
## Related Issue 헤딩은 leaf PR 에도 컨테이너 PR 과 동일하게 붙는다 — 이전에는 leaf PR 이
Closes #N 을 헤딩 없이 본문 끝 쪽에 두어 컨테이너 PR 과 구조가 달랐다.
Jira-Sourced Issue Labeling#
/cc-dev:run이 (한 줄 설명이나 기존 이슈 번호가 아니라) Jira 티켓 키/URL로 시작할 때, 생성되는 ZenHub/GitHub 이슈에 출처 라벨을 하나 추가합니다:
| 라벨 형식 | 예시 | 의미 |
|---|---|---|
JIRA-{PROJECT_KEY} |
JIRA-UB |
이 이슈가 Jira
UB
프로젝트의 티켓에서 가져온 것임을 한 라벨로 표시 — 접두사
JIRA-
가 출처(Jira)를, 나머지가 원본 프로젝트 키를 함께 인코딩
|
- 적용 대상: Jira 티켓을 가져와 만든 이슈에만 붙습니다. 일반 한 줄 설명이나 Figma 분석 결과에서 만들어진 이슈에는 붙지 않습니다.
-
생성 시점에만 설정: 다른 라벨과 마찬가지로
createGitHubIssue의labels에 포함해 생성 시점에 확정합니다 —updateIssue는 라벨 변경을 지원하지 않으므로, 생성 후 사후적으로 붙이지 않습니다. -
읽기 전용 출처: 라벨 값(
{PROJECT_KEY})은 Jira에서 읽기만 해서(getJiraIssue) 얻습니다. 이 라벨을 붙이는 동작이 Jira에 어떤 쓰기도 하지 않습니다 — Issue Tracker Policy 그대로 유지됩니다. -
다른 라벨과 병행: P0/P1/P2 우선순위 라벨,
impact-*라벨과 함께 붙습니다 — 서로 다른 목적(우선순위/매트릭스 축/출처)이라 배타적이지 않습니다.
Issue Closure Policy ("merge = Close") ⚠️#
ZenHub tracks two independent states — confusing them leaves issues stuck open:
| State | Owner | Meaning |
|---|---|---|
| Pipeline | ZenHub | Board column (In Progress, Review/QA, Done, …). Just a position. |
| GitHub state | GitHub | The real open / closed flag. |
-
Donepipeline ≠ closed. An issue moved toDoneis stillopenon GitHub.Donemeans "ready to close", not closed. -
Closedpipeline = GitHubclosed, 1:1. Judge open/closed by GitHub state, never by a column. -
An issue truly closes via exactly one of: (1) drag to
Closedpipeline, (2) close on GitHub, (3) a PR merging withCloses #N— but (3) fires ONLY when the PR merges into the repository's default branch (a hard GitHub rule).
⚠️ 이 워크스페이스에는
Closed컬럼이 없다 (라이브 6종: New Issues · Icebox · Product Backlog · Sprint Backlog · In Progress · Review/QA). 따라서 정식 close 메커니즘은gh issue close --reason completed+updateIssue({state:"CLOSED"})이며, 아래의 "Closed파이프라인으로 이동" 경로는Closed컬럼을 노출하는 워크스페이스에서만 적용된다(find(p=>p.name==="Closed")가 undefined 면 그 경로는 not-applicable — 무음 skip 을 close 성공으로 오인 금지).
🚫 Never
moveIssueToPipelinea closed issue into a non-Closedpipeline (Product Backlog,In Progress,Review/QA,Done, …). Those board columns render open issues only, so dropping a closed issue into one reopens it on GitHub to materialize the card. This is the move to avoid.✅ The
Closedpipeline is the exception — routing there never reopens. It is wired 1:1 to GitHubclosed, so the move is safe in both directions: for an already-closed issue it is a no-op (it is already inClosed; no reopen), and for an open issue it actively closes it on GitHub (valid close mechanism #1, equivalent togh issue close). SomoveIssueToPipeline({ pipelineId: <Closed>.id })is an acceptable explicit-close / fallback — resolve the id withgetWorkspacePipelinesAndRepositories().pipelines.find(p => p.name === "Closed")(skip gracefully if the workspace does not expose it).gh issue closeremains the simplest path; this is a sanctioned alternative, not a prohibition.Once an issue is
closedon GitHub, ZenHub auto-syncs it toClosedvia webhook with no pipeline move needed. If you accidentally moved a closed issue into a non-Closedcolumn and it reopened, re-close it on GitHub (gh issue close) or move it to theClosedpipeline — do not leave it parked open in the wrong column.
✅ GitHub close → ZenHub
Closedis automatic (webhook), zero extra setup. ZenHub auto-provisions the repo webhook and also periodically rescans, so a GitHub close propagates to the board on its own. If it is NOT syncing, the cause is almost always a webhook-permission gap: a repo admin must have logged into ZenHub at least once (ZenHub manages the webhook under that admin's token). Prefer verifying the repo's sync/connection state in workspace settings; if you do need to force the state, move the issue to theClosedpipeline (safe — see above), never to a non-Closedcolumn.
⚠️ Hierarchical (work-base) merges never auto-close. In the branch hierarchy (
task/→story/→epic/→development, extended byproject/andinitiative/aboveepic/when those levels have their own parent — see branch-hierarchy), Sub-task/Story/Epic/Project PRs merge into a parent work-base branch, not the default branch — so GitHub'sCloses #Nnever fires, the issue staysopen, and ZenHub never reachesClosed. Even the top-of-chain level (whichever of Initiative/Project/Epic has no parent) merging intodevelopmentdoes not auto-close unlessdevelopmentis the repo's default branch. For any merge whose base ≠ default branch, the explicit close (step 3 below) is the PRIMARY mechanism, not a fallback.
This repo's policy = (B) "merge = Close". AI agents run full-stack E2E tests + review before merge, so a merged PR counts as Done and Closed. Therefore:
-
Every PR body includes
Closes #{number}. Squash merge auto-closes GitHub → ZenHub syncs toClosedonly if the base is the default branch. If the base is a parent work-base branch (hierarchical merge), it will NOT auto-close — you must close explicitly (step 3). -
Never park completed issues in
Done(it would leave themopen). - After every merge, close + verify — unconditionally. This is mandatory (not optional) for non-default-base merges, and also covers silent miss / sync lag on default-branch merges:
// 0. `Closes #N` auto-closes ONLY on default-branch merges. For hierarchical merges
// (base = story/ or epic/) it never fires, so step 1 IS the close, not a safety net.
const defaultBranch = (await Bash(`gh repo view --json defaultBranchRef -q .defaultBranchRef.name`)).trim();
// 1. GitHub is the source of truth — close explicitly whenever still open
const state = await Bash(`gh issue view ${n} --json state -q .state`);
if (state.trim() !== "CLOSED") await Bash(`gh issue close ${n} --reason completed`);
// 2. Confirm ZenHub synced to Closed; force if lagging
const closed = await mcp__zenhub__searchClosedIssues({ query: `#${n}` });
if (!closed.find(i => i.number === n)) {
await mcp__zenhub__updateIssue({ issueId, state: "CLOSED" });
}
Reporting note: ZenHub reports/burndown count Closed as Done by default. Issues left only in a
Donecolumn are not counted as complete — another reason to always reachClosed.
Parent Closure Invariant ⛔ (컨테이너는 열린 자식 위에서 닫히지 않는다)#
A container issue (Initiative/Project/Epic/Story) must never be
closedwhile any of its children isopen.
Closes #N fires the instant a PR merges into the default branch — GitHub never consults
children. So this invariant cannot be delegated to the auto-close; the workflow must check it,
and must check it three times, because children can be created (or reopened) between checks:
| When | Where | On violation |
|---|---|---|
| Before creating the container PR | batch.md Phase 2-d / 3-1 · run.md Step 9 gate 0 | Block PR creation |
| Immediately before the merge | batch.md Phase 3-4.9 · run.md Step 11.9 | Block the merge — CI + review can take hours, so the pre-PR check is stale by then |
| Immediately after the merge closed it | batch.md Phase 3-6.6 · run.md Step 12.5-3 (= SKILL.md 표기 12.5a) | Reopen + remediate (the merge itself is not undone) |
같은 판정이 다른 닫기 경로에도 들어간다: agents/dev/issue-state-agent.md verifyClosed() 0번 단계 ·
agents/dev/pr-lifecycle-agent.md Step 8-0 · agents/sequential-workflow.md Phase 6-1.5 ·
commands/bugfix.md가 위임하는 run.md Step 11.9/12.5 · commands/zenhub/manage.md
sync-closed 3번(사후 일괄 복구).
실사고 (2026-07-31): Epic
#3451은 열린 sub-issue 5건(#3478–#3482, 04:56 생성)을 그대로 둔 채, 같은 작업을 새 이슈 5건으로 다시 만들어 처리하고 16:32 에 Epic PR 을main에 머지했다.Closes #3451이 즉시 발화해 Epic 이 닫혔고, 보드에는 Closed Epic + 자식 진행률 0% 가 남았다. 사전 게이트는parent:조회가[]를 돌려주는 바람에 공허하게 통과했다(위 Child Enumeration Contract 참조).
// 세 지점 모두 같은 판정을 쓴다 (tri-state — "none" 만 통과)
const kids = await openChildrenStatus(issueNumber);
if (kids.status !== "none") {
throw new Error(
`⛔ #${issueNumber} 종료 불가 — ${kids.status === "unknown"
? "자식 조회 실패(판정 불가)"
: `열린 자식 ${kids.open.length}건: ${kids.open.map(c => "#" + c.number).join(", ")}`}`
);
}
Post-merge remediation (3번째 지점에서 위반이 확인된 경우 — 이미 닫힌 상태를 방치하지 않는다):
# 1. 부모 재오픈 (머지는 되돌리지 않는다 — 상태만 사실과 일치시킨다)
gh issue reopen {N}
gh issue comment {N} --body "⚠️ 열린 자식 이슈가 남아 있어 자동 재오픈했습니다: #a, #b …"
# 2. 보드 칸 복원 — 반드시 reopen 이후에 (닫힌 이슈를 열린 칸으로 옮기면 GitHub 이 재오픈시킨다)
# moveIssueToPipeline({ issueId, pipelineId: <In Progress> })
남은 자식이 의도적으로 범위 밖이라면, 부모-자식 링크를 끊거나(gh api -X DELETE .../sub_issue)
그 자식들을 닫은 뒤 재실행한다 — 닫힌 부모를 열린 자식 위에 그대로 두는 선택지는 없다.
이미 어긋난 계층을 일괄 점검·복구하려면
/cc-dev:zenhub:manage sync-closed(manage.md) 를 쓴다 — 닫힌 부모 아래 열린 자식이 있는 조합을 찾아 같은 remediation 을 적용한다.
Out-of-band closes: the above close+verify steps only run when this skill drives the merge itself. An issue closed any other way — a teammate's direct GitHub merge, a manual
gh issue close, a hierarchical merge whose base isn't the default branch — never triggers them, so ZenHub silently drifts from GitHub with no automatic fix. Run/cc-dev:zenhub:manage sync-closed(manage.md → "sync-closed — Standalone Reconciliation") on demand to scan GitHub-closed issues and force ZenHub back in sync — no active PR/merge required.
Notes#
-
ZenHub Issues vs GitHub Issues
- ZenHub issues: Cannot move pipelines/set timelines
- GitHub issues: All ZenHub features work correctly
-
Parent-Child Relationships
- Link on creation via
parentIssueIdparameter - Or link later with
setParentForIssues
- Link on creation via
-
Search
searchLatestIssues: Only searches GitHub issues- ZenHub issues are not searchable