LogoSkills

zenhub-team-align

스프린트 내 인당 작업량, 미할당된 고우선순위 이슈, Epic 간 우선순위 충돌을 드러냅니다 — 읽기 전용 리포트이며 승인 게이트가 있는 재분배 옵션을 제공합니다.

/cc-dev:zenhub:team-align — 스프린트 팀 정렬 점검 도우미#

항목내용
실행 명령/cc-dev:zenhub:team-align
분류워크플로우
난이도●●○ 보통
MCP 서버zenhub

한마디로#

한 스프린트 안에서 "누가 너무 많이 맡았는지, 급한 일인데 담당자가 비어 있는지, 한 사람이 서로 다른 Epic의 급한 일을 동시에 떠안았는지"를 한눈에 보여 주는 점검표입니다. 조별 과제에서 "누가 일이 몰렸고, 주인 없는 숙제가 뭔지" 칠판에 정리해 주고, 동의하면 일감을 다시 나눠 주는 반장 같은 도구라고 보면 됩니다.

누가·언제 쓰나요#

  • 스프린트 시작 직후, 일감이 한쪽으로 쏠렸는지 미리 확인하고 싶을 때
  • 데일리/주간 점검에서 "급한데 담당자 없는 카드"를 빠르게 찾고 싶을 때
  • 한 사람이 두 Epic의 급한 일을 동시에 안고 있어 충돌이 나는지 볼 때
  • 일감을 다시 나누기로 팀이 합의했을 때 (재배분은 동의를 받은 뒤에만 실행)

무엇을 해주나요#

  • 사람별로 맡은 포인트와 급한 일(P0/P1) 개수를 모아 보여 줍니다 — 부하 쏠림 감지
  • 급한 일(P0/P1)인데 담당자가 비어 있는 카드를 골라냅니다
  • 같은 사람이 서로 다른 Epic의 P0를 동시에 들고 있는 충돌을 짚어 줍니다
  • 기본은 읽기 전용 — 보고만 하고 아무것도 바꾸지 않습니다
  • --rebalance를 붙이면 재배분 안을 제안하고, 사용자 동의를 받은 뒤에만 실제로 담당자를 바꿉니다

어떻게 쓰나요#

# 활성 스프린트 점검 (읽기 전용 — 기본)
/cc-dev:zenhub:team-align

# 다음 스프린트를 미리 점검
/cc-dev:zenhub:team-align --sprint next

# 이름/번호로 특정 스프린트 점검
/cc-dev:zenhub:team-align --sprint  " Sprint 14 " 

 # 재배분 제안까지 (동의 게이트 통과 시에만 실제 변경)
/cc-dev:zenhub:team-align --rebalance

# 변경 없이 무엇을 바꿀지 미리보기만
/cc-dev:zenhub:team-align --rebalance --dry-run
  • --sprint current|next|<이름/번호> — 점검할 스프린트 (기본 current = 활성 스프린트)
  • --rebalance — 재배분 제안 생성 (이 플래그가 없으면 순수 읽기 전용; 있어도 동의 전엔 변경 없음)
  • --dry-run — 제안만 보여 주고 실제 담당자 변경은 하지 않음

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

  1. 스프린트와 팀원 확인 — 어떤 스프린트인지 정하고(기본은 활성), 팀원 명단을 불러옵니다.
  2. 이슈 모으기 — 그 스프린트의 이슈를 모아 각 이슈의 담당자·우선순위(P-라벨)·포인트를 읽습니다.
  3. 세 가지 점검 — ① 부하 쏠림(한 사람 용량 대비 포인트/P0 과다), ② 주인 없는 급한 일(P0/P1인데 담당자 공란), ③ 교차 Epic 충돌(같은 사람이 다른 Epic의 P0 동시 보유).
  4. 보고서 출력 — 위 결과를 표로 보여 줍니다. 여기서 끝나면 아무것도 바뀌지 않습니다.
  5. (선택) 재배분--rebalance일 때만 재배분 안을 제시하고, "이대로 진행할까요?" 동의를 받은 뒤에만 담당자를 다시 지정합니다.

참고: 이 도구는 담당자 재배분(부하 분산)만 다룹니다. 카드를 닫는 동작은 하지 않으며, 닫기는 항상 GitHub 상태로만 처리합니다(보드의 임의 칸 이동으로 닫지 않음).


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

Triggers#

  • At sprint kickoff, to detect workload skew before work starts
  • During standup/weekly review, to surface unassigned high-priority cards
  • When one assignee holds P0 work across multiple Epics (cross-Epic collision)
  • When the team has agreed to rebalance assignments (write path, approval-gated)

Context Trigger Pattern#

/cc-dev:zenhub:team-align [--sprint current|next| < name > ] [--rebalance] [--dry-run]

Options / Actions#

