# Decompose Epic

You are executing the decompose-epic skill. This prompt is the authoritative operational guide. Follow it exactly.

## Argument Parsing

```
/decompose-epic {epic_id} [--repos repo1,repo2,...] [--dry-run]
```

```python
args = "$ARGUMENTS".split()
if len(args) == 0:
    print("Error: epic_id is required. Usage: /decompose-epic OMN-1234")
    exit(1)
epic_id = args[0]

import re
if not re.match(r'^[A-Z]+-\d+$', epic_id):
    print(f"Error: Invalid epic_id format '{epic_id}'. Expected pattern like 'OMN-1234'.")
    exit(1)

dry_run = "--dry-run" in args

# Parse --repos
repos_arg = None
if "--repos" in args:
    idx = args.index("--repos")
    if idx + 1 >= len(args):
        print("Error: --repos requires a value (e.g., --repos omniclaude,omnibase_core)")
        exit(1)
    repos_arg = [r.strip() for r in args[idx + 1].split(",") if r.strip()]
    if not repos_arg:
        print("Error: --repos value is empty.")
        exit(1)

# Mode detection
mode = "B" if repos_arg is not None else "A"

RESULT_DIR  = f"$ONEX_STATE_DIR/skill-results/{epic_id}"
RESULT_FILE = f"{RESULT_DIR}/decompose-epic.json"
```

---

## Mode Detection

| Condition | Mode | Description |
|-----------|------|-------------|
| `--repos` absent | **A** | Infer repo breakdown from epic description via Claude reasoning |
| `--repos` present | **B** | Repos are pre-determined; create one sub-ticket per listed repo |

---

## Mode A Flow — Infer repos from epic description

### Fetch epic from Linear

```python
epic = tracker.get_issue(id=epic_id)
if epic is None:
    print(f"decompose-epic: epic {epic_id} not found in Linear")
    exit(1)
```

### Validate description is non-empty

```python
if not epic.description or not epic.description.strip():
    print(f"decompose-epic: epic {epic_id} has no description — add context before decomposing")
    exit(1)
```

### Load repo manifest

```python
import os, yaml
from pathlib import Path

HOME_MANIFEST = Path("$ONEX_STATE_DIR/epic-team/repo_manifest.yaml").expanduser()
REPO_MANIFEST  = Path("plugins/onex/skills/epic-team/repo_manifest.yaml")

if HOME_MANIFEST.exists():
    manifest_path = HOME_MANIFEST
elif REPO_MANIFEST.exists():
    manifest_path = REPO_MANIFEST
else:
    print(f"decompose-epic: repo_manifest.yaml not found at {HOME_MANIFEST} or {REPO_MANIFEST}")
    exit(1)

with open(manifest_path) as f:
    manifest = yaml.safe_load(f)
```

### Analyze epic description and identify work areas

Use Claude reasoning (not a script) to read the epic title and description, then identify N distinct work areas. For each work area:

1. Summarize the concern in one sentence
2. Identify which repo from the manifest best owns this work area based on the manifest entry descriptions
3. Draft a scoped sub-ticket title: `[{repo}] {scoped description}`

**Reasoning heuristic**: Match work areas to repos by:
- Which repo's files/modules the work touches
- Which team in the manifest is responsible for that domain
- Whether the work is foundational (omnibase_*) or application-layer (omniclaude, omniplan, etc.)

If a work area cannot be matched to any repo with reasonable confidence, flag it as unmatched with a reason.

### Step 5a: Dry-run output

If `--dry-run` is set, print the plan as a formatted table and exit 0. Do NOT write a ModelSkillResult file.

```
decompose-epic: DRY RUN for {epic_id} — "{epic.title}"
Mode A: inferred from description

Decomposition plan:
  Work Area                              Repo              Proposed Title
  ─────────────────────────────────────────────────────────────────────────
  Hook emit_client refactor              omniclaude        [omniclaude] Refactor emit_client for DLQ support
  Contract schema extension              omnibase_core     [omnibase_core] Extend ModelContract with retry field
  ...

  Unmatched (if any):
  Work Area                              Reason
  ─────────────────────────────────────────────────────────────────────────
  Cross-cutting infra concern            No clear repo owner in manifest

DRY RUN COMPLETE — no tickets created.
```

Exit 0.

### Step 5b: Live execution — create sub-tickets

For each work area matched to a repo:

