LogoSkills

ZenHub Conventions

Issue management rules using ZenHub.

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

ํ•ด๊ฒฐ#

  1. ๋Œ€์ƒ ํ”„๋กœ์ ํŠธ์˜ ZenHub ์›Œํฌ์ŠคํŽ˜์ด์Šค์— ์ •์‹์œผ๋กœ ์Šค์ฝ”ํ”„๋œ API ํ† ํฐ์„ ๋ฐœ๊ธ‰/ํ™•์ธํ•ด .mcp.json ์— ์„ค์ •ํ•œ๋‹ค.
  2. .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} " 
    
  3. โš ๏ธ ์…ธ ํ”„๋กœํ•„(~/.zshrc ๋“ฑ)์— ์ƒˆ๋กœ export ํ•œ ํ™˜๊ฒฝ๋ณ€์ˆ˜๋Š” ์ด๋ฏธ ์‹คํ–‰ ์ค‘์ธ ํ”„๋กœ์„ธ์Šค์— ๋ฐ˜์˜๋˜์ง€ ์•Š๋Š”๋‹ค. /mcp ์žฌ์—ฐ๊ฒฐ๋งŒ์œผ๋กœ๋Š” ๋ถ€์กฑํ•˜๋‹ค โ€” ์™„์ „ํžˆ ์ƒˆ ํ„ฐ๋ฏธ๋„ ์ฐฝ์—์„œ Claude Code ๋ฅผ ์žฌ์‹œ์ž‘ํ•ด์•ผ ํ•œ๋‹ค(๊ฐ™์€ ํƒญ/์„ธ์…˜ ์žฌ์‚ฌ์šฉ ๊ธˆ์ง€, ํ•„์š”์‹œ ๋จผ์ € source ~/.zshrc). GUI ๋กœ ์‹คํ–‰ ์ค‘์ด๋ฉด ~/.zshrc ์ž์ฒด๊ฐ€ ๋กœ๋“œ๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์–ด CLI ์‹คํ–‰์„ ๊ถŒ์žฅํ•œ๋‹ค.
  4. ์—ฌ๋Ÿฌ ์›Œํฌ์ŠคํŽ˜์ด์Šค์— ๊ฑธ์ณ ์ž‘์—…ํ•ด์•ผ ํ•œ๋‹ค๋ฉด(์˜ˆ: ๋ฒ”์šฉ ์›Œํฌ์ŠคํŽ˜์ด์Šค + ํ”„๋กœ์ ํŠธ๋ณ„ ์›Œํฌ์ŠคํŽ˜์ด์Šค), ํ”„๋กœ์ ํŠธ๋งˆ๋‹ค .mcp.json ์˜ zenhub ์„œ๋ฒ„ ํ•ญ๋ชฉ์„ ํ”„๋กœ์ ํŠธ ์ „์šฉ ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์ด๋ฆ„์œผ๋กœ ๋ถ„๋ฆฌํ•œ๋‹ค.

Issue Type Hierarchy#

Issue types are hierarchical (level field, lower = higher level). Standard workspace hierarchy:

LevelTypeScopeParent
1InitiativeMulti-quarter strategic themeโ€”
2 Project Product/milestone unit (multiple features) Initiative (optional)
3EpicSingle feature unitProject (optional)
4Feature / Bug / TaskStory (screen/work unit)Epic
5Sub-taskDetailed taskStory

Hierarchy rules:

  • Parent-child links use parentIssueId (at creation) or setParentForIssues (after creation)
  • A child's type level must 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:

LimitationConsequence
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) throw against 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:run Step 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:

AxisQuestionWeight
Dependency (blocker) Do other Stories depend on this? (e.g., Entity/API foundation) High
Business valueIs it on the Epic's core user path?High
Risk/uncertaintyTechnically uncertain โ†’ tackle early to reduce riskMedium
Effort (points)Among equals, smaller points first (fast feedback)Low
TierLabelCriteria
P0p0Blocker or core-path Story โ€” must be done first
P1p1Core feature in scope, no dependents
P2p2Improvement/nice-to-have, deferrable

โš ๏ธ Labels must be set at creation time โ€” updateIssue does not support label changes. Therefore, the priority review happens before issue creation; labels are passed to createGitHubIssue.

Priority โ†’ Pipeline Placement#

