# Epic Team

> Orchestrate a Claude Code agent team to autonomously work a Linear epic across multiple repos

- Skill: `omninode-ai/epic-team` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add omninode-ai/epic-team`
- Raw SKILL.md: https://api.skillmd.com/api/skills/omninode-ai/epic-team/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: OmniNode-ai (https://skillmd.com/u/omninode-ai)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/omninode-ai/epic-team

---


# Epic Team Orchestration

## Backing Status (<TICKET>) — NON-RUNNABLE via canonical event-bus dispatch

**This skill has no deterministic node backing and is de-listed from event-bus / node-routed dispatch — deliberately, by design.** Its omniclaude "node" (`node_skill_epic_team_orchestrator`) is a generated Polly-passthrough shell (`maturity: stub` in its contract.yaml). Its real execution mechanism, `plugins/onex/skills/epic_team/run.sh`, itself shells out to `claude -p` (headless Claude Code) — still an LLM session, not a canonical COMPUTE/EFFECT/ORCHESTRATOR/REDUCER node. Spawning live `Task()`/`TeamCreate()` subagents is inherently a Claude-session capability; there is no I/O boundary a backend node can call to create a new agent session.

This is intentional, not a gap to silently paper over: epic_team is, and should remain, session-native (Skill-tool / headless-launcher driven only). Future evaluation of a gated-EFFECT wrapper around the `run.sh` launch step is tracked in <TICKET>.

## Tools Required

This skill spawns workers via `Agent(team_name=...)`. Workers run in fresh sessions where
the `Agent` tool is **deferred** (schema not pre-loaded). Any worker that needs to dispatch
further sub-agents (e.g. a `ticket-pipeline` worker spawning a verifier) must call:

```
ToolSearch(query="select:Agent,SendMessage,TaskCreate,TaskUpdate,TaskGet", max_results=5)
```

**Inject this as the first line of every `Agent()` dispatch prompt in the wave loop.** The
worker must call it before any other tool use. Without it, recursive dispatch fails silently
with "Agent tool unavailable".

## Mode Declaration

**This skill operates in BUILD mode only.**

Valid `--mode` values: `build`

**If `--mode` is omitted or set to any value other than `build`**, emit the following usage
error and stop — do NOT proceed to dispatch:

```
ERROR: /epic-team requires --mode build

Usage: /epic-team <epic_id> --mode build [options]

Valid modes:
  build    Orchestrate autonomous implementation of a Linear epic across repos

This skill is BUILD mode only. For close-out or reporting tasks:
  Close-out: /merge-sweep --mode close-out
  Reporting: /linear-insights --mode deep-dive
```

**First output line** when mode is valid must be:
```
[epic-team] MODE: build | epic: <epic_id>
```

No tool calls, file reads, or bash commands may precede this output.

## Headless Mode (Overnight Pipelines)

Use `plugins/onex/skills/epic_team/run.sh` for overnight/unattended runs:

```bash
# Full headless run
plugins/onex/skills/epic_team/run.sh PROJ-1000 --mode build

# Dry run (no spawning)
plugins/onex/skills/epic_team/run.sh PROJ-1000 --mode build --dry-run