```python
created = []
failed  = []

for work_area in matched_work_areas:
    repo    = work_area["repo"]
    entry   = manifest[repo]  # has team_id, label_name, description
    title   = f"[{repo}] {work_area['scoped_description']}"

    # Build description
    description = f"""## Parent Epic

{epic_id}: {epic.title}
{epic.url}

## Scope

This sub-ticket covers the **{repo}** portion of the parent epic.

{work_area['summary']}

## Acceptance Criteria

{work_area['acceptance_criteria']}

## Context

Generated by decompose-epic (Mode A) from epic description analysis.
"""

    # Resolve repo label
    labels = []
    try:
        all_labels = tracker.list_issue_labels(team=entry["team_id"])
        label_match = next((l for l in all_labels if l.name == entry.get("label_name")), None)
        if label_match:
            labels = [label_match.id]
    except Exception:
        pass  # Labels are best-effort

    # Create ticket
    try:
        result = tracker.create_issue(
            title=title,
            team=entry["team_id"],
            description=description,
            parentId=epic_id,
            labels=labels if labels else None,
        )
        ticket_id = result.identifier

        # MANDATORY: generate and embed ModelTicketContract for every sub-ticket
        # Seam detection: scan title + summary for interface signals
        scan_text = (title + ' ' + work_area.get('summary', '')).lower()
        seam_signals = {
            'kafka': 'topics', 'topic': 'topics', 'consumer': 'topics', 'producer': 'topics',
            'schema': 'events', 'payload': 'events', 'event model': 'events', 'modelhook': 'events',
            'spi': 'protocols', 'protocol': 'protocols',
            'envelope': 'envelopes',
            'endpoint': 'public_api', 'route': 'public_api', ' api': 'public_api', 'rest': 'public_api',
        }
        inferred_ifaces = list(dict.fromkeys(v for k, v in seam_signals.items() if k in scan_text))
        is_seam = 'true' if inferred_ifaces else 'false'
        ifaces_yaml = '\n'.join(f'  - "{s}"' for s in inferred_ifaces) if inferred_ifaces else '  []'
        completeness = 'full' if inferred_ifaces else 'stub'

        contract_yaml = f"""schema_version: "1.0.0"
ticket_id: "{ticket_id}"
summary: "{title}"
is_seam_ticket: {is_seam}
interface_change: {is_seam}
interfaces_touched:
{ifaces_yaml}
contract_completeness: {completeness}
evidence_requirements:
  - kind: tests
    description: "Unit tests pass"
    command: "uv run pytest tests/ -m unit -x"
  - kind: ci
    description: "CI pipeline green"
    command: "gh pr checks"
emergency_bypass:
  enabled: false
  justification: ""
  follow_up_ticket_id: ""\
"""
        updated_desc = description.rstrip() + f"\n\n---\n\n## Contract\n\n```yaml\n# ModelTicketContract\n{contract_yaml}\n```\n"
        tracker.save_issue(id=result.id, description=updated_desc)
        print(f"  Contract embedded: {ticket_id} (completeness={completeness})")

        created.append({"id": ticket_id, "title": title, "repo": repo})
        print(f"  Created: {ticket_id} — {title}")
    except Exception as e:
        failed.append({"repo": repo, "title": title, "error": str(e)})
        print(f"  FAILED:  {title} — {e}")
```

> **Plan-file check prohibition:** When generating `dod_evidence` items from ticket descriptions,
> never emit a check whose `check_value` contains `test -f docs/plans/` or any path under
> `docs/plans/`. The `onex_change_control` repo's `no-planning-docs` pre-commit hook forbids
> planning docs from existing there — such a check is structurally unsatisfiable and will break
> Contract Compliance Check CI on every contract that contains it. All other ticket-specific
> acceptance criteria extracted by dod_parser are retained normally.
>
> **Fail-open check_value prohibition (OMN-9350):** Never emit a `check_value` containing
> fail-open shell patterns. The `lint-contract-check-values` pre-commit hook in
> `onex_change_control` will block the commit. Forbidden patterns:
>
> - `[ -z "$<var>" ] ||` — empty-permissive short-circuit; TRUE when the variable is absent
> - `|| true` — always-true tail
> - `|| exit 0` — explicit pass-on-error
> - `2>/dev/null` at the end of the fragment (silenced errors without explicit exit check)
>
> **Correct fail-closed form for CI status checks:**
>
> ```bash
> result=$(gh pr view {pr} --repo {repo} --json statusCheckRollup \
>   -q '[.statusCheckRollup[] | select(.name == "<check-name>") | .conclusion] | first // empty')
> [ "$result" = "SUCCESS" ]
> ```
>
> Empty result → expression FALSE → DoD correctly fails. Do NOT add `[ -z "$result" ] ||` before it.

### Write ModelSkillResult

```python
import json
from pathlib import Path

status = "completed" if not failed else ("partial" if created else "failed")
if status == "failed" and not created:
    print(f"decompose-epic: analysis produced 0 sub-tickets — check epic description")
    exit(1)

