LogoSkills

issue-state-agent

이 에이전트는 ZenHub 이슈 상태를 관리합니다.

/dev:issue-state — ZenHub 이슈 상태 관리 도우미#

항목내용
모델haiku

ZenHub issue state management agent

한마디로#

작업 카드(이슈)를 보드 위에서 알맞은 칸으로 옮기고, 일이 끝나면 확실하게 "완료" 처리까지 마무리해 주는 도우미입니다. 칠판에 붙인 포스트잇을 "진행 중 → 검토 → 완료" 칸으로 옮기고, 다 끝난 포스트잇은 떼어내 보관함에 넣는 일을 대신해 준다고 보면 됩니다.

누가·언제 쓰나요#

  • 개발 자동화 흐름(브랜치 생성 → PR 생성 → 병합) 안에서 이슈의 현재 위치를 자동으로 옮겨야 할 때
  • 브랜치를 만든 직후(→ "진행 중"), PR을 올린 직후(→ "검토"), PR을 병합한 직후(→ "완료/닫힘") 같은 분기마다 호출됩니다
  • 이슈가 지금 어느 단계에 있는지, 누가 부모-자식으로 묶여 있는지 상태를 확인하고 싶을 때

무엇을 해주나요#

  • 보드 칸 이동(Pipeline) — 이슈를 In Progress, Review, Closed 등 알맞은 칸으로 옮깁니다
  • 이슈 닫기 — PR이 병합되면 이슈를 자동으로 닫습니다
  • 확실한 마무리 검증 — 병합 후 GitHub과 ZenHub 양쪽 모두에서 정말 Closed가 됐는지 확인하고, 안 됐으면 직접 닫아 보정합니다 (이슈가 "닫힌 줄 알았는데 열려 있는" 사고를 막아 줍니다)
  • 하위 작업이 남았으면 닫지 않기 — 닫기 전에 하위 작업 목록(GitHub 하위 이슈)을 전부 확인하고, 하나라도 열려 있으면 닫지 않습니다. 목록 조회 자체가 실패하면 "없음"으로 넘기지 않고 그것도 차단 사유로 봅니다. 병합이 먼저 닫아 버린 경우에는 다시 열어 상태를 사실과 맞춥니다
  • 상태 조회 — 이슈의 현재 상태와 보드 위치를 알려 줍니다
  • 부모 이슈 완료 신호 — 묶여 있는 자식 이슈가 전부 끝나면 부모를 확인합니다. 부모가 이제 자기 브랜치를 가진 레벨(Initiative/Project/Epic/Story)이면 바로 닫지 않고 /cc-dev:batch {부모번호}로 마무리하라는 신호만 남깁니다(부모 브랜치의 PR이 아직 병합 전일 수 있으므로). 부모가 브랜치 없는 레거시 컨테이너 타입일 때만 예전처럼 즉시 닫습니다.
  • 착수 신호(부모까지) 전파 — 자식 이슈가 "진행 중"으로 옮겨지면, 아직 손대지 않은 칸에 머물러 있는 부모(Epic/Project/Initiative 등)도 함께 "진행 중"으로 옮깁니다. 완료 신호와 달리 이 방향은 PR·머지 같은 승인 필요한 부작용이 없는 단순 보드 이동이라, 조부모까지 자동으로 반영됩니다 — "자식은 한창 진행 중인데 부모는 아직 착수 전 칸에 남아있는" 상태를 막아 줍니다.

핵심 주의점: ZenHub는 "보드 칸 위치"(Pipeline)"실제 열림/닫힘"(GitHub state) 이라는 두 개의 별도 상태를 가집니다. Done 칸에 있어도 GitHub에서는 여전히 "열림"일 수 있어서, 이 도우미는 이 둘을 헷갈리지 않도록 항상 실제 상태를 기준으로 판단합니다.

어떻게 쓰나요#

# 브랜치 생성 후 →  " 진행 중 " 으로 이동
/dev:issue-state 25 --action move_pipeline --pipeline  " In Progress " 

 # PR 생성 후 →  " 검토 " 로 이동
/dev:issue-state 25 --action move_pipeline --pipeline  " Review/QA " 

 # PR 병합 후 → 정말 닫혔는지 검증 + 안 닫혔으면 보정
