Ticketing Epic Organization
Dispatch Surface
Target: Agent Teams
Overview
Group orphaned tickets (tickets with no parent epic) into sensible epics. Auto-creates epics when the grouping is obvious (consistent naming prefix, single repo). Gates on human approval when groupings are ambiguous.
Announce at start: "I'm using the ticketing-epic-org skill to organize orphaned tickets."
Imports: @_lib/contracts/helpers.md
Quick Start
# Run after ticketing-triage (uses its TriageReport)
/ticketing-epic-org --triage-report $ONEX_STATE_DIR/state/ticketing-triage/{run_id}.yaml
# Run standalone (fetches orphans fresh from Linear)
/ticketing-epic-org
# Preview without creating
/ticketing-epic-org --dry-run
Algorithm
Phase 1: Load Orphaned Tickets
If --triage-report provided:
Read orphaned_tickets list from the TriageReport YAML.
If no triage report: Fetch orphans directly from Linear:
tracker.list_issues(
state="not done",
limit=250
)
Filter to tickets where parentId == null.
Phase 2: Group by Epic
Apply grouping rules in priority order:
Rule 1: Named prefix (auto-create eligible)
Tickets matching [repo] PREFIX-NN: pattern with the same PREFIX are grouped together.
from collections import defaultdict
import re
def group_by_prefix(tickets):
groups = defaultdict(list)
for t in tickets:
# "[omniclaude] DB-SPLIT-03: ..." → key = ("omniclaude", "DB-SPLIT")
m = re.match(r'^\[([^\]]+)\]\s+([A-Z][A-Z0-9-]+?)-\d+:', t.title)
if m:
repo, prefix = m.group(1), m.group(2)
groups[(repo, prefix)].append(t)
continue
# "DB-SPLIT-03: ..." (no repo prefix, but repo known from branchName/label)
m = re.match(r'^([A-Z][A-Z0-9-]+?)-\d+:', t.title)
if m and t.repo:
groups[(t.repo, m.group(1))].append(t)
return dict(groups)
Auto-create eligible: groups with ≥2 tickets AND consistent repo AND clear prefix.
Rule 2: Same repo + same Linear label (auto-create eligible if ≥3 tickets)
Tickets in the same repo sharing a domain label (not a state/priority label):
def group_by_label(tickets):
groups = defaultdict(list)
domain_labels = {l for t in tickets for l in t.labels
if l not in ("bug", "enhancement", "question", "wont-fix")}
for t in tickets:
for label in t.labels:
if label in domain_labels:
groups[(t.repo, label)].append(t)
return dict(groups)
Auto-create eligible: groups with ≥3 tickets.
Rule 3: Single ticket (human decision)
Tickets not matching Rule 1 or 2 are presented to the user for manual grouping or individual epic creation. Never auto-create a single-ticket epic.
Phase 3: Classify Auto-Create vs Human Gate vs Structural Violation
Each proposed group is run through the structural guards in
omniclaude.epic_org.guards (canonical implementation). The guard
returns one of three verdicts:
auto_create: group size ≥ 2 AND single repo AND clear naming prefix
human_gate: anything else (ambiguous repo, single ticket, cross-repo mix)
structural_violation: every member of the group is itself an epic — REFUSED
structural_violation MUST cause the skill to refuse the group entirely. It
is not a "flag and then proceed" verdict — auto-creating a parent over
existing epics is structurally wrong. Detection rule:
- every member has a Linear
Epiclabel, OR - every member's title starts with
[Epic]orEpic:(after stripping a leading[<repo>]bracket if present).
Algorithm (must be invoked exactly as classify_proposed_group from
omniclaude.epic_org.guards — do not re-implement inline):
from omniclaude.epic_org.guards import classify_proposed_group
from omniclaude.epic_org.models import EnumProposedGroupVerdict
verdict = classify_proposed_group(group)
if verdict.verdict is EnumProposedGroupVerdict.STRUCTURAL_VIOLATION:
# REFUSE — emit under report.structural_violations
# do NOT auto-create, do NOT prompt the user to override
...
Phase 3b: Secondary clustering pass within each group
For every group that survives the structural-violation refusal, run
secondary_cluster_pass over its members (same module). This surfaces
sub-cohorts the primary prefix/label rules missed:
phase— titles containingPhase <N>(e.g.OmniStudio Phase 1).prefix-nn— short ALL-CAPS prefix + dash + digit (e.g.SEAM-1,DB-SPLIT-3).multi-word-prefix— hyphenated multi-word prefix (e.g.Cross-CLI).
Sub-cohorts surfaced inside a group whose parent verdict is human_gate
are emitted as separate proposed groups in the report, but they are NOT
auto-applied — they inherit the parent's human-review requirement.
Phase 4: Present Proposed Groupings
Always show the full plan before creating anything. The report MUST include
a top-level structural_violations block whenever the guard returned that
verdict for any group.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Epic Organization Proposal
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STRUCTURAL VIOLATIONS (refused — no action taken):
⛔ EPIC (10 tickets — all members are themselves epics)
ticket-A, ticket-B, ticket-C, ticket-D, ticket-E, ticket-F,
ticket-G, ticket-H, ticket-I, ticket-J
→ Reason: cannot create a parent over existing epics.
→ Action: leave as top-level epics; group manually if a meta-initiative
is genuinely needed.
AUTO-CREATE (obvious groupings):
📦 [omniclaude] DB-SPLIT (3 tickets)
ticket-X — DB-SPLIT-03: FK scan
ticket-Y — DB-SPLIT-04: Migration validation
ticket-Z — DB-SPLIT-05: Cross-service FK removal
→ Proposed epic: "[omniclaude] DB-SPLIT — Database Split"
📦 [omnibase_core] CLI-REG (2 tickets)
ticket-P — Create YAML schemas for agent definitions
ticket-Q — Define cli.contribution.v1 contract schema
→ Proposed epic: "[omnibase_core] CLI Registry"
NEEDS HUMAN INPUT (ambiguous groupings):
❓ ambiguous (13 tickets) — secondary clustering pass surfaced 3 sub-cohorts:
• OmniStudio Phase (4 tickets, pattern=phase): ticket-A1, ticket-A2, ticket-A3, ticket-A4
• SEAM (4 tickets, pattern=prefix-nn): ticket-B1, ticket-B2, ticket-B3, ticket-B4
• Cross-CLI (3 tickets, pattern=multi-word-prefix): ticket-C1, ticket-C2, ticket-C3
→ Each sub-cohort proposed as a separate group; not auto-applied.
❓ 2 cross-repo tickets
ticket-M (omninode_infra), ticket-N (onex_change_control)
→ Suggest: add to existing CLAUDE.md Consolidation epic?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Proceed? [y/n/edit]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
The on-disk EpicOrgReport YAML must include the corresponding sections:
structural_violations:
- group: "EPIC"
count: 10
tickets: ["ticket-A", "ticket-B", ...]
reason: "All 10 members are themselves epics. Refused per structural guard."
proposed_epic_groups:
- group: "ambiguous"
count: 13
sub_cohorts:
- cohort_key: "OmniStudio Phase"
pattern: "phase"
members: ["ticket-A1", "ticket-A2", "ticket-A3", "ticket-A4"]
- cohort_key: "SEAM"
pattern: "prefix-nn"
members: ["ticket-B1", "ticket-B2", "ticket-B3", "ticket-B4"]
- cohort_key: "Cross-CLI"
pattern: "multi-word-prefix"
members: ["ticket-C1", "ticket-C2", "ticket-C3"]
If user says y: proceed with auto-create only; leave ambiguous for next step.
If user says n: abort.
If user says edit: present each ambiguous group individually for decision.
For each ambiguous group, ask:
Group: 4 omniintelligence tickets [ticket-E1, ticket-E2, ticket-E3, ticket-E4]
Options:
a) Add to existing Review-Fix Pairing epic
b) Create new epic
c) Leave unparented (skip)
Choice [a/b/c]:
Phase 5: Create Epics
For each auto-create group (and human-approved groups):
Step 5a: Build EpicContract
id: null
title: "[{repo}] {PREFIX} — {human readable description}"
emoji: "{select appropriate emoji}"
status: "In Progress"
priority: "High"
scope: "Tickets from the {PREFIX} work stream in {repo}"
repos:
- "{repo}"
children: []
Emoji selection guide:
- DB/schema work → 🗃️
- CI/testing → 🧪
- Security → 🔒
- API/endpoints → 🔌
- Refactoring → 🔧
- Documentation → 📋
- Performance → ⚡
- Infrastructure → 🏗️
- Agent/AI features → 🤖
- Frontend/UI → 🎨
Step 5b: Create epic in Linear
tracker.save_issue(
title="[{repo}] {PREFIX} — {description}",
team="Omninode",
state="In Progress",
labels=["{repo}"]
)
→ returns new epic ID
Step 5c: Link children
For each child ticket:
tracker.save_issue(
id=ticket_id,
parentId=new_epic_id
)
Step 5d: Add creation comment
tracker.create_comment(
issueId=new_epic_id,
body="🤖 Epic created by ticketing-epic-org\n\nGrouped {N} tickets from {PREFIX} work stream:\n{ticket_list}"
)
Phase 6: Report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Epic Organization Complete
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Epics created: {N}
Children linked: {M}
Skipped (human): {K}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Existing Epic Detection
Before creating a new epic, check if a suitable existing epic already exists:
tracker.list_issues(
query="{PREFIX}",
state="not done"
)
If an existing epic with matching prefix is found AND its scope matches, prefer adding children to it rather than creating a duplicate.
Dry-Run Mode
When --dry-run:
- All grouping logic runs normally
- No
save_issuecalls are made - Print the full proposal but do not prompt for confirmation
- Output ends with: "Dry run complete — no changes made"
See Also
@_lib/contracts/helpers.md— EpicContract schematicketing-triageskill — produces orphaned_tickets list this skill consumeslinear-housekeepingskill — parent orchestrator- Linear MCP tools (
tracker.*)