result_payload = {
    "status": status,
    "created_tickets": created,
    "repos_affected": [c["repo"] for c in created],
    "epic_id": epic_id,
    "count": len(created),
    "dry_run": False,
}
if failed:
    result_payload["failed"] = failed

Path(RESULT_DIR).expanduser().mkdir(parents=True, exist_ok=True)
with open(Path(RESULT_FILE).expanduser(), "w") as f:
    json.dump(result_payload, f, indent=2)

print(f"\ndecompose-epic: {len(created)} ticket(s) created for {epic_id}.")
if failed:
    print(f"WARNING: {len(failed)} ticket(s) failed to create:")
    for fail in failed:
        print(f"  [{fail['repo']}] {fail['title']}: {fail['error']}")
print(f"Result written to: {RESULT_FILE}")
```

If `status == "failed"` with 0 created tickets: print error and exit 1. Do NOT write a result file.

---

## Mode B Flow — Repos pre-determined via `--repos`

### Fetch epic (or ticket) from Linear

```python
epic = tracker.get_issue(id=epic_id)
if epic is None:
    print(f"decompose-epic: epic {epic_id} not found in Linear")
    exit(1)
```

The description may be empty in Mode B — that is acceptable. The repos are pre-determined.

### Load repo manifest

Same as Mode A Step 3. Required to resolve team IDs and labels for the listed repos.

Validate that each repo in `repos_arg` appears in the manifest:

```python
unknown = [r for r in repos_arg if r not in manifest]
if unknown:
    print(f"decompose-epic: unknown repos not in manifest: {', '.join(unknown)}")
    print(f"Known repos: {', '.join(manifest.keys())}")
    exit(1)
```

### Step 3a: Dry-run output

If `--dry-run` is set, print the plan and exit 0. Do NOT write a ModelSkillResult file.

```
decompose-epic: DRY RUN for {epic_id} — "{epic.title}"
Mode B: repos pre-determined

Decomposition plan:
  Repo              Proposed Title
  ──────────────────────────────────────────────────────────────────────────
  omniclaude        [omniclaude] {epic.title}
  omnibase_core     [omnibase_core] {epic.title}
  ...

DRY RUN COMPLETE — no tickets created.
```

Exit 0.

### Step 3b: Live execution — create one sub-ticket per repo

For each repo in `repos_arg`, create one sub-ticket scoped to that repo's concerns:

```python
created = []
failed  = []

for repo in repos_arg:
    entry = manifest[repo]
    title = f"[{repo}] {epic.title}"

    description = f"""## Parent Ticket / Epic

{epic_id}: {epic.title}
{epic.url}

## Scope

This sub-ticket covers the **{repo}** portion of the work described in the parent ticket/epic.

Cross-repo split context: this work was identified as requiring changes in multiple repositories.
The other affected repos have their own parallel sub-tickets under the same parent.

## Acceptance Criteria

Implement the changes required by the parent ticket as they apply to the **{repo}** repository.
Refer to the parent ticket description for full context.

## Context

Generated by decompose-epic (Mode B) from explicit --repos argument.
Repos in this split: {', '.join(repos_arg)}
"""

    # Resolve repo label
    labels = []
    try:
        all_labels = tracker.list_issue_labels(team=entry["team_id"])
        label_match = next((l for l in all_labels if l.name == entry.get("label_name")), None)
        if label_match:
            labels = [label_match.id]
    except Exception:
        pass  # Labels are best-effort

    # Create ticket
    try:
        result = tracker.create_issue(
            title=title,
            team=entry["team_id"],
            description=description,
            parentId=epic_id,
            labels=labels if labels else None,
        )
        ticket_id = result.identifier

        # MANDATORY: generate and embed ModelTicketContract for every sub-ticket
        scan_text = title.lower()
        seam_signals = {
            'kafka': 'topics', 'topic': 'topics', 'consumer': 'topics', 'producer': 'topics',
            'schema': 'events', 'payload': 'events', 'event model': 'events', 'modelhook': 'events',
            'spi': 'protocols', 'protocol': 'protocols',
            'envelope': 'envelopes',
            'endpoint': 'public_api', 'route': 'public_api', ' api': 'public_api', 'rest': 'public_api',
        }
        inferred_ifaces = list(dict.fromkeys(v for k, v in seam_signals.items() if k in scan_text))
        is_seam = 'true' if inferred_ifaces else 'false'
        ifaces_yaml = '\n'.join(f'  - "{s}"' for s in inferred_ifaces) if inferred_ifaces else '  []'
        completeness = 'full' if inferred_ifaces else 'stub'

        contract_yaml = f"""schema_version: "1.0.0"
ticket_id: "{ticket_id}"
summary: "{title}"
is_seam_ticket: {is_seam}
interface_change: {is_seam}
interfaces_touched:
{ifaces_yaml}
contract_completeness: {completeness}
evidence_requirements:
  - kind: tests
    description: "Unit tests pass"
    command: "uv run pytest tests/ -m unit -x"
  - kind: ci
    description: "CI pipeline green"
    command: "gh pr checks"
emergency_bypass:
  enabled: false
  justification: ""
  follow_up_ticket_id: ""\
"""
        updated_desc = description.rstrip() + f"\n\n---\n\n## Contract\n\n```yaml\n# ModelTicketContract\n{contract_yaml}\n```\n"
        tracker.save_issue(id=result.id, description=updated_desc)
        print(f"  Contract embedded: {ticket_id} (completeness={completeness})")

        created.append({"id": ticket_id, "title": title, "repo": repo})
        print(f"  Created: {ticket_id} — {title}")
    except Exception as e:
        failed.append({"repo": repo, "title": title, "error": str(e)})
        print(f"  FAILED:  {title} — {e}")