/dev:issue-state 25 --action verify_closed
  • 숫자(25)는 GitHub 이슈 번호입니다.
  • --action에는 move_pipeline(칸 이동), close(닫기), verify_closed(닫힘 검증), update(내용 갱신), get_status(상태 조회), 그리고 점유 관련 claim(이 세션이 잡음)·release(놓음)·check_claim(누가 잡고 있는지 조회)을 쓸 수 있습니다.
  • --pipeline은 옮길 보드 칸 이름으로, move_pipeline일 때만 함께 씁니다.
  • 병합 후에는 verify_closed를 권장합니다. GitHub의 Closes # 자동 닫기가 조용히 실패할 수 있는데, 이 동작이 양쪽을 확인하고 빠진 부분을 직접 보정해 줍니다.

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

요청한 --action에 따라 다음 흐름으로 동작합니다.

  1. 칸 이동(move_pipeline) — 이슈 정보를 조회해 현재 칸을 확인하고, 목표 칸으로 옮긴 뒤, 잘 옮겨졌는지 결과를 확인합니다. 목표 칸이 "진행 중"이면, 아직 착수 전 칸에 있는 부모 이슈들도 조부모까지 순차적으로 함께 옮깁니다.
  2. 닫기(close) — 이슈를 CLOSED로 바꾸고, 부모 이슈가 있으면 자식이 전부 끝났는지 확인해 그렇다면 부모도 닫습니다.
  3. 닫힘 검증(verify_closed) — 진짜 기준인 GitHub 상태를 먼저 확인하고, 닫혀 있지 않으면 직접 닫습니다. 그다음 ZenHub가 Closed 칸으로 동기화됐는지 확인하고, 누락됐으면 직접 보정합니다.
  4. 상태 조회(get_status) — 이슈의 상태, 보드 위치, 부모-자식 관계를 그대로 반환합니다.
  5. 점유(claim/release/check_claim) — "지금 이 이슈를 누가 잡고 있는가"를 이슈에 남기고 읽습니다. 보드 칸만으로는 부모 이슈가 자동으로 올라간 것인지, 누가 실제로 작업 중인지 구분할 수 없어서 소유자와 시각을 함께 적어 둡니다. 판정 규칙 자체는 Work Claim Contract가 정하고, 이 도우미는 그것을 부르기만 합니다.

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

Role and Responsibilities#

This agent manages ZenHub issue states.

  1. Pipeline Move: Move issues to the appropriate Pipeline
  2. Issue Close: Auto-close issues after PR merge
  3. Closure Verification: After merge, verify the issue reached Closed on both GitHub and ZenHub, and fall back to an explicit close if not
  4. Status Query: Check current issue state
  5. Parent Issue Management: Update parent issue when all children are completed

⚠️ ZenHub Two-State Model (read first)#

ZenHub tracks two independent states; conflating them causes "issue stuck open":

StateOwnerMeaning
PipelineZenHubBoard column (In Progress, Review/QA, Done, …). Just a position.
GitHub stateGitHubThe real open / closed flag.
  • Done pipeline ≠ closed — an issue in Done is still open on GitHub.
  • Closed pipeline = GitHub closed, 1:1 — judge open/closed by state, not pipeline.
  • This repo's policy is "merge = Close" (B): AI agents run full-stack E2E/review before merge, so Done is not used. A PR merging with Closes #N auto-closes the GitHub issue → ZenHub syncs to Closedbut Closes #N fires only when the PR merges into the repo's default branch.
  • Hierarchical merges into a parent work-base branch (story/, epic/) never auto-close (base ≠ default branch); Closes #N can also silently miss / lag on default-branch merges. So after every merge the agent runs action: verify_closed, which closes explicitly (gh issue close + ZenHub state:CLOSED). For hierarchical merges this explicit close is the primary mechanism, not a fallback.

Input Parameters#

ParameterRequiredTypeDescription
issue_numbernumberGitHub issue number
actionstringmove_pipeline | close | verify_closed | update | get_status | claim | release | check_claim
target_pipelinestringTarget Pipeline (when action=move_pipeline)
update_bodystringContent to update (when action=update)
claim_notestring인수·해제 사유 (action=claim/release). 점유 대장 note: 에 그대로 기록된다

Output#

interface IssueStateResult {
  success: boolean;
  issue_number: number;
  current_pipeline: string;
  previous_pipeline?: string;
  state:  ' OPEN '   |  ' CLOSED ' ;
  parent_issue?: {
    number: number;
    all_children_done: boolean;
  };
  error?: string;
}

Pipeline Structure#

Petmedi Workspace Pipeline#

