---
description: Orchestrate a Claude Code agent team to autonomously work a Linear epic across multiple repos
mode: full
version: 2.0.0
level: advanced
debug: false
category: workflow
tags: [epic, team, multi-repo, autonomous, linear, slack]
args:
  - epic_id (required): Linear epic ID (e.g., OMN-2000)
  - --dry-run: Print decomposition plan (includes unmatched reason), no spawning
  - --force: Pause if active tasks remain; archive state and restart
  - --force-kill: Combine with --force to destroy active run even with live workers
  - --resume: Re-enter monitoring; finalize if all tasks terminal; no-op if already done
  - --force-unmatched: Route unmatched tickets to omniplan as TRIAGE tasks
mode: full
---

# Epic Team Orchestration

## Dispatch Requirement

When invoked, your FIRST and ONLY action is to dispatch to a polymorphic-agent. Do NOT read
files, run bash, or take any other action before dispatching.

```
Agent(
  subagent_type="onex:polymorphic-agent",
  description="Run epic-team for <epic_id>",
  prompt="Run the epic-team skill. <full context and args>"
)
```

**CRITICAL**: `subagent_type` MUST be `"onex:polymorphic-agent"` (with the `onex:` prefix).

> **Session lifetime**: The monitoring phase is alive only while this session runs. Use `/epic-team {epic_id} --resume` to re-enter after a disconnection.

> **Architecture note (v2.0.0)**: epic-team is a thin orchestrator. All business logic lives in
> independently-invocable composable sub-skills. epic-team's job is coordination, state, and routing
> — not implementation.

## Overview

Decompose a Linear epic into per-repo workstreams and autonomously drive them to completion.
The team lead (this session) owns planning, dispatch, state persistence, and lifecycle
notifications. Tickets are executed by dispatching `ticket-pipeline` as sequential `Task()`
subagents directly from the team-lead session, in dependency-respecting waves.

**Key constraint**: Workers spawned as team members (via `TeamCreate` + `Task(team_name=...)`)
go idle immediately and never process tasks. The proven working pattern is **direct dispatch
from the team-lead session** — see the Architecture section below.

**If the epic has zero child tickets**, epic-team invokes `decompose-epic` to create sub-tickets,
then posts a Slack LOW_RISK gate. Silence for 30 minutes = proceed.

## Execution Rules

Execute end-to-end without stopping between tasks. If blocked on one task, record a skip note
and continue to the next. Only pause for: (a) credentials not available in the session,
(b) a destructive action not explicitly covered by the plan, or (c) an explicit user gate in
the plan. Do not exit plan mode or stop to "await direction" in any other circumstance.

---

## Wave Cap

Dispatch tickets in waves of **5 tickets maximum** to prevent usage-limit interruptions.
Start the next wave only after all Task() calls in the current wave have returned.
Never dispatch more than 5 tickets in a single wave, even if more are independent.

---

## Composable Sub-Skills

epic-team orchestrates these independently-invocable primitives:

| Sub-Skill | Purpose | Ticket |
|-----------|---------|--------|
| `decompose-epic` | Analyze epic → create Linear child tickets | OMN-2522 |
| `slack-gate` | LOW_RISK / MEDIUM_RISK / HIGH_RISK human gates | OMN-2521 |
| `ticket-pipeline` | Per-ticket pipeline (implement → review → PR → CI → merge) | — |

`ticket-pipeline` in turn composes:

| Sub-Skill | Purpose | Ticket |
|-----------|---------|--------|
| `ticket-work` | Implement ticket (autonomous mode) | OMN-2526 |
| `local-review` | Review + fix loop | — |
| `ci-watch` | Poll CI, auto-fix failures | OMN-2523 |
| `pr-watch` | Poll PR reviews, auto-fix comments | OMN-2524 |
| `auto-merge` | Merge PR with HIGH_RISK Slack gate | OMN-2525 |

Each layer is independently invocable:
- `ticket-pipeline` runs standalone without `epic-team`
- `ticket-work` runs standalone without `ticket-pipeline`
- `ci-watch` runs standalone on any PR

