LogoSkills

zenhub:team-align

Surface per-person workload, unassigned high-priority, and cross-Epic priority conflicts in a sprint โ€” read-only report with optional approval-gated rebalance

ํ•ญ๋ชฉ๋‚ด์šฉ
Invoke/zenhub:team-align
Categorykobic-workflow
Complexitymoderate
MCP Serverszenhub

/zenhub:team-align โ€” ์Šคํ”„๋ฆฐํŠธ ํŒ€ ์ •๋ ฌ ์ ๊ฒ€ ๋„์šฐ๋ฏธ#

ํ•œ๋งˆ๋””๋กœ#

ํ•œ ์Šคํ”„๋ฆฐํŠธ ์•ˆ์—์„œ "๋ˆ„๊ฐ€ ๋„ˆ๋ฌด ๋งŽ์ด ๋งก์•˜๋Š”์ง€, ๊ธ‰ํ•œ ์ผ์ธ๋ฐ ๋‹ด๋‹น์ž๊ฐ€ ๋น„์–ด ์žˆ๋Š”์ง€, ํ•œ ์‚ฌ๋žŒ์ด ์„œ๋กœ ๋‹ค๋ฅธ Epic์˜ ๊ธ‰ํ•œ ์ผ์„ ๋™์‹œ์— ๋– ์•ˆ์•˜๋Š”์ง€"๋ฅผ ํ•œ๋ˆˆ์— ๋ณด์—ฌ ์ฃผ๋Š” ์ ๊ฒ€ํ‘œ์ž…๋‹ˆ๋‹ค. ์กฐ๋ณ„ ๊ณผ์ œ์—์„œ "๋ˆ„๊ฐ€ ์ผ์ด ๋ชฐ๋ ธ๊ณ , ์ฃผ์ธ ์—†๋Š” ์ˆ™์ œ๊ฐ€ ๋ญ”์ง€" ์น ํŒ์— ์ •๋ฆฌํ•ด ์ฃผ๊ณ , ๋™์˜ํ•˜๋ฉด ์ผ๊ฐ์„ ๋‹ค์‹œ ๋‚˜๋ˆ  ์ฃผ๋Š” ๋ฐ˜์žฅ ๊ฐ™์€ ๋„๊ตฌ๋ผ๊ณ  ๋ณด๋ฉด ๋ฉ๋‹ˆ๋‹ค.

๋ˆ„๊ฐ€ยท์–ธ์ œ ์“ฐ๋‚˜์š”#

  • ์Šคํ”„๋ฆฐํŠธ ์‹œ์ž‘ ์งํ›„, ์ผ๊ฐ์ด ํ•œ์ชฝ์œผ๋กœ ์ ๋ ธ๋Š”์ง€ ๋ฏธ๋ฆฌ ํ™•์ธํ•˜๊ณ  ์‹ถ์„ ๋•Œ
  • ๋ฐ์ผ๋ฆฌ/์ฃผ๊ฐ„ ์ ๊ฒ€์—์„œ "๊ธ‰ํ•œ๋ฐ ๋‹ด๋‹น์ž ์—†๋Š” ์นด๋“œ"๋ฅผ ๋น ๋ฅด๊ฒŒ ์ฐพ๊ณ  ์‹ถ์„ ๋•Œ
  • ํ•œ ์‚ฌ๋žŒ์ด ๋‘ Epic์˜ ๊ธ‰ํ•œ ์ผ์„ ๋™์‹œ์— ์•ˆ๊ณ  ์žˆ์–ด ์ถฉ๋Œ์ด ๋‚˜๋Š”์ง€ ๋ณผ ๋•Œ
  • ์ผ๊ฐ์„ ๋‹ค์‹œ ๋‚˜๋ˆ„๊ธฐ๋กœ ํŒ€์ด ํ•ฉ์˜ํ–ˆ์„ ๋•Œ (์žฌ๋ฐฐ๋ถ„์€ ๋™์˜๋ฅผ ๋ฐ›์€ ๋’ค์—๋งŒ ์‹คํ–‰)

๋ฌด์—‡์„ ํ•ด์ฃผ๋‚˜์š”#

  • ์‚ฌ๋žŒ๋ณ„๋กœ ๋งก์€ ํฌ์ธํŠธ์™€ ๊ธ‰ํ•œ ์ผ(P0/P1) ๊ฐœ์ˆ˜๋ฅผ ๋ชจ์•„ ๋ณด์—ฌ ์ค๋‹ˆ๋‹ค โ€” ๋ถ€ํ•˜ ์ ๋ฆผ ๊ฐ์ง€
  • ๊ธ‰ํ•œ ์ผ(P0/P1)์ธ๋ฐ ๋‹ด๋‹น์ž๊ฐ€ ๋น„์–ด ์žˆ๋Š” ์นด๋“œ๋ฅผ ๊ณจ๋ผ๋ƒ…๋‹ˆ๋‹ค
  • ๊ฐ™์€ ์‚ฌ๋žŒ์ด ์„œ๋กœ ๋‹ค๋ฅธ Epic์˜ P0๋ฅผ ๋™์‹œ์— ๋“ค๊ณ  ์žˆ๋Š” ์ถฉ๋Œ์„ ์งš์–ด ์ค๋‹ˆ๋‹ค
  • ๊ธฐ๋ณธ์€ ์ฝ๊ธฐ ์ „์šฉ โ€” ๋ณด๊ณ ๋งŒ ํ•˜๊ณ  ์•„๋ฌด๊ฒƒ๋„ ๋ฐ”๊พธ์ง€ ์•Š์Šต๋‹ˆ๋‹ค
  • --rebalance๋ฅผ ๋ถ™์ด๋ฉด ์žฌ๋ฐฐ๋ถ„ ์•ˆ์„ ์ œ์•ˆํ•˜๊ณ , ์‚ฌ์šฉ์ž ๋™์˜๋ฅผ ๋ฐ›์€ ๋’ค์—๋งŒ ์‹ค์ œ๋กœ ๋‹ด๋‹น์ž๋ฅผ ๋ฐ”๊ฟ‰๋‹ˆ๋‹ค

์–ด๋–ป๊ฒŒ ์“ฐ๋‚˜์š”#

# ํ™œ์„ฑ ์Šคํ”„๋ฆฐํŠธ ์ ๊ฒ€ (์ฝ๊ธฐ ์ „์šฉ โ€” ๊ธฐ๋ณธ)
/zenhub:team-align

# ๋‹ค์Œ ์Šคํ”„๋ฆฐํŠธ๋ฅผ ๋ฏธ๋ฆฌ ์ ๊ฒ€
/zenhub:team-align --sprint next

# ์ด๋ฆ„/๋ฒˆํ˜ธ๋กœ ํŠน์ • ์Šคํ”„๋ฆฐํŠธ ์ ๊ฒ€
/zenhub:team-align --sprint  " Sprint 14 " 

 # ์žฌ๋ฐฐ๋ถ„ ์ œ์•ˆ๊นŒ์ง€ (๋™์˜ ๊ฒŒ์ดํŠธ ํ†ต๊ณผ ์‹œ์—๋งŒ ์‹ค์ œ ๋ณ€๊ฒฝ)
/zenhub:team-align --rebalance

# ๋ณ€๊ฒฝ ์—†์ด ๋ฌด์—‡์„ ๋ฐ”๊ฟ€์ง€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋งŒ
/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#

/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>").
  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).