| ํญ๋ชฉ | ๋ด์ฉ |
|---|---|
| Invoke | /zenhub:team-align |
| Category | kobic-workflow |
| Complexity | moderate |
| MCP Servers | zenhub |
/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โ ์ ์๋ง ๋ณด์ฌ ์ฃผ๊ณ ์ค์ ๋ด๋น์ ๋ณ๊ฒฝ์ ํ์ง ์์
์์์ ๋ฌด์จ ์ผ์ด ๋ฒ์ด์ง๋์#
- ์คํ๋ฆฐํธ์ ํ์ ํ์ธ โ ์ด๋ค ์คํ๋ฆฐํธ์ธ์ง ์ ํ๊ณ (๊ธฐ๋ณธ์ ํ์ฑ), ํ์ ๋ช ๋จ์ ๋ถ๋ฌ์ต๋๋ค.
- ์ด์ ๋ชจ์ผ๊ธฐ โ ๊ทธ ์คํ๋ฆฐํธ์ ์ด์๋ฅผ ๋ชจ์ ๊ฐ ์ด์์ ๋ด๋น์ยท์ฐ์ ์์(P-๋ผ๋ฒจ)ยทํฌ์ธํธ๋ฅผ ์ฝ์ต๋๋ค.
- ์ธ ๊ฐ์ง ์ ๊ฒ โ โ ๋ถํ ์ ๋ฆผ(ํ ์ฌ๋ ์ฉ๋ ๋๋น ํฌ์ธํธ/P0 ๊ณผ๋ค), โก ์ฃผ์ธ ์๋ ๊ธํ ์ผ(P0/P1์ธ๋ฐ ๋ด๋น์ ๊ณต๋), โข ๊ต์ฐจ Epic ์ถฉ๋(๊ฐ์ ์ฌ๋์ด ๋ค๋ฅธ Epic์ P0 ๋์ ๋ณด์ ).
- ๋ณด๊ณ ์ ์ถ๋ ฅ โ ์ ๊ฒฐ๊ณผ๋ฅผ ํ๋ก ๋ณด์ฌ ์ค๋๋ค. ์ฌ๊ธฐ์ ๋๋๋ฉด ์๋ฌด๊ฒ๋ ๋ฐ๋์ง ์์ต๋๋ค.
-
(์ ํ) ์ฌ๋ฐฐ๋ถ โ
--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#
| Option | Description | Default |
|---|---|---|
--sprint | Sprint selector: current (active), next (upcoming), or a name/number substring | current (active) |
--rebalance | Generate a reassignment proposal. Without it the run is pure read-only. With it, writes still require approval. | off (read-only) |
--dry-run | Show the proposal but never call assignIssues | off |
Read-only by default. The ONLY write tool this command may call is
assignIssues, and only after anAskUserQuestionapproval 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#
- Read-only by default. Without
--rebalance, the command performs ZERO writes โ it only reads and reports. - Approval gate on every write.
assignIssuesruns only after anAskUserQuestion"Apply all" decision.--dry-runskips writes entirely. assignIssuesis the only write tool. NevermoveIssueToPipeline, never close, neverupdateIssuelabels (labels are immutable post-creation โ see Matrix Signals in conventions).- Sprint selector = current โ
getSprint()(active), next โgetUpcomingSprint(). Never resolvecurrentviagetUpcomingSprintโ that points at the next sprint. Follow "Sprint Selector Resolution" inrules/zenhub-conventions.md. - Fail-closed pipeline lookups. Every
pipelines.find(p => p.name === X)must guardif (!p) throw new Error(... ๋ผ์ด๋ธ: ${workspace.pipelines.map(p => p.name).join(", ")})โ never dereference an unverified.id. getIssuesInPipelinerequiresrepositoryIdsfromgetWorkspacePipelinesAndRepositories().githubRepositories.map(r => r.id).- No
getIssue/getChildrenOfParent. Fetch single issues or Epic children viasearchLatestIssues("#N"/"parent:<graphqlId>"). - 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).