**If the epic has zero child tickets**, epic-team automatically invokes the `decompose-epic` sub-skill to analyze the epic description and create sub-tickets, then posts a Slack LOW_RISK gate. Silence for 30 minutes means proceed with newly created tickets.

## Usage Examples

```bash
# Dry run — see decomposition without spawning agents
/epic-team OMN-2000 --dry-run

# Full run
/epic-team OMN-2000

# Resume after session disconnect
/epic-team OMN-2000 --resume

# Force restart (archive existing state; pauses if workers active)
/epic-team OMN-2000 --force

# Force restart even with active workers (dangerous)
/epic-team OMN-2000 --force --force-kill

# Route unmatched tickets to omniplan triage
/epic-team OMN-2000 --force-unmatched
```

## Orchestration Flow

```
epic-team OMN-XXXX
  → Fetch child tickets from Linear
  → If 0 child tickets:
      → Dispatch decompose-epic (composable, returns skill_result)
      → Read $ONEX_STATE_DIR/skill-results/{context_id}/decompose-epic.json
      → Dispatch slack-gate (LOW_RISK, 30 min, silence=proceed)
      → Read $ONEX_STATE_DIR/skill-results/{context_id}/slack-gate.json
      → If rejected: stop
      → Re-fetch newly created tickets
  → Assign tickets to repos via repo_manifest
  → Build dependency waves:
      Wave 0: independent tickets + cross-repo Part 1 splits (run in parallel)
      Wave 1: cross-repo Part 2 splits (run after Wave 0 completes)
  → For each wave: dispatch ticket-pipeline per ticket as Task() from team-lead session
  → Await all Task() calls in wave before starting next wave
  → Collect results (status, pr_url, branch) from each Task()
  → Post-wave integration check (OMN-3345): run gap cycle --no-fix per repo touched
      → GREEN/YELLOW/RED per repo → post to Slack epic thread
      → Write integration_check section to state.yaml (non-blocking — always advances)
  → DoD compliance gate (OMN-5833): invoke /dod-sweep {epic_id} (targeted mode)
      → If FAIL: do NOT mark epic Done; follow-ups auto-created; post Slack block
      → If PASS: proceed to mark epic Done; post Slack clean notification
      → If UNKNOWN (all exempt): proceed (rollout accommodation); post review warning
  → Send Slack lifecycle notifications (started, ticket done, epic done)
  → Persist state to $ONEX_STATE_DIR/epics/{epic_id}/state.yaml
```

## Dispatch: decompose-epic

When epic has 0 child tickets:

```
Task(
  subagent_type="onex:polymorphic-agent",
  description="epic-team: auto-decompose empty epic {epic_id}",
  prompt="The epic {epic_id} has no child tickets.

    Invoke: Skill(skill=\"onex:decompose-epic\", args=\"{epic_id}\")

    Read result from $ONEX_STATE_DIR/skill-results/{context_id}/decompose-epic.json
    Report back: created_tickets (list of IDs and titles), count."
)
```

## Dispatch: Ticket-Pipeline per Ticket (Direct Dispatch Pattern)

For each ticket in a wave, dispatch ticket-pipeline as a Task() from the team-lead session:

```
Task(
  subagent_type="onex:polymorphic-agent",
  description="epic-team: ticket-pipeline for {ticket_id} [{repo}]",
  prompt="You are executing ticket {ticket_id} for epic {epic_id}.

    Ticket: {ticket_id} - {title}
    URL: {url}
    Repo: {repo} at {repo_path}
    Epic: {epic_id}  Run: {run_id}

    Invoke: Skill(skill=\"onex:ticket-pipeline\", args=\"{ticket_id}\")

    After ticket-pipeline completes, report back:
    - ticket_id: {ticket_id}
    - status: (merged/failed/blocked)
    - pr_url: (if available)
    - branch: (branch name used)"
)
```

**Wave parallelism**: All Task() calls within a wave MUST be dispatched in the same response
(same message) for true parallelism. Do NOT dispatch tickets sequentially within a wave.

**Wave serialization**: Wave N+1 starts only after all Task() calls from Wave N have returned.