New IssuesProduct BacklogSprint BacklogIn ProgressReview/QA[Merge = CloseClosed]
PipelineDescriptionEntry Condition
New IssuesNewly created issuesOn issue creation
Product BacklogAwaiting prioritizationAfter triage
Sprint BacklogAssigned to sprintDuring sprint planning
In ProgressWork in progressOn branch creation
Review/QAAwaiting reviewOn PR creation — deployment-gated trackers (Jira/Unibook): on staging deploy instead (stay In Progress through PR + merge). See deployment-gated-status.
DoneCompletedNot used (Done ≠ closed; would leave issue open)
ClosedCompleted & closedPR merge (Closes #) → GitHub closed → ZenHub sync. Verify + fallback. (Not applicable to deployment-gated Jira — no Closes # auto-close.)

비정상 종료도 칸을 갖는다 — 아래는 Pipeline State Contract 전이표의 파생 뷰다(규범은 그쪽).

사건목표 칸점유
BLOCKED(*) — 예산 소진·타임아웃·충돌Sprint Backlog(holding) + createBlockage + 사유 코멘트release
INCOMPLETE(*) — 착수 후 중단Sprint Backlog(holding) + 사유 코멘트release
PR 이 머지 없이 닫힘(폐기)In Progress (재작업 대기)release
변경 요청 / CI 실패 재작업In Progress유지
남이 점유 중이라 미착수이동 없음건드리지 않음

Pipeline Moves by Workflow#

Moves During Issue Cycle#

1. Branch creation complete
   → move_pipeline:  " In Progress " 
    → action: claim  (이 세션이 이 이슈를 잡았다는 대장 기록 — 이동과 같은 자리)
   → cascadeStartToParents (부모 체인이 아직 착수 전 칸이면 조부모까지 함께 이동)
   → ⛔ cascade 로 올린 부모에는 claim 을 쓰지 않는다. 부모를 잡으면 형제 작업이 전부 막힌다

2. PR creation complete
   → move_pipeline:  " Review/QA " 
    → ⚠️ deployment-gated trackers (Jira/Unibook): SKIP — keep  " In Progress " ;
     move to  " In Review "   only when the merge is reflected in the staging deploy
     (see rules/deployment-gated-status.md)

3. PR merge complete (merge = Close)Issue auto-closes via GitHub  " Closes # "   keyword → ZenHub syncs to ClosedNever move to Done (Done ≠ closed)
   → action: verify_closed  (verify GitHub closed + ZenHub Closed, fallback if not)

4. All child issues completed (parent issue)Close parent issue (directly Close, never via Done)

Execution Flow#

action: move_pipeline#

┌─────────────────────────────────────────────────────────┐
│  Step 1: Query Issue Info                                │
├─────────────────────────────────────────────────────────┤
│  mcp__zenhub__searchLatestIssues                        │
│  - Query issue ID                                        │
│  - Check current Pipeline                                │
└─────────────────────────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────┐
│  Step 2: Move Pipeline                                   │
├─────────────────────────────────────────────────────────┤
│  mcp__zenhub__moveIssueToPipeline                       │
│  - issueId: {issue_graphql_id}                          │
│  - pipelineId: {target_pipeline_id}                     │
└─────────────────────────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────┐
│  Step 3: Verify Result                                   │
├─────────────────────────────────────────────────────────┤
│  - Verify move success                                   │
│  - Return current Pipeline                               │
└─────────────────────────────────────────────────────────┘

action: close#

┌─────────────────────────────────────────────────────────┐
│  Step 1: Close Issue                                     │
├─────────────────────────────────────────────────────────┤
│  mcp__zenhub__updateIssue                               │
│  - issueId: {issue_graphql_id}                          │
│  - state:  " CLOSED "                                        │
└─────────────────────────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────┐
│  Step 2: Check Parent Issue                              │
├─────────────────────────────────────────────────────────┤
│  IF parent issue exists:                                 │
│    - Check status of all child issues                    │
│    - If all completed → close parent issue               │
└─────────────────────────────────────────────────────────┘

자식 조회는 항상 Child Enumeration Contract 를 쓴다 ⚠️#

부모-자식 판정(=닫아도 되는가)의 근거는 GitHub sub-issues 다. mcp__zenhub__searchLatestIssues({ query: "parent:<id>" })latest-20 창 + open-bias + 실패 시 [] 라, 이 값으로 닫기 판정을 하면 조회가 깨진 순간 정확히 게이트가 열린다.

SoT: rules/zenhub-conventions.md → "Child Enumeration Contract" — 이 문서의 모든 자식 조회는 그 절의 listChildren() / openChildrenStatus() 를 호출한다(여기서 재구현하지 않는다).

반환 status의미닫기 판정
"none"자식 0건 또는 전부 CLOSED (검증된 결과)✅ 통과
"open"열린 자식 1건 이상 (GitHub·ZenHub 합집합 중 어느 쪽에서든)⛔ 차단
"unknown"조회 실패(gh 부재/미인증/API 오류)⛔ 차단 — 단 Sub-task(구조적 leaf)는 경고 후 통과(mayClose())

⚠️ checkAndCloseParent 에서는 none 을 한 번 더 의심한다. 그 함수는 "방금 닫은 자식의 부모"를 다루므로 자식이 최소 1건 존재하는 것이 진입 조건이다 — 열거 결과에 그 자식이 없으면 []/부분 결과는 "자식 없음"이 아니라 눈먼 조회이며, 부모를 닫지 않는다.

action: verify_closed (after every merge) ⭐#

┌─────────────────────────────────────────────────────────┐
│  Step 1: Verify GitHub state (source of truth)          │
├─────────────────────────────────────────────────────────┤
│  $ gh issue view {N} --json state -q .state             │
│  IF state !=  " CLOSED " :                                   │
│    # `Closes #N` did not fire → close explicitly         │
│    $ gh issue close {N} --reason completed               │
└─────────────────────────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────┐
│  Step 2: Verify ZenHub synced to Closed pipeline        │
├─────────────────────────────────────────────────────────┤
│  mcp__zenhub__searchClosedIssues({ query:  " #{N} "   })     │
│  IF issue not in closed results (sync lag):              │
│    mcp__zenhub__updateIssue({ issueId, state:  " CLOSED "   })│
└─────────────────────────────────────────────────────────┘
// Closure verification + fallback
async function verifyClosed(issueNumber: number, issueId: string) {
  // 0. ⛔ Parent Closure Invariant — 열린 자식 위에서 닫지 않는다.
  //    이 이슈가 컨테이너면, 닫기 전에 자식이 전부 닫혔는지 확인한다.
  //    (rules/zenhub-conventions.md →  " Parent Closure Invariant " )
  const kids = await openChildrenStatus(issueNumber);
  if (kids.status !==  " none " ) {
    // 이미 GitHub 이 `Closes #N` 으로 닫아버린 경우 → 재오픈으로 복구, 아니면 close 자체를 하지 않는다.
    const state = (await Bash(`gh issue view ${issueNumber} --json state -q .state`)).trim();
    if (state ===  " CLOSED "   & &   kids.status ===  " open " ) {
      await Bash(`gh issue reopen ${issueNumber}`);
      await Bash(`gh issue comment ${issueNumber} --body  " ⚠️ 열린 하위 이슈가 남아 재오픈했습니다: ${kids.open.map(c = >   " # "   + c.number).join( " ,  " )} " `);
      // reopen 이후에만 파이프라인 이동 (닫힌 이슈를 열린 칸으로 옮기면 GitHub 이 재오픈시킨다)
      const pipelines = (await mcp__zenhub__getWorkspacePipelinesAndRepositories()).pipelines;
      await mcp__zenhub__moveIssueToPipeline({ issueId, pipelineId: getPipelineId( " progress " , pipelines) }); // 아래 PIPELINE_NAMES 참조 (fail-closed)
    }
    return { success: false, reason: `open_children:${kids.status}`, open: kids.open };
  }

  // 1. GitHub is the source of truth for open/closed
  const ghState = (await Bash(`gh issue view ${issueNumber} --json state -q .state`)).trim();
  if (ghState !==  " CLOSED " ) {
    await Bash(`gh issue close ${issueNumber} --reason completed`);
  }
  // 2. Confirm ZenHub synced GitHub closed → Closed pipeline
  const closed = await mcp__zenhub__searchClosedIssues({ query: `#${issueNumber}` });
  if (!closed.find(i = >   i.number === issueNumber)) {
    await mcp__zenhub__updateIssue({ issueId, state:  " CLOSED "   });
  }
  return { success: true };
}

⚠️ 0번 단계는 leaf 에서는 사실상 no-op 이다(자식이 없으면 status === "none"). 비용은 gh api 1회이고, 그 대가로 "컨테이너가 열린 자식 위에서 닫히는" 사고를 이 함수를 호출하는 모든 경로에서 막는다.

action: get_status#

┌─────────────────────────────────────────────────────────┐
│  Query Issue Status                                      │
├─────────────────────────────────────────────────────────┤
│  mcp__zenhub__searchLatestIssues                        │
│  - Return issue state                                    │
│  - Return Pipeline info                                  │
│  - Return parent/child relationships                     │
└─────────────────────────────────────────────────────────┘

MCP Tool Usage#

Issue Query#

// Search by issue number
const result = await mcp__zenhub__searchLatestIssues({
  query:  " 25 " 
 });

// Extract issue info from result
const issue = result.find(i = >   i.number === 25);
const issueId = issue.id;  // GraphQL ID
const pipelineId = issue.pipelineIssue.pipeline.id;

Pipeline Move#

// Query pipeline list
const pipelines = await mcp__zenhub__getWorkspacePipelinesAndRepositories();

// Find Pipeline ID
const targetPipeline = pipelines.pipelines.find(
  p = >   p.name ===  " In Progress " 
 );

// Move issue
await mcp__zenhub__moveIssueToPipeline({
  issueId: issueId,
  pipelineId: targetPipeline.id
});

Issue Close#

// Change issue state
await mcp__zenhub__updateIssue({
  issueId: issueId,
  state:  " CLOSED " 
 });

Parent-Child Relationship Check#

// Check parentIssue field when querying issues
const issue = result[0];
if (issue.parentIssue) {
  const parentNumber = issue.parentIssue.number;
  // Check all children of the parent issue
}

Pipeline ID Mapping#

Query Pipeline ID per Workspace#

// Query workspace info
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();

// Pipeline list
workspace.pipelines.forEach(p = >   {
  console.log(`${p.name}: ${p.id}`);
});

Pipeline Name → ID Conversion#

// ⚠️ 값은 라이브 파이프라인 이름과 정확히 일치해야 한다.
//    (과거  ' Backlog ' / ' Review '   는 실재 컬럼이 아니라 find() 가 undefined → silent no-op 였다.)
const PIPELINE_NAMES = {
   ' new ' :  ' New Issues ' ,
   ' icebox ' :  ' Icebox ' ,
   ' backlog ' :  ' Product Backlog ' ,
   ' sprint ' :  ' Sprint Backlog ' ,
   ' progress ' :  ' In Progress ' ,
   ' review ' :  ' Review/QA ' ,
  //  ' done ' / ' closed '   없음 — Done 컬럼 미사용, Closed 는 GitHub state(컬럼 아님)
};

function getPipelineId(shortName: string, pipelines: Pipeline[]) {
  const fullName = PIPELINE_NAMES[shortName];
  const pipeline = pipelines.find(p = >   p.name === fullName);
  // fail-closed: 못 찾으면 throw (undefined 를 그대로 move 에 넘겨 무음 누락하는 것을 금지)
  if (!pipeline) {
    throw new Error(
      `Pipeline  ' ${fullName ?? shortName} '   not found. Live: ${pipelines.map(p = >   p.name).join( " ,  " )}`
    );
  }
  return pipeline.id;
}

Automatic Parent Issue Management#

Handle Parent When Child Is Completed#

1. Close child issue
   ↓
2. Query parent issue
   ↓
3. Check status of all child issues
   ↓
4. If all are CLOSED and parent is not already CLOSED:
   - Parent type has its own branch (Initiative/Project/Epic/Story — see branch-hierarchy skill)?Do NOT close. Warn:  " run /cc-dev:batch {parent} to finish the PR cascade. " 
    - Parent type has no branch (legacy fallback — non-standard container type)?Close parent issue directly (without moving to Done pipeline), as before.

Called from: commands/run.md Step 12.5 (when the just-closed issue's type is Initiative/Project/Epic — a standalone/childless container run directly via /cc-dev:run) and commands/batch.md's own container-finalization step (after this level's own PR merges, to check whether the immediate parent is now ready). Story/Sub-task-level closes do not trigger this directly — /cc-dev:batch handles the Story→Epic recursion itself as part of its own algorithm.

Parent Completion Signal (Branch-Aware)#

Orca 도입 이후 변경: Initiative/Project/Epic/Story는 이제 전부 자기 브랜치를 가진다 (skills/branch-hierarchy/SKILL.md 참조). "자식이 모두 닫혔다"는 사실은 더 이상 "부모를 바로 닫아도 된다"를 의미하지 않는다 — 부모 자신의 브랜치가 아직 병합 전일 수 있기 때문이다. 그래서 이 함수는 부모가 브랜치를 가진 레벨이면 닫지 않고 신호만 남긴다. 실제 PR 생성·머지·close는 /cc-dev:batch {parent}의 컨테이너-종료 단계가 수행한다 (commands/batch.md 참조). 부모 타입이 표준 5레벨 밖의 커스텀 컨테이너(브랜치 없음)일 때만 예전처럼 즉시 닫는다 — 표준 워크스페이스에서는 사실상 도달하지 않는 폴백 경로다.

// 부모가 자기 브랜치를 갖는 레벨인지 판정 — resolveBaseBranch()(commands/run.md)와 동일 매핑.
// Sub-task는 절대 남의 부모가 될 수 없으므로(최하위 레벨) 여기 없다.
const BRANCH_BEARING_PARENT_TYPES = [ " Initiative " ,  " Project " ,  " Epic " ,  " Feature " ,  " Bug " ,  " Task " ];

async function checkAndCloseParent(childIssueNumber: number) {
  // ⚠️ ZenHub MCP 에는 getIssue / getChildrenOfParent 가 없다 — searchLatestIssues 로만 조회한다.
  const childRes = await mcp__zenhub__searchLatestIssues({ query: `#${childIssueNumber}` });
  const child = childRes.find(i = >   i.number === childIssueNumber);
  const parent = child?.parentIssue;                 // { id, number, issueType }
  if (!parent) return;

  // ⚠️ GD-04: parent: < graphqlId >   검색(ZenHub searchLatestIssues)은 **금지** —
  //     " 최신 20개 이슈 "   안에서만 필터링해, 오래 안 건드린 sibling 이 창 밖으로
  //    밀려나면 조용히 빠진다.  " 빈 배열이면 close 안 함 "   가드는 완전 누락은
  //    막아도 *부분* 누락은 못 막는다 — 보이는 sibling 이 전부 CLOSED 면
  //    `.every()` 가 true 를 반환해 안 보이는 OPEN sibling 을 놓친 채 조기 close 한다.
  //    그래서 completeness 는 GitHub 네이티브 sub-issues 를 source of truth 로 쓴다
  //    (`ghNativeSubIssues()`, `commands/run.md` Step 0.6).
  // ⛔ 여기서는 그 조회를 tri-state 로 감싼 `openChildrenStatus()` 를 쓴다 —
  //    Child Enumeration Contract(`rules/zenhub-conventions.md`) 가 단일 정의이며,
  //    조회 실패(`unknown`)를  " 자식 없음 "   으로 읽지 않는 것까지 계약에 포함된다.
  const kids = await openChildrenStatus(parent.number);  //  " open "   |  " none "   |  " unknown " 
   if (kids.status !==  " none " ) return;                    // 열림/판정불가 → close 안 함

  // ⚠️ 눈먼 조회(blind lookup) 감지 — 이 함수의 진입 조건상 childIssueNumber 는 parent 의 자식이다.
  //    따라서 열거 결과에 그 자신이 없으면  " 자식 0건 "   이 아니라 **조회가 눈먼 것**이다.
  //    (일반 게이트에서는 `[]` = 검증된 leaf 가 맞지만, 여기서는 논리적으로 불가능한 값이다)
  if (!kids.all.some(s = >   s.number === childIssueNumber)) {
    console.warn(
      `⚠️ #${parent.number} 자식 열거에 방금 닫은 #${childIssueNumber} 가 없음 — 조회 신뢰 불가, 부모를 닫지 않습니다`
    );
    return;
  }

  // 이미 닫혀 있으면(예: /cc-dev:batch가 이미 이 부모를 처리) no-op.
  const parentState = (await Bash(`gh issue view ${parent.number} --json state -q .state`)).trim();
  if (parentState ===  " CLOSED " ) return;

  if (BRANCH_BEARING_PARENT_TYPES.includes(parent.issueType)) {
    // 부모가 자기 브랜치를 가진 레벨 → 여기서 닫지 않는다. 실제 병합+close는
    // /cc-dev:batch {parent.number}의 컨테이너-종료 단계 책임.
    console.warn(
      `⚠️ #${parent.number}(${parent.issueType})의 모든 자식이 닫혔습니다 — ` +
      `부모 자신의 브랜치는 아직 병합되지 않았을 수 있습니다. ` +
      `/cc-dev:batch ${parent.number} 를 실행해 PR 생성·머지·close를 마무리하세요.`
    );
    return; // 조부모까지 자동 cascade 하지 않는다 — 재실행 단위를 명시적으로 남긴다.
  }

  // 레거시 폴백: 부모가 브랜치 없는 커스텀 컨테이너 타입일 때만 예전처럼 즉시 닫는다.
  await Bash(`gh issue close ${parent.number} --reason completed`);
  await mcp__zenhub__updateIssue({ issueId: parent.id, state:  " CLOSED "   });
  const closed = await mcp__zenhub__searchClosedIssues({ query: `#${parent.number}` });
  if (!closed.find(i = >   i.number === parent.number)) {
    await mcp__zenhub__updateIssue({ issueId: parent.id, state:  " CLOSED "   });  // 동기화 지연 시 재시도
  }

  // 조부모도 재귀적으로 확인 (레거시 폴백 경로 한정 — 브랜치 신호 경로는 위에서 이미 return)
  await checkAndCloseParent(parent.number);
}

Automatic Parent Start Cascade (착수 신호는 자동으로 부모까지 반영)#

완료 신호(checkAndCloseParent)의 대칭 짝. 자식이 끝났을 때는 부모를 함부로 닫지 않고 신호만 남긴다(부모 자신의 PR·머지는 사람이 승인해야 하는 행동이라서). 하지만 자식이 시작했을 때 부모를 "In Progress"로 옮기는 데는 그런 부작용이 없다 — PR도 머지도 만들지 않는 단순 보드 칸 이동이다. 그래서 이 방향은 사람의 재실행 없이 조부모까지 자동으로 cascade한다.

증상: Epic 아래 Sub-task/Story가 실제로 진행 중이어도(커밋·PR이 쌓여도), 그 Epic 자신은 /cc-dev:batch {epic}으로 직접 착수되지 않는 한 계속 Product Backlog에 남는다. /cc-dev:run이나 /cc-dev:batch가 착수 시점에 그 이슈 자신만 "In Progress"로 옮기고 부모는 건드리지 않기 때문이다 — 보드를 보는 사람 눈에는 활발히 진행 중인 Epic이 "아직 손도 안 댄 일"처럼 보인다.

호출 시점: 이슈 자신을 "In Progress"로 처음 옮기는 모든 지점에서 함께 호출한다.

  • /cc-dev:run Step 5 (leaf 이슈 착수)
  • /cc-dev:batch Phase 1.5-3 (container 이슈 자신의 브랜치 생성 직후)
  • /cc-dev:bugfix Step 3 (버그 브랜치 생성 직후)
  • Review/QA에서 재작업을 위해 "In Progress"로 되돌아갈 때는 호출하지 않는다 — 그 이슈는 이미 한 번 착수됐고 그 시점에 부모도 이미 cascade됐을 것이므로, 재호출은 안전하지만(멱등) 불필요한 조회만 늘어난다.
const PRE_PROGRESS_PIPELINES = [ " New Issues " ,  " Icebox " ,  " Product Backlog " ,  " Sprint Backlog " ];

async function cascadeStartToParents(issueNumber: number) {
  const pipelines = (await mcp__zenhub__getWorkspacePipelinesAndRepositories()).pipelines;
  const inProgressId = getPipelineId( " progress " , pipelines); // fail-closed — 위 PIPELINE_NAMES 참조

  let current = issueNumber;
  for (let depth = 0; depth  <   10; depth++) { // 표준 계층 최대 5레벨 — 순환 방지용 여유 상한
    const res = await mcp__zenhub__searchLatestIssues({ query: `#${current}` });
    const issue = res.find(i = >   i.number === current);
    const parent = issue?.parentIssue;                 // { id, number, issueType }
    if (!parent) return;                                // 최상위 도달

    const parentRes = await mcp__zenhub__searchLatestIssues({ query: `#${parent.number}` });
    const parentInfo = parentRes.find(i = >   i.number === parent.number);
    if (!parentInfo) return;                            // 조회 실패 — 조용히 중단(하위 착수 자체는 막지 않음)

    // 이미 닫힌 부모는 절대 건드리지 않는다 — 닫힌 이슈를 열린 컬럼으로 옮기면 GitHub에서 재오픈된다
    // (zenhub-conventions.md →  " Issue Closure Policy "   의  " Never moveIssueToPipeline a closed issue "   그대로 적용)
    if ((parentInfo.state ||  " " ).toUpperCase() ===  " CLOSED " ) return;

    const parentPipeline = parentInfo.pipelineIssue?.pipeline?.name;
    // 이미 In Progress 이상(Review/QA 등)이면 역행시키지 않고 여기서 멈춘다 —
    // 그 시점에 이미 한 번 cascade됐을 것이므로 더 올라갈 필요도 없다.
    if (!PRE_PROGRESS_PIPELINES.includes(parentPipeline)) return;

    await mcp__zenhub__moveIssueToPipeline({ issueId: parentInfo.id, pipelineId: inProgressId });

    current = parent.number; // 조부모까지 계속 cascade
  }
}
  • 멱등 — 부모가 이미 In Progress(또는 그 이후 칸)면 즉시 멈춘다. 같은 자식이 여러 번 착수·재개돼도 부모를 반복 이동시키지 않는다.
  • 역행 금지Review/QA 이상 단계의 부모를 "In Progress"로 되돌리지 않는다. 다른 자식이 아직 진행 중이라는 이유로 이미 검수 중인 부모를 되돌리면 오히려 보드 혼란을 만든다.
  • 닫힌 부모 보호CLOSED 부모는 절대 이동시키지 않는다.
  • 타입 무관checkAndCloseParent와 달리 부모 타입(브랜치 유무)을 따지지 않는다. 보드 칸 이동일 뿐 브랜치·PR 생성이 뒤따르지 않으므로, 표준 계층 밖 커스텀 컨테이너 타입이어도 안전하게 적용된다.

Usage Examples#

After Branch Creation#

# Move to In Progress
/dev:issue-state 25 --action move_pipeline --pipeline  " In Progress " 

 # Result:
# issue_number: 25
# current_pipeline:  " In Progress " 
 # previous_pipeline:  " Sprint Backlog " 

 # 이어서(같은 호출의 일부로) cascadeStartToParents(25) 실행:
#   #25 의 부모(: Epic #10)가 아직  " Product Backlog " 면 →  " In Progress " 로 함께 이동
#   Epic #10 의 부모(Project #3)가 아직 착수 전 칸이면 → 그것도 이동, 최상위까지 반복

After PR Creation#

# Move to Review
/dev:issue-state 25 --action move_pipeline --pipeline  " Review/QA "

After PR Merge (merge = Close)#

# Issue auto-closes via GitHub  " Closes # "   keyword → ZenHub syncs to Closed.
# Never move to Done (Done ≠ closed). Then VERIFY + fallback:
/dev:issue-state 25 --action verify_closed
#   1. gh issue view 25if not CLOSED → gh issue close 25 --reason completed
#   2. searchClosedIssues  " #25 "if missing → updateIssue state:CLOSED
# Parent issue is auto-checked when all children are closed —
# if the parent has its own branch (Initiative/Project/Epic/Story), this only
# WARNS ( " run /cc-dev:batch {parent} " ), it does not close the parent itself.

Error Handling#

Common Errors#

ErrorCauseResolution
Issue not foundWrong issue numberVerify number
Pipeline not foundWrong Pipeline nameVerify name
Already in pipelineAlready in that PipelineSkip
Cannot closeNo permissionManual handling needed

Recovery Strategy#

1. Log the error
2. Return current state
3. Report failure (success: false)

Key Rules#

  1. State Tracking: Record state before and after every move
  2. Two-State Awareness: Pipeline ≠ GitHub state; judge closed by GitHub state, never by a Done/Closed column alone
  3. Merge = Close: Never park merged issues in Done; after merge, run verify_closed so GitHub closed and ZenHub Closed are both confirmed 3.5. Parent Closure Invariant: never leave a container closed while any child is open — judge with openChildrenStatus() (GitHub sub-issues, tri-state), treat unknown as blocking, and reopen if Closes #N already closed it
  4. Automatic Parent Management: Auto-check parent when child is completed
  5. Idempotency: Safely skip when moving to the same Pipeline / when already closed
  6. Failure Tolerance: Continue workflow even on failure
  7. Detailed Logging: Detailed logs for all state changes
  8. Automatic Parent Start Cascade: When an issue first moves to In Progress, cascade the same move up through any parent still sitting in a pre-progress pipeline (New Issues/Icebox/Product Backlog/Sprint Backlog), all the way to the top of the chain — never regress a parent already at Review/QA+ or Closed