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 Artifact Contract (๋ณธ๋ฌธ = ๊ณ์ฝ, ์์ = ์ํฐํฉํธ)#
์ด ์ ์ ์ด์ ๋ณธ๋ฌธ ํ๋ฉด์ ๋ฌด์์ ์ฃ๋์ง๋ฅผ ์ ํ๋ค. ๋ฐํ ๋ฉ์ปค๋์ฆ(๊ณต๊ฐ ๋ฒ์ยทURL ์ ์งยท๋ฐํ ์์ยทDegradationยท๋ฏผ๊ฐ์ ๋ณด ๊ธ์ง)์
rules/artifact-publishing.md๊ฐ SoT ์ด๋ฉฐ ์ฌ๊ธฐ์ ๋ณต์ ํ์ง ์๋๋ค. ๊ฐ์ SoT ๋ฅผ ์ฐ๋ ๋ค๋ฅธ ํ๋ฉด: PR ์์ ๋ด์ญ(skills/pr-work-artifact/SKILL.md).
์ด์ ๋ณธ๋ฌธ์ ๊ธฐํ์๋ฅผ ํต์งธ๋ก ๋ถ์ง ์๋๋ค. ๊ธด ์์ ์ claude.ai ์ํฐํฉํธ๋ก ๋ฐํํด ๋งํฌ๋ง ๋จ๊ธฐ๊ณ , ์๋ ํ์ดํ๋ผ์ธ์ด ํ์ฑํ๋ ๊ณ์ฝ ๋ธ๋ก์ ๋ณธ๋ฌธ์ ์ ์งํ๋ค.
ํ์ ๊ธฐ์ค์ artifact-publishing.md ยง3 ๊ทธ๋๋ก๋ค โ ๊ธฐ๊ณ๊ฐ ์ฝ์ผ๋ฉด ๋ณธ๋ฌธ, ์ฌ๋๋ง ์ฝ์ผ๋ฉด ์ํฐํฉํธ. ์ ๋งคํ๋ฉด ๋ณธ๋ฌธ์ ๋จ๊ธด๋ค: ๋งํฌ๊ฐ ์ ์ด๋ ค ์์
์ด ๋งํ๋ ์ชฝ์ด, ์์ ์ด ์กฐ๊ธ ๊ธธ์ด์ง๋ ์ชฝ๋ณด๋ค ๋น์ผ ์คํจ๋ค.
| ์น์ | ์์น | ์๋น์ / ์ด์ |
|---|---|---|
## โ
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 ์๋ ์ฐ๊ฒฐ |
## ๐ ์์ธ ๊ธฐํ ๋งํฌ + ๊ณต์ ์๋ด | ๋ณธ๋ฌธ | ์ํฐํฉํธ ์ง์ ์ |
| ๊ฐ์ ยท ๋ฐฐ๊ฒฝ ยท ๋ฌธ์ ์ ์ | ์ํฐํฉํธ | ์ฌ๋์ด ์ฝ๋ ์์ |
| ๋น์ฆ๋์ค ๊ฐ์น | ์ํฐํฉํธ | ์ฌ๋์ด ์ฝ๋ ์์ |
| ํ๋ฉดยท์ํ ์ค๊ณ ํ, ์์ด์ดํ๋ ์ | ์ํฐํฉํธ | ํยท์ด๋ฏธ์ง๊ฐ ์ํฐํฉํธ์์ ํจ์ฌ ์ฝํ |
| ๊ธฐ์ ์ค๊ณ ยท ์ํคํ ์ฒ ๋ค์ด์ด๊ทธ๋จ | ์ํฐํฉํธ | mermaid ๋ค์ดํฐ๋ธ ๋ ๋ |
| DDR ์ ๋ฌธ(๊ทผ๊ฑฐ ์ฌ๋ค๋ฆฌ ์์ ) | ์ํฐํฉํธ | ๋ณธ๋ฌธ์๋ ๊ฒฐ์ ํ ์ค๋ง ๋จ๊ธด๋ค |
| ์ฐธ๊ณ ๋งํฌ ยท ์ ๋ก ์กฐ์ฌ ๊ธฐ๋ก | ์ํฐํฉํธ | ์ฌ๋์ด ์ฝ๋ ์์ |
๊ณ์ธต๋ณ ์ ์ฉ
| ๊ณ์ธต | ์ํฐํฉํธ | ๋น๊ณ |
|---|---|---|
| Initiative ยท Project | ํญ์ | ์ ๋ต ์์ ๋น์ค์ด ๊ฐ์ฅ ํผ |
| Epic | ํญ์ | ์ฐ์ ์์ ํ๋ ๋ณธ๋ฌธ์ ์ ์ง |
| Feature ยท Bug ยท Task | ์์ ์ด 20ํ ์ด๊ณผ์ผ ๋๋ง | ACยท๋ฒ์ยทDoD ๋ ๋ณธ๋ฌธ์ ์ ์ง |
| Sub-task | ๋ฐํํ์ง ์์ | ๋ณธ๋ฌธ์ด 3~5ํ โ ๋งํฌ ์ค๋ฒํค๋๊ฐ ๋ด์ฉ๋ณด๋ค ํฌ๋ค |
๋ฐํ ์์ (์ญ์ ๊ธ์ง)
artifact-publishing.md ยง5 ๋ฅผ ์ด์ ํ๋ฉด์ ์ ์ฉํ ๊ฒ โ ์ํฐํฉํธ๋ฅผ ๋จผ์ ๋ฐํํด URL ์ ํ๋ณดํ ๋ค ์ด์๋ฅผ ๋ง๋ ๋ค. ์์๋ฅผ ๋ค์ง์ผ๋ฉด ๋งํฌ ์๋ ๋ณธ๋ฌธ์ผ๋ก ์ด์๊ฐ ๋จผ์ ์๊ฒจ ์ฌํ ์์ ์ด ํ์ํ๋ค.
// 1) ์์ ๊ณผ ๊ณ์ฝ ๋ธ๋ก์ ์ ํ์ ๊ธฐ์ค๋๋ก ๋ถ๋ฆฌ
// 2) ์ํฐํฉํธ ์๊ณ ๋ฅผ ํ์ผ๋ก ์์ฑ โ ๊ฒฝ๋ก๋ ์ด์๋น ๊ณ ์
// .claude/docs/{scope}/issue-{number|slug}.html
// 3) ๋ฐํ โ URL ํ๋ณด
const url = await Artifact({ file_path, favicon, description });
// 4) ๊ณ์ฝ ๋ธ๋ก + ๋งํฌ๋ก body ๋ฅผ ์กฐ๋ฆฝํ ๋ค ์์ฑ
await mcp__zenhub__createGitHubIssue({ title, body: buildBody(url), ... });
๋ณธ๋ฌธ ๋งํฌ ๋ธ๋ก (๊ณ ์ ํ์)
## ๐ ์์ธ ๊ธฐํ
**{artifact_url}**
โณ {์ํฐํฉํธ์ ๋ด๊ธด ์น์
๋ชฉ๋ก}
{๊ณต์ ์๋ด ๋ฌธ๊ตฌ โ `artifact-publishing.md` ยง2 ์ ๊ณ ์ ๋ฌธ๊ตฌ๋ฅผ ๊ทธ๋๋ก ๋ถ์ฌ๋ฃ๋๋ค. ์ฌ๊ธฐ์ ๋ค์ ์ ์ง ์๋๋ค}
โ ๏ธ ํ์ดํ๋ผ์ธ์ "ํผ๋ธ๋ฆญ ๋งํฌ"๋ฅผ ์๋์ผ๋ก ๋ง๋ค ์ ์๋ค (
artifact-publishing.mdยง2). ์ด์ ํ๋ฉด์์์ ๊ท๊ฒฐ: ์ธ๋ถ ๊ธฐ์ฌ์๊ฐ ์ฝ์ด์ผ ํ๋ ์ด์๋ผ๋ฉด ์์ ์ ์ํฐํฉํธ๋ก ์ฎ๊ธฐ์ง ๋ง๊ณ ๋ณธ๋ฌธ์ ๋จ๊ธด๋ค.
์ฌ๋ฐํ = ๊ฐ์ ๊ฒฝ๋ก (URL ๊ณ ์ )
artifact-publishing.md ยง4 ๊ทธ๋๋ก. ์ด์ ํ๋ฉด์์์ ๊ท๊ฒฐ: ์คํ์ด ๋ฐ๋๋ฉด ํ์ผ์ ์์ ํด ์ฌ๋ฐํํ๊ณ ์ด์ ๋ณธ๋ฌธ์ ๊ฑด๋๋ฆฌ์ง ์๋๋ค.
Degradation Contract (๋๊ตฌ ๋ถ์ฌ ์)
artifact-publishing.md ยง6 ๊ทธ๋๋ก โ ๋ถ์ฌ๋ฅผ ์คํจ๋ก ์ทจ๊ธํ์ง ์๋๋ค. ์ด์ ํ๋ฉด์ ํด๋ฐฑ์ ์ ๋ ๋งํฌ๋ค์ด ๋ณธ๋ฌธ(์ข
์ ๋ฐฉ์)์ผ๋ก ์์ฑํ๊ณ ๋ก๊ทธ ํ ์ค์ ๋จ๊ธฐ๋ ๊ฒ์ด๋ฉฐ, ์ด์ ์์ฑ์ ์ค๋จํ์ง ์๋๋ค.
์๋น์ ์ธก (๋ณธ๋ฌธ์ ์ฝ๋ ์ชฝ)
-
AC ํ์ฑ ๋์์ ์ธ์ ๋ ์ด์ ๋ณธ๋ฌธ์ด๋ค. ์ํฐํฉํธ ๋งํฌ๋ ๋ณด์กฐ ์ปจํ
์คํธ๋ค โ AC ๋ฅผ ์ฐพ์ผ๋ ค๊ณ ๋งํฌ๋ฅผ
WebFetchํ์ง ์๋๋ค(๋น๊ณต๊ฐ ์ํฐํฉํธ๋ผ ํค๋๋ฆฌ์ค ์ธ์ ์์ ์คํจํ๋ค). - ๋ณธ๋ฌธ์ AC ๊ฐ ์์ผ๋ฉด ๊ทธ๊ฒ์ "์ํฐํฉํธ๋ฅผ ์ด์ด์ผ ํ๋ค"๋ ์ ํธ๊ฐ ์๋๋ผ ์ด์๊ฐ ์ด ๊ท์ฝ์ ์๋ฐํ ๊ฒ์ด๋ค. ๋งํฌ๋ฅผ fetch ํ๋ ์ฐํ ๋์ ์ด์๋ฅผ ๊ณ ์น๋ค.
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 |
| Tier | Label | Criteria |
|---|---|---|
| P0 | p0 | Blocker or core-path Story โ must be done first |
| P1 | p1 | Core 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 ์ ๋ฌดยท์ธ๋ถ ์ฐ๋ ์ ๋ฌด)๋ง์ผ๋ก ๊ฐ์ฅ ๊ฐ๊น์ด ๋จ๊ณ๋ฅผ ๊ณ ๋ฅธ๋ค.
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 ์์กด |
| ์ ์ ํธ๊ฐ ์ ํ ์์ | ๋ ๋ฆฝ(๋ณ๋ ฌ ํ๋ณด) โ ๊ธฐ๋ณธ๊ฐ |
โ ๏ธ ์ด ๊ธฐ๋ณธ๊ฐ(๋ถํ์ค โ ๋ ๋ฆฝ)์
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 | ## ๐ ์์ธ ๊ธฐํ (์ํฐํฉํธ ๋งํฌ) |
โ * | โ * | ์กฐ๊ฑด๋ถ* | ์กฐ๊ฑด๋ถ* | ์กฐ๊ฑด๋ถ* | โ |
| 2 | ## ๐ ๊ฐ์ |
โ | โ | โ | โ | โ | โ |
| 3 | ํ์ ๋ณ ํต์ฌ ์น์ (์๋ ํ) | โ | โ | โ | โ | โ | โ |
| 4 | ## ๐ ๏ธ ๊ธฐ์ ๋
ธํธ/๊ธฐ์ ์์
|
์ ํ | โ | โ | ์ ํ | โ | โ |
| 5 | ## ๐ข ์ฐ์ ์์ (ํ) |
โ | โ | โ | โ | โ | โ |
| 6 | ## ๐ ๊ด๋ จ ์ด์ |
โ | โ | โ | โ | โ | โ |
| 7 | ## ๐ ์์ Story Point |
โ | โ (๋กค์ ) | โ | โ | โ | โ |
| 8 | footer (๐ค Generated by) |
โ | โ | โ | โ | โ | โ |
* Issue Body Artifact Contract(์) ํ์ ์ ๋ฐ๋ฆ โ Sub-task ๋ ํญ์ ์ ์ธ, Feature/Bug/Task ๋ ์์ 20ํ ์ด๊ณผ์ผ ๋๋ง.
ํ์ ๋ณ 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
โ
## ๐ ์์
๋ด์ญ(์ํฐํฉํธ ๋งํฌ) โ ## 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 Step 8-0 ยท 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