# Resume after context limit or crash
plugins/onex/skills/epic_team/run.sh PROJ-1000 --mode build --resume
```

**Minimum tool allowlist for headless epic-team:**
```
tracker.get_issue, tracker.list_issues,
tracker.save_issue, tracker.save_comment,
tracker.list_projects, tracker.get_project,
tracker.list_teams, Bash, Read, Write, Edit, Glob, Grep,
TeamCreate, TeamDelete, Agent, TaskCreate, TaskUpdate, TaskGet, TaskList, SendMessage
```

**Failure doctrine in headless mode:**
- **Missing epic_id**: exit 1, structured JSON error — no partial run ever started
- **Missing credentials** (`LINEAR_API_KEY`): exit 2 immediately, no retries
- **Ambiguity** (cannot determine epic state, conflicting lock files): write
  `$ONEX_STATE_DIR/epics/<id>/ambiguity_<ts>.json`, exit 3 — never guess
- **Blocked tool** (not in allowlist): log denial, exit 4 — never silently substitute
- **Context limit**: checkpoint to `$ONEX_STATE_DIR/epics/<id>/state.yaml` before stopping;
  re-run with `--resume` to continue from last completed wave

**Checkpointable state:**
All in-progress state is written to `$ONEX_STATE_DIR/epics/<epic_id>/state.yaml` before
each wave and updated after each ticket completion. A coordinator killed at any point can be
resumed by re-running with `--resume` against the same `state.yaml`.

## Dispatch Surface: Agent Teams

epic-team uses Claude Code Agent Teams for all worker dispatch. The team lead (this session)
creates a named team, generates task contracts, dispatches workers, monitors for stalls, and
shuts down the team on completion.

### Non-Blocking Foreground Contract

`Agent(...)` dispatches return immediately — workers run as independent background sessions.
The team lead must never `await` or block the foreground on any single worker. The wave
monitoring loop (see "Stall Detection in Wave Dispatch" below) polls `TaskGet` status and
only advances to the next wave when the current wave has reported terminal status or a stall
has been handled. This is the canonical dispatch pattern referenced by Workstream D of the
2026-04-02 Insights Plan: read epic tickets from Linear → group by repo → `TeamCreate` →
one `Agent(team_name=...)` per ticket → poll status non-blocking. No separate
`/dispatch-epic` skill is needed; that workflow is exactly this skill's dispatch phase.

Stall detection integrates `agent_healthcheck` and `dispatch_watchdog`: two minutes of zero
tool calls from a worker is a stall signal, and stalled workers are killed and redispatched
with narrower scope per the watchdog's escalation policy.

### Lifecycle

```
1. TeamCreate(team_name="epic-{epic_id}")
2. For each ticket in wave:
   a. Generate a ModelTaskContract from the ticket's Linear DoD and inject into the worker prompt
   b. TaskCreate(subject="{ticket_id}: {title}", description=ticket_requirements)
   c. Agent(name="worker-{ticket_id}", team_name="epic-{epic_id}",
            prompt="Execute ticket-pipeline for {ticket_id}. Task contract: {contract_json}")
3. Workers execute in background, report progress via SendMessage(to="team-lead")
4. Team lead monitors progress, handles stalls (5-min inactivity timeout)
5. On each worker completion: verification chain runs (see below)
6. TeamDelete(team_name="epic-{epic_id}") after all tasks complete or are terminal
```

### Task Contract Generation

Before dispatching each worker, the team lead generates a task contract:

1. Fetch ticket DoD from Linear (description, acceptance criteria)
2. Build `ModelTaskContract` with mechanical checks (test pass, lint clean, type-check clean)
3. Inject the serialized contract into the worker's dispatch prompt
4. The contract travels with the worker — it is the single source of truth for what "done" means

### Verification Chain (Part 2 Reference)

Each worker task must produce evidence through the full Part 2 verification chain:

1. **Task contract** — `ModelTaskContract` persisted before dispatch
2. **Self-check** — worker runs `SelfCheckResult` against its own contract checks
3. **Verifier** — independent verifier agent validates self-check evidence
4. **Quorum gate** — `ModelQuorumResult` evaluates self-check + verifier verdicts
5. **Completion event** — `ModelTaskCompletedEvent` emitted with evidence to unified event stream

A worker is not considered complete until all five artifacts are present. Missing evidence
triggers a re-verification pass before the task is marked done.

### Stall Detection

After dispatching workers in a wave, the team lead monitors for stalls:

- **Inactivity timeout**: 5 minutes with no tool calls from a worker → stall detected
- **On stall**: Log friction event to `.onex_state/friction/`, cancel stalled worker, mark task
  for retry in next wave
- **Retry policy**: Each ticket gets 1 retry. If stalled twice → mark `blocked`, add Linear
  comment, skip
- **Wave timeout**: 30 minutes total per wave. Remaining in-progress tasks are timed out and
  retried once

### Failure on Dispatch

If Agent Teams dispatch fails (TeamCreate error, Agent tool unavailable, auth error):
**STOP immediately.** Report the exact error to the user and wait for direction. Do NOT fall
back to direct Bash, Read, Edit, Write, or Glob calls — falling back bypasses observability,
context management, and the orchestration layer.

> **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 (v3.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. Workers are Agent Teams members, not poly-agent subagents.

## 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 workers via Agent Teams (`TeamCreate` +
`Agent(team_name=...)`) in dependency-respecting waves.

**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 Agent() workers in the current wave have reported back.
Never dispatch more than 5 tickets in a single wave, even if more are independent.

---

## Sequential PR Chaining

When creating PRs for sequential tasks that modify the same files:
1. PR N+1 should target PR N's branch (not main) if they share modified files
2. Maximum chain depth: 3 PRs
3. If chain would exceed 3, wait for earlier PRs to merge before creating more

**Detection**: Before creating a PR, check if another open PR from the same epic modifies any of the same files:
```bash
# Get files in current change
CHANGED_FILES=$(git diff --name-only main...)