**DEPRECATED**: Spawning per-repo workers via `TeamCreate` + `Task(team_name=...)` + a
`WORKER_TEMPLATE` is no longer used. See `prompt.md` for the deprecated WORKER_TEMPLATE
preserved for historical reference.

## Skill Result Communication

All sub-skills write their output to `$ONEX_STATE_DIR/skill-results/{context_id}/`:

| Sub-Skill | Output File | Key Fields |
|-----------|------------|------------|
| `decompose-epic` | `decompose-epic.json` | status, created_tickets, count |
| `slack-gate` | `slack-gate.json` | status (accepted/rejected/timeout) |
| `ticket-pipeline` | `ticket-pipeline.json` | status, ticket_id, pr_url |
| `ticket-work` | `ticket-work.json` | status, pr_url, phase_reached |
| `local-review` | `local-review.json` | status, iterations_run |
| `ci-watch` | `ci-watch.json` | status, fix_cycles_used |
| `pr-watch` | `pr-watch.json` | status, fix_cycles_used |
| `auto-merge` | `auto-merge.json` | status, merge_commit |

## Skill Result Input Contract

**Input contract:** All sub-skill result files conform to `ModelSkillResult` from `omnibase_core.models.skill`.

> **Note: This contract reference is behavioral guidance for the LLM executing this skill. Runtime validation not yet implemented.**

Load result files and check outcomes as follows:

```python
result = ModelSkillResult.from_json(path.read_text())

# Check if the sub-skill completed successfully (success, partial, or dry_run)
if result.is_success_like:
    # Proceed with next wave or action
    pass

# Check for hard failure
elif result.status == EnumSkillResultStatus.FAILED:
    # Record failure, apply circuit breaker (max 2 retries per ticket)
    pass

# Check for blocking states
elif result.status == EnumSkillResultStatus.GATED:
    # Human approval is pending — do not advance wave
    pass

# Access skill-specific fields via extra dict (not direct attribute access).
# Use .get() with a default — extra may be empty on non-success paths.
created_tickets = result.extra.get("created_tickets", [])   # decompose-epic result
iterations_run = result.extra.get("iterations_run", 0)      # local-review result
```

**Behaviorally significant `extra_status` values by sub-skill:**

| Sub-Skill | `extra_status` | Orchestrator action |
|-----------|---------------|---------------------|
| `decompose-epic` | `null` | Normal — read `extra.get("created_tickets", [])` |
| `slack-gate` | `"accepted"` | Silence-proceed gate passed — continue |
| `slack-gate` | `"rejected"` | Gate rejected — stop (cancel orchestration) |
| `slack-gate` | `"timeout"` | Gate timed out — apply configured timeout policy |
| `ticket-pipeline` | `"merged"` | Ticket fully merged — record as done |
| `ticket-pipeline` | `"held"` | Merge gate open — non-terminal, do not retry yet |
| `auto-merge` | `"merged"` | PR merged — record wave ticket as complete |
| `auto-merge` | `"timeout"` | Merge gate expired — retryable with new pipeline run |

**Promotion rule for `extra` fields:** If any orchestrator consumer (epic-team, ticket-pipeline) branches on `extra["x"]`, that field MUST be promoted to a first-class field in `ModelSkillResult`. `extra` is a migration bridge, not a permanent schema extension mechanism.

## State Persistence

Runtime state is persisted to `$ONEX_STATE_DIR/epics/{epic_id}/state.yaml`:

```yaml
epic_id: OMN-XXXX
run_id: f084b6c3
status: monitoring  # queued | monitoring | done | failed
checkpoint:
  schema_version: 1
  last_completed_wave: 0
  waves:
    - wave_id: 0
      tickets: [OMN-2001, OMN-2002]
      status: completed  # pending | in_progress | completed | failed
      completed_at: "2026-03-06T..."
    - wave_id: 1
      tickets: [OMN-2003]
      status: pending
  open_prs:
    OMN-2001: {pr_number: 45, repo: "omniclaude", branch: "jonah/omn-2001-..."}
  failures:
    OMN-2003: {class: "ci_failure_ruff", attempts: 1, last_error: "..."}
  last_update_utc: "2026-03-06T..."
workers:
  - repo: omniclaude
    tickets: [OMN-2001, OMN-2002]
    status: running  # running | done | failed
ticket_status:
  OMN-2001: merged
  OMN-2002: running
```