TargetPipeline
Project / EpicProduct Backlog
Story P0 (with --sprint) Sprint Backlog + addIssuesToSprints
Story P0/P1 (no sprint)Product Backlog
Story P2Icebox
Sub-taskFollows 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:

  1. Move in descending priority order โ€” move P0 issues first, then P1, then P2 (one moveIssueToPipeline call each, sequentially)
  2. Record the priority table in the parent issue body โ€” Epic/Project body includes a ## ๐Ÿ”ข ์šฐ์„ ์ˆœ์œ„ table (rank / issue # / tier / rationale) as the source of truth
  3. 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 PointsetIssueEstimate
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 ๊ฐ€ ์—†์„ ๋•Œ):

ํฌ๊ธฐํฌ์ธํŠธ๊ธฐ์ค€
XS3ํ™”๋ฉด/์—”๋“œํฌ์ธํŠธ 1๊ฐœ ์ˆ˜์ค€
S5ํ™”๋ฉด/์—”๋“œํฌ์ธํŠธ 2~3๊ฐœ, ์ƒˆ ์˜์กด์„ฑ ์—†์Œ
M8ํ™”๋ฉด/์—”๋“œํฌ์ธํŠธ 4~6๊ฐœ, ๋˜๋Š” ์ƒˆ Entity/API 1๊ฐœ
L13์—ฌ๋Ÿฌ ํ™”๋ฉด + ์ƒˆ Entity/API, ์™ธ๋ถ€ ์—ฐ๋™ 1๊ฐœ
XL21ํ•˜์œ„ ์‹œ์Šคํ…œ ํ•˜๋‚˜(์—ฌ๋Ÿฌ Entity, ์—ฌ๋Ÿฌ ํ™”๋ฉด, ๋ฐฐ์น˜/์•Œ๋ฆผ ๋“ฑ ๋ณด์กฐ ํ๋ฆ„ ํฌํ•จ)
XXL34์œ„ ์ด์ƒ โ€” ๋Œ€๊ฐœ ์ด ํฌ๊ธฐ๋Š” 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.md D-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.md Phase 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๊ฐœ

  1. ์„ ์–ธ์ด ์•„๋‹ˆ๋ผ ํ˜ธ์ถœ์ด๋‹ค. ํ๋ฆ„ ๋ธ”๋ก์˜ record:BLOCKED(...)+board, ๋ฌธ์„œ์˜ "๋ณด๋“œ์— ๋ฐ˜์˜ํ•˜๊ณ  ์ค‘๋‹จ" ์€ ์ „๋ถ€ ์•„๋ž˜ reflectBoardState() ํ˜ธ์ถœ์„ ๋œปํ•œ๋‹ค. ๋ฌธ๊ตฌ๋งŒ ์žˆ๊ณ  ํ˜ธ์ถœ์ด ์—†์œผ๋ฉด ๊ทธ ๊ฒฝ๋กœ๋Š” ๋ณด๋“œ๋ฅผ ๊ฐฑ์‹ ํ•˜์ง€ ์•Š๋Š”๋‹ค โ€” ์‹ค์ œ๋กœ ๋น„์–ด ์žˆ๋˜ ์ž๋ฆฌ๊ฐ€ commands/run.md ์˜ BLOCKED ์ข…๋ฃŒ 6๊ณณ๊ณผ commands/batch.md ์˜ record:...+board ํ‘œ๊ธฐ 5์ข…์ด์—ˆ๋‹ค.
  2. ๋น„์ •์ƒ ์ข…๋ฃŒ๋„ ์ƒํƒœ๋‹ค. ์ •์ƒ ๊ฒฝ๋กœ(์ฐฉ์ˆ˜ โ†’ PR โ†’ ๋จธ์ง€=Close)๋งŒ ๊ฐฑ์‹ ํ•˜๋ฉด ๋ณด๋“œ๋Š” ์‹คํŒจ๋ฅผ ๊ฐ์ถ˜๋‹ค. ๋ง‰ํžŒ ์ด์Šˆยท์ค‘๋‹จ๋œ ์ด์Šˆยท๋จธ์ง€ ์—†์ด ๋‹ซํžŒ PR ์€ ์ „๋ถ€ ์ „์ดํ‘œ์— ํ–‰์„ ๊ฐ–๋Š”๋‹ค. ๊ฐฑ์‹ ํ•˜์ง€ ์•Š์œผ๋ฉด ๊ทธ ์ด์Šˆ๋Š” In Progress ์— ์˜๊ตฌ ์ž”๋ฅ˜ํ•˜๊ณ , ๊ทธ ์ƒํƒœ๊ฐ€ ๋‹ค์‹œ ๋‹ค์Œ ์„ธ์…˜์˜ ์ ์œ  ์˜คํŒ์œผ๋กœ ์ด์–ด์ง„๋‹ค.
  3. ์“ฐ๊ณ  ๋‚˜์„œ ์ฝ๋Š”๋‹ค. 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.md R3(๋ธŒ๋žœ์น˜ ๋Œ€์žฅ) ์˜ ๋งˆ์ปค ์ฝ”๋ฉ˜ํŠธ ํ”„๋กœํ† ์ฝœ์„ ๊ทธ๋Œ€๋กœ ์“ด๋‹ค โ€” ๋ณต์ œ ๊ธˆ์ง€. ์ฝ๊ธฐ๋Š” | 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#