# Check open PRs from same epic for overlapping files
for PR_NUM in $(gh pr list --label "epic:<EPIC-ID>" --json number -q '.[].number'); do
  PR_FILES=$(gh pr view $PR_NUM --json files -q '.files[].path')
  OVERLAP=$(comm -12 <(echo "$CHANGED_FILES" | sort) <(echo "$PR_FILES" | sort))
  if [[ -n "$OVERLAP" ]]; then
    echo "Overlapping files with PR #$PR_NUM: $OVERLAP"
    echo "Target PR #$PR_NUM's branch instead of main"
  fi
done
```

---

## Composable Sub-Skills

epic-team orchestrates these independently-invocable primitives:

| Sub-Skill | Purpose |
|-----------|---------|
| `decompose-epic` | Analyze epic → create Linear child tickets |
| `slack-gate` | LOW_RISK notification gate (one-way, silence=proceed) |
| `ticket-pipeline` | Per-ticket pipeline (implement → review → PR → CI → merge) |
| `verification-sweep` | Post-orchestration endpoint/DB/DoD verification |

`ticket-pipeline` in turn composes:

| Sub-Skill | Purpose |
|-----------|---------|
| `ticket-work` | Implement ticket (autonomous mode) |
| `local-review` | Review + fix loop |
| `ci-watch` | Poll CI, auto-fix failures |
| `pr-watch` | Poll PR reviews, auto-fix comments |
| `auto-merge` | Merge PR automatically after CI is clean |

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 PROJ-1000 --dry-run

# Full run
/epic-team PROJ-1000

# Resume after session disconnect
/epic-team PROJ-1000 --resume

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

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

# Route unmatched tickets to omniplan triage
/epic-team PROJ-1000 --force-unmatched
```

## Orchestration Flow