OptionDescriptionDefault
--sprintSprint selector: current (active), next (upcoming), or a name/number substringcurrent (active)
--rebalanceGenerate a reassignment proposal. Without it the run is pure read-only. With it, writes still require approval.off (read-only)
--dry-runShow the proposal but never call assignIssuesoff

Read-only by default. The ONLY write tool this command may call is assignIssues, and only after an AskUserQuestion approval gate. It never moves pipelines, never closes issues, never edits labels.

Execution Flow#

1. Resolve sprint + roster#

// Sprint Selector Resolution — see rules/zenhub-conventions.md (do not redefine).
//   current → getSprint() (active) | next → getUpcomingSprint() | num/name → listRecentSprints().openSprints
const sprint = await resolveSprint(opts.sprint ||  " current " ); // current = ACTIVE (getSprint), NOT getUpcomingSprint
if (!sprint) throw new Error(`스프린트 해석 실패:  ' ${opts.sprint} ' . listRecentSprints().openSprints 에서 매칭 없음`);

const roster = await mcp__zenhub__getTeamMembers(); // [{ id, login/name, ... }]

2. Collect sprint issues with assignee + priority + estimate#

// Prefer the sprint ' s own issue set; fall back to scanning active-work pipelines.
let issues = sprint.issues || [];
if (!issues.length) {
  const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
  const repositoryIds = workspace.githubRepositories.map(r = >   r.id); // getIssuesInPipeline REQUIRES repositoryIds
  // Live pipelines (6): New Issues, Icebox, Product Backlog, Sprint Backlog, In Progress, Review/QA — no Done/Closed.
  const scanNames = [ " Sprint Backlog " ,  " In Progress " ,  " Review/QA " ];
  for (const name of scanNames) {
    const p = workspace.pipelines.find(p = >   p.name === name);
    if (!p) throw new Error(` ' ${name} '   파이프라인 없음. 라이브: ${workspace.pipelines.map(p = >   p.name).join( " ,  " )}`);
    const inPipe = await mcp__zenhub__getIssuesInPipeline({ pipelineId: p.id, repositoryIds });
    issues.push(...(inPipe.issues || inPipe).filter(i = >   i.sprintId === sprint.id || (i.sprints || []).some(s = >   s.id === sprint.id)));
  }
}

// Normalize each issue → { number, id, assignee, priority, estimate, epic }
//   priority: p-label (p0/p1/p2)  ·  effort: estimate (setIssueEstimate)  — see  " Matrix Signals "   in conventions
const rows = issues.map(i = >   ({
  number: i.number,
  id: i.id,
  assignee: (i.assignees  & &   i.assignees[0]  & &   (i.assignees[0].login || i.assignees[0].name)) || null,
  priority: (i.labels || []).map(l = >   l.name).find(l = >   /^p[012]$/.test(l)) || null,
  estimate: (i.estimate  & &   i.estimate.value) || i.estimate || 0,
  epic: (i.parent  & &   i.parent.title) || (i.epic  & &   i.epic.title) || null,
}));

3. Three analyses#

const CAPACITY = 8; // points per person per sprint (tune per team; surfaced in report header)

// (a) OVER-ALLOCATED — points or P0-count beyond one person ' s capacity
const byPerson = {};
for (const r of rows.filter(r = >   r.assignee)) {
  const a = (byPerson[r.assignee] ||= { points: 0, p0: 0, issues: [] });
  a.points += r.estimate; if (r.priority ===  " p0 " ) a.p0++; a.issues.push(r.number);
}
const overAllocated = Object.entries(byPerson)
  .filter(([, a]) = >   a.points  >   CAPACITY || a.p0  > = 2)
  .map(([person, a]) = >   ({ person, ...a }));

// (b) UNASSIGNED-HIGH-PRIORITY — P0/P1 with no assignee
const unassignedHigh = rows.filter(r = >   !r.assignee  & &   (r.priority ===  " p0 "   || r.priority ===  " p1 " ));

// (c) CROSS-EPIC-COLLISION — same assignee holds P0 in ≥2 distinct Epics
const collisions = Object.values(
  rows.filter(r = >   r.assignee  & &   r.priority ===  " p0 "   & &   r.epic)
      .reduce((acc, r) = >   {
        const k = r.assignee; (acc[k] ||= { person: r.assignee, epics: new Set(), issues: [] });
        acc[k].epics.add(r.epic); acc[k].issues.push(r.number); return acc;
      }, {}))
  .filter(c = >   c.epics.size  > = 2)
  .map(c = >   ({ person: c.person, epics: [...c.epics], issues: c.issues }));

4. Emit report (read-only default — STOP here unless --rebalance)#