Use `--resume` to re-enter monitoring from persisted state after session disconnect.

## Empty Epic Auto-Decompose

When epic has 0 child tickets:

```
[LOW_RISK] epic-team: Auto-decomposed OMN-XXXX

Epic had no child tickets. Created N sub-tickets:
  - OMN-YYYY: [title]
  - OMN-ZZZZ: [title]
  ...

Reply "reject" within 30 minutes to cancel. Silence = proceed with orchestration.
```

### --dry-run behavior for empty epic

Invoke `decompose-epic --dry-run` (returns plan, no tickets created). Print plan. Do not post
Slack gate.

## Repo Manifest

Ticket-to-repo assignment uses `plugins/onex/skills/epic-team/repo_manifest.yaml`:

```yaml
MIN_TOP_SCORE: 4

repos:
  - name: omniclaude
    path: ~/Code/omniclaude
    keywords: [hooks, skills, agents, claude, plugin, ticket-pipeline]
  - name: omnibase_core
    path: ~/Code/omnibase_core
    keywords: [nodes, contracts, runtime, onex]
  - name: omnibase_infra
    path: ~/Code/omnibase_infra
    keywords: [kubernetes, deploy, infra, helm]
```

Keyword matching is case-insensitive. Tickets with no keyword match are UNMATCHED.
Use `--force-unmatched` to route them to omniplan as TRIAGE tasks.

## Worktree Policy

Workers create isolated git worktrees at:
```
$ONEX_STATE_DIR/worktrees/{epic_id}/{run_id}/{ticket_id}/
```

Stale worktrees are cleaned up automatically after merge when `auto_cleanup_merged_worktrees: true`
(default).

## Architecture

epic-team is a thin composition layer. It owns:
- Epic decomposition (via `decompose-epic`)
- Ticket-to-repo assignment (via repo_manifest)
- Wave construction (group tickets by dependency into parallel waves)
- Direct Task() dispatch of ticket-pipeline per ticket (from team-lead session)
- State persistence (`$ONEX_STATE_DIR/epics/{epic_id}/state.yaml`)
- Slack lifecycle notifications (started, ticket done, epic done)

It does NOT own:
- Ticket implementation (delegated to `ticket-pipeline` → `ticket-work`)
- Code review (delegated to `local-review`)
- CI polling (delegated to `ci-watch`)
- PR review polling (delegated to `pr-watch`)
- Merge execution (delegated to `auto-merge`)

### Execution Model

**Direct dispatch from team-lead session** is the authoritative execution pattern:

1. Team-lead constructs waves of tickets grouped by dependency
2. For each wave, team-lead dispatches one `Task()` per ticket in parallel
3. Team-lead awaits all Task() completions in a wave before starting the next wave
4. Results (status, pr_url, branch) are collected directly from Task() return values
5. No background workers, no TaskList polling loop, no SendMessage coordination

**Why not per-repo workers?** Workers spawned as team members via `TeamCreate` + `Task(team_name=...)`
go idle immediately (`idleReason: available`) and never process tasks from the task queue.
This is a confirmed behavior across multiple epic runs. The direct dispatch pattern is the
only execution model that reliably completes tickets.

### Circuit Breaker / Heartbeat Timeout

Every Task() dispatch has a configurable timeout to prevent stalled agents from blocking the
entire wave indefinitely. If an agent produces no output for `DISPATCH_TIMEOUT_MINUTES`
(default: 30, configurable via `EPIC_TEAM_DISPATCH_TIMEOUT_MINUTES` env var), the circuit
breaker trips:

1. The Task() dispatch returns a timeout error
2. The ticket is marked `AGENT_TIMEOUT` in state.yaml
3. A Kafka event `onex.evt.omniclaude.agent-circuit-breaker.v1` is emitted for observability
4. The wave continues with remaining tickets (timeout is non-blocking)

This prevents the first autopilot close-out failure mode where a release dispatch stalled
for 1+ hour with zero output.

