Installs into .claude/skills of the current project.
Are you the author of Link Epics?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/brennontwilliams-link-epics)
---
name: link-epics
description: Assign orphaned issues to existing EPICs, or cluster them into new-EPIC proposals, via `ll-issues link-epics`.
disable-model-invocation: true
argument-hint: "[--mode assign|synthesize] [--threshold <score>] [--auto] [--deep]"
model: sonnet
allowed-tools:
- AskUserQuestion
- Write
- Edit
- Read
- Bash(ll-issues:*, git:*)
arguments:
- name: flags
description: "--mode assign|synthesize (default: assign); --threshold 0.5 to set the min score (default: config.issues.link_epics.min_score); --auto to apply/create proposals without prompting; --deep (synthesize mode only) to add an LLM-adjudicated clustering pass"
required: false
metadata:
short-description: Assign orphans to EPICs, or cluster them into new-EPIC proposals.
---
# Link Epics
Scoring, tiering, and clustering all live in `ll-issues link-epics` — this skill
only parses arguments, presents the CLI's proposals, and (in `synthesize` mode)
names/creates the resulting EPICs, since EPIC-file creation is not yet part of
the CLI (tracked separately as FEAT-2947).
- **`mode: assign`** (default) — score orphans against **existing** open EPICs;
apply accepted proposals via the CLI's `--apply`.
- **`mode: synthesize`** — cluster orphans against each other; the CLI returns
proposal clusters only, this skill names and creates the EPIC files.
`ll-issues link-epics` scores *text similarity* between orphan/EPIC titles —
distinct from `ll-issues clusters`, which visualizes existing *dependency-edge*
relationships between issues that already declare `blocked_by`/`depends_on`.
---
## Step 1: Parse Arguments
- `MODE` from `--mode <value>`. Default: `assign`.
- `THRESHOLD` from `--threshold <value>` if present; otherwise omit the flag and
let the CLI fall back to `config.issues.link_epics.min_score`.
- `AUTO=true` if `--auto` is present.
- `DEEP=true` if `--deep` is present. Only meaningful with `MODE=synthesize`; the
CLI itself rejects `--deep` with `assign` (or `MODE` omitted), so no
argument-parsing check is needed here — just forward it through in S1 below.
---
## Exclusion Report
Both modes' JSON always include `skipped_malformed_metadata`, `skipped_intentional`,
`skipped_children_listed`, `malformed_metadata`, and `children_listed_drift`. Orphans counted
there were removed **before** scoring; never propose or hand-write a parent for them. When any
counter is non-zero or a list is non-empty, show it ahead of everything else:
```
Skipped N orphan(s): X malformed metadata, Y intentionally parentless, Z already listed in an EPIC's Children
FEAT-1: parenting key(s) after the frontmatter fence (parent) — run `ll-issues format-check FEAT-1 --fix --apply`
FEAT-2: listed in EPIC-3 (open), EPIC-4 (done) — add the back-reference or fix the listing (`ll-issues epic-consistency`)
```
- `malformed_metadata[].keys` names keys only; point the user at `ll-issues format-check <ID> --fix --apply`.
- In `children_listed_drift[].epics[]`, `blocks_proposal: false` (a `done`/`cancelled` EPIC) is
informational: that orphan may still appear in `proposals`/`clusters`. `excluded_reason` is
`null` for those.
- An orphan with a recorded `parentless_reason` is intentional; do not suggest a parent.
---
## Mode: `--mode assign` (default)
### A1: Get Proposals
```bash
ll-issues link-epics --mode assign --json ${THRESHOLD:+--threshold "$THRESHOLD"}
```
Parse `{"proposals": [{orphan_id, epic_id, score, tier}, ...], "applied": []}`. Every
payload also carries the exclusion report — see **Exclusion Report** below — **display it
before** any empty-result early return. If `proposals` is empty, report:
```
No orphan-to-EPIC proposals found above the score threshold.
```
Stop.
### A2: Proposal Flow
**Interactive (no `--auto`)**: present proposals via `AskUserQuestion`:
```yaml
questions:
- question: "Link these orphaned issues to their proposed epics? Select all you want to apply."
header: "Proposals"
multiSelect: true
options:
- label: "ENH-123 → EPIC-42 (HIGH 0.82)"
description: "orphan title — epic title"
```
`ll-issues link-epics --apply` applies the single highest-ranked EPIC for **every** orphan
at or above `THRESHOLD`; it has no single-pair apply, and a score threshold cannot represent
an arbitrary accepted subset or select a non-top EPIC. Run A3 only when the user accepts all
proposals. If they accept only some, or want a different EPIC than the top-ranked one, do
not run `--apply`; apply each such pair individually instead with
`ll-issues link <ORPHAN_ID> --parent <EPIC_ID>` (add `--reparent` only to replace an
existing parent). If nothing is selected, report
`No assignments made.` and stop.
**Auto (`--auto`)**: skip the prompt, go straight to A3.
### A3: Apply Assignments
```bash
ll-issues link-epics --mode assign --apply --json ${THRESHOLD:+--threshold "$THRESHOLD"}
```
This writes `parent:`/`epic:` on each orphan and appends `- **ID** — <title> (open)` to the
target EPIC's `## Children` section (idempotent — reapplying a pair changes nothing). An
EPIC with no exact `## Children` heading is left untouched and its pair carries
`"children_wired": false` in `applied`. Pairs that cannot be applied appear in the
JSON `rejected` list (`reason`: `conflicting_parent`, `ambiguous_children_section`,
`lock_timeout`, `metadata_unsafe`, `write_failed`) and the command exits 1; report them.
A `write_failed` rejection means the orphan was linked but the EPIC bullet was not
written — re-running `link-epics --apply` will **not** repair it (the orphan is no longer
parentless); run `ll-issues epic-consistency --fix <EPIC>` (or re-run
`ll-issues link <ORPHAN_ID> --parent <EPIC>`). For a `conflicting_parent` rejection, the
user can choose to override it explicitly with `ll-issues link <ORPHAN_ID> --parent <EPIC>
--reparent`. Stage the touched files:
```bash
git add -u {{config.issues.base_dir}}/
```
### A4: Report Results
```
Applied N orphan→EPIC assignment(s):
✓ ENH-123 → EPIC-42 (HIGH 0.82)
✓ BUG-55 → EPIC-42 (MEDIUM 0.51)
Files staged. Run /ll:commit to commit the changes.
```
---
## Mode: `--mode synthesize`
### S1: Get Cluster Proposals
```bash
ll-issues link-epics --mode synthesize --json ${THRESHOLD:+--threshold "$THRESHOLD"} ${DEEP:+--deep}
```
Parse `{"clusters": [{member_ids, placeholder_title, modal_priority,
pairwise_min_score, evidence, source}, ...], "applied": []}` — `evidence`/`source`
are only present on `--deep` runs (ENH-2979). `--apply` is **not** supported for
this mode (EPIC creation is deliberately kept out of the CLI — FEAT-2947).
If a top-level `"deep"` key is present (`--deep` skipped the LLM pass because the
orphan count exceeded the 40-orphan cap), surface it as a warning before
continuing with the score-based `clusters` the CLI still returned:
```
⚠ --deep skipped: {deep.count} orphans exceeds the 40-orphan cap; showing the score-based clusters only.
```
Display the **Exclusion Report** (below) before this empty-result check. If `clusters` is empty, report (mode-aware — `--deep` found no thematic or
vocabulary-based groupings, vs. plain scoring finding no vocabulary overlap):
```
No orphan clusters found above the score threshold — nothing to synthesize. # without --deep
No orphan clusters found (vocabulary- or LLM-adjudicated) — nothing to synthesize. # with --deep
```
Stop.
### S2: Name and Validate Clusters
For each cluster, review `placeholder_title` (frequency-derived from member
titles, or LLM-proposed for a `--deep`-sourced cluster) and `member_ids`. Replace
the placeholder with a clearer title when it's awkward or ambiguous; sanity-check
that every member actually belongs (drop odd-fit members from the proposal rather
than forcing the CLI's transitive grouping — clustering can chain unrelated
issues together through a shared intermediate).
When `source` is present (`--deep` runs), display it and the cited `evidence`
alongside each cluster, e.g.:
```
[Duplication Cleanup] ENH-10, ENH-22 (source: deep)
Evidence: "predicate duplication in autodev", "heuristic duplication in refine-issue"
```
Clusters with `source: deep` have **no lexical corroboration** — they were found
purely by the LLM's thematic read, not by shared vocabulary — so give them a closer
look than `source: jaccard`/`merged` clusters before accepting.
### S3: Proposal Flow
**Interactive (no `--auto`)**: one `AskUserQuestion`, `multiSelect: true`, options
sorted by descending cluster size:
```yaml
questions:
- question: "Which EPIC proposals should be created? Select all you want to create."
header: "EPIC Proposals"
multiSelect: true
options:
- label: "Cluster 1 → new EPIC \"CLI Output Format\" (5 issues)"
description: "FEAT-10, BUG-22, ENH-31, FEAT-45, ENH-67"
```
If nothing is selected, report `No EPICs created.` and stop.
**Auto (`--auto`)**: create an EPIC for every returned cluster.
### S4: Create Accepted EPICs and Write-Back
For each accepted cluster:
1. **Allocate EPIC ID** via `ll-issues next-id`, called **immediately before each
`Write`** — never batch-allocate upfront. If the PostToolUse hook reports the
file was deleted (duplicate integer ID), call `ll-issues next-id` again and
retry.
2. **Determine path**:
`{{config.issues.base_dir}}/epics/<PRIORITY>-<EPIC_ID>-<slugified-title>.md`
(slugify: lowercase, non-alphanumeric → `-`, collapse repeats). `<PRIORITY>` is
the cluster's `modal_priority`.
3. **Write the EPIC file** with `Write`:
```markdown
---
id: EPIC-NNN
title: <validated title>
type: EPIC
priority: <modal_priority>
status: open
captured_at: "<TODAY, date -u +%Y-%m-%dT%H:%M:%SZ>"
discovered_date: <DATE_ONLY, date -u +%Y-%m-%d>
discovered_by: link-epics
relates_to: []
---
# EPIC-NNN: <validated title>
## Summary
Group of <N> related issues: <member titles, comma-separated>.
## Children
- **CHILD_ID_1** — child issue 1 title (open)
- **CHILD_ID_2** — child issue 2 title (open)
```
> **Note (ENH-162)**: `relates_to:` is reserved for peer/see-also cross-references
> between EPICs and sibling issues — never list child IDs there; containment is
> `parent:` on each child plus the `## Children` section above.
4. **Write `parent:`/`epic:` back to each child.** The new EPIC did not exist
when `ll-issues link-epics --mode assign` last ran, so its `--apply` path
cannot cover this write — insert `parent: EPIC-NNN` and `epic: EPIC-NNN`
into each child's frontmatter block directly (same fields `apply_assignment()`
writes for `assign` mode). If `parent:` already has a non-null value, skip
and log: `⚠ CHILD_ID already has parent: <existing_value>, skipping.`
5. **Stage all files** by explicit path (`git add "<epic_path>"`,
`git add "<child_path>"` per child) — never `git add .issues/` (sweeps unrelated
files; see BUG-1976).
### S5: Report Results
```
Created N EPIC(s) from M orphaned issue(s):
✓ EPIC-42 "CLI Output Format" (5 issues)
• FEAT-10 — issue title
• BUG-22 — issue title
Files staged. Run /ll:commit to commit the changes.
```
If nothing was created (user declined all proposals), report:
```
No EPICs created. Run /ll:link-epics --mode assign to assign orphans to existing EPICs.
```
---
## Choosing a Mode
Run `--mode synthesize` first when no EPICs exist yet, or orphaned issues don't fit
any existing EPIC — it clusters orphans by thematic similarity and proposes new EPIC
files. Then run `--mode assign` (the default) to link any remaining orphans to the
newly created (or pre-existing) EPICs.
## Usage Examples
```bash
/ll:link-epics # assign mode, interactive
/ll:link-epics --auto # assign mode, apply without prompting
/ll:link-epics --threshold 0.4 # assign mode, interactive, custom threshold
/ll:link-epics --mode synthesize # synthesize mode, interactive
/ll:link-epics --mode synthesize --auto # synthesize mode, create all clusters
/ll:link-epics --mode synthesize --deep # + LLM-adjudicated clusters for same-theme,
# different-vocabulary orphans (ENH-2979)
```