```

> **Plan-file check prohibition:** Same rule as Mode A — never emit `dod_evidence` checks
> containing `test -f docs/plans/`. See Mode A note above for rationale.
>
> **Fail-open check_value prohibition (OMN-9350):** Same rule as Mode A — never emit
> fail-open patterns (`[ -z "$<var>" ] ||`, `|| true`, `|| exit 0`, trailing `2>/dev/null`).
> See Mode A note above for the correct fail-closed form.

### Write ModelSkillResult

Same as Mode A Step 6.

---

## ModelSkillResult Path

**Exact path**: `$ONEX_STATE_DIR/skill-results/{epic_id}/decompose-epic.json`

Where `{epic_id}` is the positional argument passed to the skill (e.g., `OMN-2522`).

The `context_id` used as the directory name equals `epic_id` exactly. Create the directory if it does not exist (`mkdir -p`).

**Not written** when `--dry-run` is active — dry-run exits 0 with console output only.

---

## Error Reference

| Condition | Exit code | Message |
|-----------|-----------|---------|
| epic_id missing | 1 | `Error: epic_id is required. Usage: /decompose-epic OMN-1234` |
| epic_id invalid format | 1 | `Error: Invalid epic_id format '...'. Expected pattern like 'OMN-1234'.` |
| --repos value empty | 1 | `Error: --repos value is empty.` |
| Epic not found in Linear | 1 | `decompose-epic: epic {epic_id} not found in Linear` |
| Epic description empty (Mode A) | 1 | `decompose-epic: epic {epic_id} has no description — add context before decomposing` |
| Unknown repo in --repos | 1 | `decompose-epic: unknown repos not in manifest: {repos}` |
| repo_manifest.yaml not found | 1 | `decompose-epic: repo_manifest.yaml not found at {home_path} or {repo_path}` |
| Zero tickets generated | 1 | `decompose-epic: analysis produced 0 sub-tickets — check epic description` |
| Partial create failure | 0 | Warn + report partial success in ModelSkillResult (`status: "partial"`) |

---

## Caller Integration

**Callers read the ModelSkillResult** from `$ONEX_STATE_DIR/skill-results/{epic_id}/decompose-epic.json` after the skill completes:

```python
# In ticket-pipeline (cross-repo split handler):
result_path = f"$ONEX_STATE_DIR/skill-results/{parent_epic_id}/decompose-epic.json"
result = json.load(open(Path(result_path).expanduser()))
created_tickets = result["created_tickets"]   # [{"id": ..., "title": ..., "repo": ...}]
repos_affected  = result["repos_affected"]    # ["omniclaude", "omnibase_core"]
count           = result["count"]             # 2
```

**epic-team** calls decompose-epic in its Phase 2 (Decomposition) via Mode A when it needs to create sub-tickets from a fresh epic description. It does NOT use Mode B (Mode B is used by ticket-pipeline during cross-repo split detection, where the repos are already known).

---

## Execution Checklist

Before marking the skill complete, verify:

- [ ] epic_id validated with regex before any Linear call
- [ ] Manifest loaded from home-dir path first, fallback to repo path
- [ ] Mode A: description non-empty check fires before any analysis
- [ ] Mode B: all repos validated against manifest before creating any ticket
- [ ] Labels resolved via MCP (best-effort — ticket creation proceeds even if label resolution fails)
- [ ] Each ticket has parentId set to epic_id
- [ ] Dry-run: console output only, no ModelSkillResult written, exit 0
- [ ] Live: ModelSkillResult written to correct path before printing final summary
- [ ] Partial failure: exits 0, reports which repos failed
- [ ] Zero tickets: exits 1 with error message, no result file