## Failure Taxonomy and Recovery Strategies

| Failure Class | Symptoms | Recovery Strategy |
|---|---|---|
| `rate_limit` | Sub-agent exits with rate limit error | Wait 60s, retry the ticket via `ticket-pipeline` sequentially |
| `context_limit` | Sub-agent hits max context length mid-ticket | Spawn fresh sub-agent; previous work is in the branch |
| `ci_failure_uv` | CI fails with lock file or uv version error | Verify CI uv version; regenerate lock with matching version |
| `ci_failure_ruff` | CI fails with lint/format error | Run `uv run ruff check --fix` + `uv run ruff format`, recommit |
| `stale_branch` | PR fails to merge — "main has moved" | `git rebase origin/main`, re-push, re-enable auto-merge |
| `wrong_repo` | Ticket worked in wrong repo | Look up target repo in Linear ticket `repo` field; re-dispatch |
| `blocker_unresolved` | Ticket blocked by another in-progress ticket | Move to end of queue; complete blocking ticket first |
| `pr_template_blocked` | Mergeability gate rejected — missing PR body sections | Update PR body with required sections (Summary/Risk/Test Evidence/Rollback), rerun gate |
| `agent_timeout` | Sub-agent produces no output for >30 min | Circuit breaker trips; ticket marked AGENT_TIMEOUT; retry in next run |
| `unknown` | Failure doesn't match above patterns | Escalate to user: ticket ID, last command output, branch state |

## Failure Attempt Definition

A failure attempt is counted when `ticket-pipeline` returns non-zero exit OR the summary contains an explicit failure class. Partial completions (branch exists, some steps done) count as one attempt.

## Self-Healing Rules

- Attempt retry up to 2 times *within the same run only*. On interruption + re-run, counts reset.
- After 2 failed attempts: produce failure report and stop dispatching new tickets.
- Escalation = log the failure report and continue with remaining unblocked tickets.

## DoD Compliance Gate

After all ticket-pipeline waves complete and before marking the epic as Done:

1. Invoke `/dod-sweep {epic_id}` (targeted mode)
2. If `overall_status == FAIL`:
   - Do NOT mark the epic Done
   - Follow-up tickets are auto-created by dod_sweep
   - Post Slack notification: "Epic {epic_id} blocked by DoD gaps: {failed} failed, {exempted} exempted, {passed} passed"
   - Leave epic in current state for manual resolution
3. If `overall_status == PASS`:
   - Proceed to mark epic Done
   - Post Slack notification: "Epic {epic_id} DoD gate cleared: {passed} passed, {exempted} exempted, {failed} failed"
4. If `overall_status == UNKNOWN` (e.g., all tickets exempted):
   - Post Slack notification: "Epic {epic_id} DoD gate: no evidence-backed passes -- {exempted} exempted, review recommended"
   - Proceed to mark epic Done (non-blocking) but flag for review
   - NOTE: Allowing all-exempted epics to proceed under UNKNOWN is a rollout
     accommodation, not the intended long-term steady state. This allow-through
     must sunset after the cutoff exemption review trigger fires (4 weeks or
     warning volume <10%, whichever first).

Notifications must always include passed/failed/exempted counts separately --
never imply all tickets passed when exemptions are present.

## See Also

- `prompt.md` — full orchestration logic, state machine, and error handling reference
- `ticket-pipeline` skill — per-ticket pipeline invoked by workers
- `ticket-work` skill — implementation phase (autonomous mode)
- `local-review` skill — review + fix loop
- `ci-watch` skill (OMN-2523) — CI polling and auto-fix
- `pr-watch` skill (OMN-2524) — PR review polling and auto-fix
- `auto-merge` skill (OMN-2525) — merge gate
- `decompose-epic` skill (OMN-2522) — empty epic auto-decompose
- `slack-gate` skill (OMN-2521) — LOW/MEDIUM/HIGH_RISK gates
- `plugins/onex/skills/epic-team/repo_manifest.yaml` — repo keyword mapping
- Linear MCP tools (`mcp__linear-server__*`) — epic and ticket access