🧭 Team Alignment< sprint.name >   (capacity ${CAPACITY} pt/person)
── Over-allocated ──────────
   < person > :  < points >   pt,  < p0 >   × P0   (# < issues… > )
── Unassigned high-priority ─
  # < number >   [ < priority > ]< title >     (Epic:  < epic > )
── Cross-Epic P0 collisions ─
   < person > : P0 in  < epicA > ,  < epicB >     (# < issues… > )
(no writes performed — read-only)

5. Rebalance (only with --rebalance, behind approval gate)#

if (opts.rebalance) {
  // Build a proposal: move surplus issues from over-allocated people → under-loaded roster members.
  const proposal = buildReassignmentProposal({ rows, byPerson, roster, capacity: CAPACITY });
  // proposal: [{ issueNumber, issueId, from, to, reason }]

  if (!proposal.length) { print( " 재배분 제안 없음 (균형 상태) " ); return; }

  if (opts.dryRun) { printProposalTable(proposal); print( " --dry-run: assignIssues 미실행 " ); return; }

  // APPROVAL GATE — never write without explicit user approval
  const decision = await AskUserQuestion({
    questions: [{
      question: `다음 ${proposal.length}건 재배분을 적용할까요?\n` +
        proposal.map(p = >   `#${p.number}: ${p.from ||  " (미할당) " } → ${p.to} — ${p.reason}`).join( " \n " ),
      options: [ " Apply all " ,  " Skip (report only) " ],
    }],
  });
  if (decision !==  " Apply all " ) { print( " 재배분 취소 — 변경 없음 " ); return; }

  for (const p of proposal) {
    await mcp__zenhub__assignIssues({ issueIds: [p.issueId], assigneeIds: [resolveAssigneeId(roster, p.to)] });
  }
}

MCP Tool Usage#

// Roster
const roster = await mcp__zenhub__getTeamMembers();

// Sprint (current = ACTIVE; next = upcoming — DO NOT confuse them)
const active   = await mcp__zenhub__getSprint();          // active sprint
const upcoming = await mcp__zenhub__getUpcomingSprint();  // NEXT sprint, not active
const { openSprints } = await mcp__zenhub__listRecentSprints(); // [{ id, name }] for name/number match

// Pipeline scan (fallback) — getIssuesInPipeline REQUIRES repositoryIds
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const repositoryIds = workspace.githubRepositories.map(r = >   r.id);
const sb = workspace.pipelines.find(p = >   p.name ===  " Sprint Backlog " );
if (!sb) throw new Error(` ' Sprint Backlog '   파이프라인 없음. 라이브: ${workspace.pipelines.map(p = >   p.name).join( " ,  " )}`);
await mcp__zenhub__getIssuesInPipeline({ pipelineId: sb.id, repositoryIds });

// Detail fetch — there is NO getIssue / getChildrenOfParent. Use search.
await mcp__zenhub__searchLatestIssues({ query:  " #1413 "   });        // by number
await mcp__zenhub__searchLatestIssues({ query:  " parent: < graphqlId > "   }); // children of an Epic

// The ONLY write in this command (approval-gated)
await mcp__zenhub__assignIssues({ issueIds: [ " < graphqlId > " ], assigneeIds: [ " < memberId > " ] });

Key Rules#

  1. Read-only by default. Without --rebalance, the command performs ZERO writes — it only reads and reports.
  2. Approval gate on every write. assignIssues runs only after an AskUserQuestion "Apply all" decision. --dry-run skips writes entirely.
  3. assignIssues is the only write tool. Never moveIssueToPipeline, never close, never updateIssue labels (labels are immutable post-creation — see Matrix Signals in conventions).
  4. Sprint selector = current ⇒ getSprint() (active), next ⇒ getUpcomingSprint(). Never resolve current via getUpcomingSprint — that points at the next sprint. Follow "Sprint Selector Resolution" in rules/zenhub-conventions.md.
  5. Fail-closed pipeline lookups. Every pipelines.find(p => p.name === X) must guard if (!p) throw new Error(... 라이브: ${workspace.pipelines.map(p => p.name).join(", ")}) — never dereference an unverified .id.
  6. getIssuesInPipeline requires repositoryIds from getWorkspacePipelinesAndRepositories().githubRepositories.map(r => r.id).
  7. No getIssue / getChildrenOfParent. Fetch single issues or Epic children via searchLatestIssues ("#N" / "parent:<graphqlId>"). ⚠️ GD-04: parent: search only scans ZenHub's "latest 20 issues" — old/untouched children silently drop out. Fine for a survey report here, but never use it as a completeness check (e.g. "are all children closed") — use gh api graphql's native subIssues connection for that (commands/run.md Step 0.6).
  8. Respect the 2-state model. This command never closes issues; if closure is ever needed it is gh issue close + updateIssue(state:CLOSED), never a "Done"/"Closed" column move (there is no Done/Closed pipeline).