```
epic-team <EPIC-ID>
  → 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 Agent() workers in parallel
  → Start stall-detection watchdog for the wave
  → Await all Agent() workers in wave to report via SendMessage before starting next wave
  → If watchdog detects stall: cancel stalled worker, log to state, retry in next wave
  → Collect results (status, pr_url, branch) from SendMessage reports
  → Post-wave integration check: 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)
  → Post-orchestration verification sweep: invoke /verification-sweep --epic {epic_id}
      → Verify dashboard endpoints return HTTP 200 with real data
      → Verify database tables contain expected rows
      → Verify dod_evidence rendered_output items have passing receipts
      → Write receipts to .onex_state/verification-receipts/ (non-blocking — always advances)
  → DoD compliance gate: 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:

```
Agent(
  name="decompose-{epic_id}",
  team_name="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 via SendMessage: created_tickets (list of IDs and titles), count."
)
```

## Pre-Dispatch Verification Preamble

**Before dispatching any ticket in a wave**, the team lead performs a lightweight verification
pass to catch misaligned tickets early -- before they consume a full pipeline run.

### Worker Model Enforcement

All workers dispatched by epic-team MUST use **Sonnet** (not Opus). Do NOT dispatch workers with
`model: opus` or leave the model unspecified in environments where Opus is the default.

Add to every `Agent()` dispatch prompt:

```
MODEL: claude-sonnet-4-6
```

Rationale: Opus is reserved for orchestrator-level reasoning. Worker cost scales with the number
of tickets; Sonnet is sufficient for implementation tasks.

---

### ONEX Pattern Gate (pre-dispatch static review)

> **DISABLED:** hostile_reviewer is currently disabled pending eval framework validation. Skip this gate entirely. Do NOT invoke. Proceed to Verification Steps directly.

**Before dispatching any ticket in a wave**, run `hostile_reviewer --static` on each task
description to catch ONEX anti-patterns before they consume a full pipeline run.

**Invoke:**
```
Skill(skill="onex:hostile_reviewer", args="--static --text \"<task_description>\"")
```

**Block dispatch if any of the following patterns are detected:**

| Anti-pattern | Example | Action |
|---|---|---|
| Docker service design in a ticket | "Create a new Docker service for..." | BLOCK |
| String field types (untyped primitives) | `name: str`, `id: str` instead of typed models | BLOCK |
| Hardcoded topic strings | `"onex.evt.foo.v1"` literal in task description | BLOCK |

**On block**: Mark the ticket as `pattern_gate_failed` in `state.yaml` with the violation detail.
Add a Linear comment: `"[epic-team] Pattern gate blocked dispatch: {violation}"`. Do NOT dispatch.
Escalate to user if more than 2 tickets in a wave are blocked by the pattern gate.

**On pass**: Record `pattern_gate: passed` in the ticket's contract before Agent() dispatch.

---

### Verification Steps (per ticket, before Agent() dispatch)

1. **ONEX pattern gate**: ~~Run `hostile_reviewer --static` on task description~~ **DISABLED** — skip this step, proceed to step 2.

2. **Ticket readiness check**: Fetch ticket via `tracker.get_issue` and verify:
   - Description is non-empty and contains actionable content
   - Repo target is identifiable (from title, description, or labels)
   - No blocking dependencies are in non-Done state

3. **Approach sanity check**: For each ticket, state in the dispatch prompt:
   - What the ticket requires (1-sentence summary)
   - Which repo and approximate files will be modified
   - Any known constraints or patterns to follow

5. **Skip unready tickets**: If a ticket fails verification:
   - Mark as `skipped` in `state.yaml` with reason: `"verification_failed: {detail}"`
   - Add a Linear comment: "Skipped by epic-team: {reason}"
   - Do NOT dispatch -- move to next ticket in wave
   - Unready tickets are re-evaluated in the next wave (dependency may have resolved)

### Verification in the Dispatch Prompt

The verification context is injected directly into each Agent() dispatch prompt so the
ticket-pipeline agent starts with pre-validated understanding rather than re-deriving it.

## Dispatch: Ticket-Pipeline per Ticket (Agent Teams Pattern)

For each ticket in a wave, generate a task contract and dispatch a worker via Agent Teams:

```
# 1. Generate task contract from Linear DoD
contract = generate_task_contract(ticket_id, ticket_description, ticket_acceptance_criteria)
# Persist contract to $ONEX_STATE_DIR/epics/{epic_id}/contracts/{ticket_id}.json

# 2. Create a task for tracking
TaskCreate(subject="{ticket_id}: {title}", description=ticket_requirements)