TypePrefixExample
Project(์—†์Œ โ€” Issue Type์œผ๋กœ ๊ตฌ๋ถ„)Admin console v2 milestone
Epic (์—†์Œ โ€” Issue Type์œผ๋กœ ๊ตฌ๋ถ„) API integration and SWR caching strategy
Story(์—†์Œ โ€” Issue Type์œผ๋กœ ๊ตฌ๋ถ„)classroom API integration
Bugfix:fix: Login token refresh error
Featurefeat:feat: Add user profile page
Taskchore: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:

StateOwnerMeaning
Pipeline ZenHub Board column (In Progress, Review/QA, Done, โ€ฆ). Just a position.
GitHub state GitHub The real open / closed flag.
  • Done pipeline โ‰  closed. An issue moved to Done is still open on GitHub. Done means "ready to close", not closed.
  • Closed pipeline = GitHub closed, 1:1. Judge open/closed by GitHub state, never by a column.
  • An issue truly closes via exactly one of: (1) drag to Closed pipeline, (2) close on GitHub, (3) a PR merging with Closes #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 moveIssueToPipeline a closed issue into a non-Closed pipeline (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 Closed pipeline is the exception โ€” routing there never reopens. It is wired 1:1 to GitHub closed, so the move is safe in both directions: for an already-closed issue it is a no-op (it is already in Closed; no reopen), and for an open issue it actively closes it on GitHub (valid close mechanism #1, equivalent to gh issue close). So moveIssueToPipeline({ pipelineId: <Closed>.id }) is an acceptable explicit-close / fallback โ€” resolve the id with getWorkspacePipelinesAndRepositories().pipelines.find(p => p.name === "Closed") (skip gracefully if the workspace does not expose it). gh issue close remains the simplest path; this is a sanctioned alternative, not a prohibition.

Once an issue is closed on GitHub, ZenHub auto-syncs it to Closed via webhook with no pipeline move needed. If you accidentally moved a closed issue into a non-Closed column and it reopened, re-close it on GitHub (gh issue close) or move it to the Closed pipeline โ€” do not leave it parked open in the wrong column.

โœ… GitHub close โ†’ ZenHub Closed is 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 the Closed pipeline (safe โ€” see above), never to a non-Closed column.

โš ๏ธ Hierarchical (work-base) merges never auto-close. In the branch hierarchy (task/ โ†’ story/ โ†’ epic/ โ†’ development, extended by project/ and initiative/ above epic/ 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's Closes #N never fires, the issue stays open, and ZenHub never reaches Closed. Even the top-of-chain level (whichever of Initiative/Project/Epic has no parent) merging into development does not auto-close unless development is 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:

  1. Every PR body includes Closes #{number}. Squash merge auto-closes GitHub โ†’ ZenHub syncs to Closed only 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).
  2. Never park completed issues in Done (it would leave them open).
  3. 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 Done column are not counted as complete โ€” another reason to always reach Closed.

Parent Closure Invariant โ›” (์ปจํ…Œ์ด๋„ˆ๋Š” ์—ด๋ฆฐ ์ž์‹ ์œ„์—์„œ ๋‹ซํžˆ์ง€ ์•Š๋Š”๋‹ค)#

A container issue (Initiative/Project/Epic/Story) must never be closed while any of its children is open.

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:

WhenWhereOn 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#

  1. ZenHub Issues vs GitHub Issues

    • ZenHub issues: Cannot move pipelines/set timelines
    • GitHub issues: All ZenHub features work correctly
  2. Parent-Child Relationships

    • Link on creation via parentIssueId parameter
    • Or link later with setParentForIssues
  3. Search

    • searchLatestIssues: Only searches GitHub issues
    • ZenHub issues are not searchable