# 3. Dispatch worker as Agent Teams member
Agent(
  name="worker-{ticket_id}",
  team_name="epic-{epic_id}",
  prompt="FIRST ACTION — fetch deferred dispatch tool schemas:
    ToolSearch(query=\"select:Agent,SendMessage,TaskCreate,TaskUpdate,TaskGet\", max_results=5)

    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}

    TASK CONTRACT (source of truth for done):
    {contract_json}

    VERIFIED CONTEXT (from epic-team pre-dispatch check):
    - Summary: {verified_summary}
    - Target files: {verified_file_targets}
    - Pattern to follow: {verified_pattern_reference}
    - Dependencies met: {dependency_status}

    VERIFICATION CHAIN — you must produce ALL of these before reporting done:
    1. Run self-check against the task contract (SelfCheckResult)
    2. Evidence is verified by an independent verifier agent
    3. Quorum gate evaluates self-check + verifier verdicts (ModelQuorumResult)
    4. Emit ModelTaskCompletedEvent with all evidence

    Invoke: Skill(skill=\"onex:ticket_pipeline\", args=\"{ticket_id}\")

    After ticket-pipeline completes, report back via SendMessage(to=\"team-lead\"):
    - ticket_id: {ticket_id}
    - status: (merged/failed/blocked)
    - pr_url: (if available)
    - branch: (branch name used)
    - verification_verdict: (PASS/FAIL/INCOMPLETE)"
)
```

**Wave parallelism**: Agent Teams workers run as independent concurrent Claude sessions.
Dispatch all Agent() calls for a wave before monitoring. Do NOT dispatch tickets sequentially
within a wave.

**Wave serialization**: Wave N+1 starts only after all workers from Wave N have reported back
via SendMessage or been timed out by stall detection.

## Stall Detection in Wave Dispatch

After dispatching all Agent() calls in a wave, the team lead monitors for stalled agents
using the `dispatch_watchdog` skill. This prevents a single stalled agent from blocking
the entire wave indefinitely.

### Monitoring Loop

```
# After dispatching wave N tasks:
wave_start_time = now()
stall_timeout = 300  # 5 minutes with no tool calls

while any_task_still_running(wave_tasks):
    # Check each running task
    for task in wave_tasks:
        if task.status == "in_progress":
            elapsed_since_last_activity = now() - task.last_tool_call_time

            if elapsed_since_last_activity > stall_timeout:
                # Stall detected
                log("[epic-team] STALL: {task.ticket_id} idle for {elapsed}s")
                write_stall_event(epic_id, task.ticket_id, elapsed)

                # Recovery: mark as failed, retry in next wave
                task.status = "stalled"
                task.retry_in_next_wave = true

    # Check wave-level timeout (30 minutes total)
    if now() - wave_start_time > 1800:
        log("[epic-team] WAVE TIMEOUT: cancelling remaining tasks")
        for task in wave_tasks:
            if task.status == "in_progress":
                task.status = "timeout"
                task.retry_in_next_wave = true
        break

    sleep(60)  # Check every minute
```

### Stall State in state.yaml

```yaml
waves:
  - wave_id: 0
    dispatched_at: "2026-03-29T10:00:00Z"
    completed_at: "2026-03-29T10:15:00Z"
    tasks:
      - ticket_id: PROJ-1001
        status: merged
        pr_url: "https://github.com/..."
      - ticket_id: PROJ-1002
        status: stalled
        stall_detected_at: "2026-03-29T10:08:00Z"
        idle_seconds: 480
        retry_wave: 1
```

### Retry Policy

- Stalled tasks are retried **once** in the next wave
- If a task stalls twice: mark as `blocked`, add Linear comment, skip
- Timeout tasks (wave-level) are always retried once
- Maximum retry count per ticket: 1 (prevents infinite retry loops)

### Integration with dispatch_watchdog Skill

The monitoring logic above is the inline version for the team-lead session.
For more sophisticated monitoring (e.g., headless overnight runs), invoke
the `dispatch_watchdog` skill as a composable sub-skill:

```
Skill(skill="onex:dispatch_watchdog", args="--epic-id {epic_id} --timeout 300 --action report")
```

## 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: PROJ-XXXX
run_id: f084b6c3
status: monitoring  # queued | monitoring | done | failed
checkpoint:
  schema_version: 1
  last_completed_wave: 0
  waves:
    - wave_id: 0
      tickets: [PROJ-1001, PROJ-1002]
      status: completed  # pending | in_progress | completed | failed
      completed_at: "2026-03-06T..."
    - wave_id: 1
      tickets: [PROJ-1003]
      status: pending
  open_prs:
    PROJ-1001: {pr_number: 45, repo: "omniclaude", branch: "<user>/omn-2001-..."}
  failures:
    PROJ-1003: {class: "ci_failure_ruff", attempts: 1, last_error: "..."}
  last_update_utc: "2026-03-06T..."
workers:
  - repo: omniclaude
    tickets: [PROJ-1001, PROJ-1002]
    status: running  # running | done | failed
ticket_status:
  PROJ-1001: merged
  PROJ-1002: 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 PROJ-XXXX

Epic had no child tickets. Created N sub-tickets:
  - PROJ-YYYY: [title]
  - PROJ-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.

## Stacked Branch Execution

When Wave N+1 tickets depend on Wave N (via cross-repo Part 2 splits or file-overlap
chains), the downstream ticket branches from the Wave N branch tip instead of `main`.
This avoids merge conflicts and ensures later tickets see earlier changes without
waiting for merge.

**How it works:**

1. After Wave N completes, collect `{ticket_id: branch_name}` from worker results
2. When dispatching Wave N+1, check if any ticket has a dependency on a Wave N ticket
   (via `chain_targets` from `detect_file_overlap_chains` or cross-repo Part 2 mapping)
3. If a dependency exists and the upstream ticket succeeded, pass `--base-branch <branch>`
   to the downstream ticket's `ticket-pipeline` invocation
4. `ticket-pipeline` creates its worktree from the specified base branch and opens its
   PR targeting that base branch

**Fallback:** If the upstream ticket failed or returned no branch, fall back to `main`.

**Chain depth limit:** Maximum 3 levels of stacking. Beyond that, tickets target `main`
independently to avoid deep rebase chains.

**State tracking:** The `ticket_results` dict in state.yaml records `branch` per ticket,
making stacked branch resolution available across waves and on `--resume`.

---

## 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)
- Agent Teams dispatch of ticket-pipeline per ticket (TeamCreate + Agent with team_name)
- 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

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

1. Team-lead creates the team via `TeamCreate(team_name="epic-{epic_id}")`
2. Team-lead constructs waves of tickets grouped by dependency
3. For each wave, team-lead dispatches `Agent()` workers in parallel with `team_name` parameter
4. Workers execute in background and report completion via `SendMessage(to="team-lead")`
5. Team-lead monitors SendMessage reports and stall detection before starting the next wave
6. After all waves complete, team-lead cleans up via `TeamDelete()`

### Circuit Breaker / Heartbeat Timeout

Every Agent() 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 Agent() 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.

### Agent Health-Check Integration

The `agent_healthcheck` skill provides more sophisticated stall detection beyond the simple
timeout circuit breaker. During wave monitoring, epic-team checks agent health using three
heuristics:

1. **Inactivity**: No tool calls for 10 minutes (configurable)
2. **Context overflow**: Context window usage > 80% (preemptive recovery before hard limit)
3. **Rate limits**: Agent encounters rate-limit errors

On stall detection, the health-check module:
- Snapshots progress to a checkpoint file (using the checkpoint protocol from an earlier pass)
- Summarizes completed vs remaining work
- Relaunches a fresh agent with the summary and remaining tasks only

See `@skills/agent_healthcheck/SKILL.md` for the full detection and recovery protocol.

## 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 — CI polling and auto-fix
- `pr-watch` skill — PR review polling and auto-fix
- `auto-merge` skill — merge gate
- `decompose-epic` skill — empty epic auto-decompose
- `slack-gate` skill — LOW_RISK notification gate
- `verification-sweep` skill — post-orchestration endpoint/DB/DoD verification
- `plugins/onex/skills/epic-team/repo_manifest.yaml` — repo keyword mapping
- Linear MCP tools (`tracker.*`) — epic and ticket access

## Container / Degraded Environment

When running without the full omniclaude plugin (e.g., container-based Claude Code sessions):

- **`general-purpose`** is used as a safety-net fallback when the preferred skill is
  unavailable or returns an error. ONEX intelligence integration, action logging, and
  observability will be inactive. Skill instructions still execute correctly — only
  metadata and telemetry are affected.
- **Cross-skill dispatch** (`Skill(skill="onex:...")`) requires the plugin's skill registry.
  If skills are not registered, dispatch calls will fail. Verify: check if the skill
  appears in the system-reminder skills list.
- **Hook enforcement** (poly_enforcer, authorization_shim, bash_guard) will not be active.
- **Linear MCP** may not be available. See `--local` mode (if applicable) for offline
  ticket management.

