Scan all non-completed Linear tickets, verify status against actual PR state, auto-mark done tickets, flag stale ones, and identify orphans needing epic assignment
Install
npx skillscat add omninode-ai/omniclaude/linear-triage Install via the SkillsCat registry.
Linear Triage
Dispatch Requirement
When invoked, dispatch to a polymorphic-agent:
Agent(
subagent_type="onex:polymorphic-agent",
description="Linear triage scan",
prompt="Run the linear-triage skill. <full context>"
)CRITICAL: subagent_type MUST be "onex:polymorphic-agent" (with the onex: prefix).
Overview
Scan all non-completed tickets in Linear, determine their true status, apply updates,
and produce a TriageReport for downstream skills (linear-epic-org, ticket-plan --sync).
Announce at start: "I'm using the linear-triage skill to assess ticket health."
Imports: @_lib/contracts/helpers.md
Quick Start
/linear-triage
/linear-triage --dry-run
/linear-triage --threshold-days 7Algorithm
Phase 1: Fetch
Fetch all non-done tickets from Linear:
mcp__linear-server__list_issues(
state="not done", # excludes: Done, Cancelled
limit=250
)Repeat with cursor until all pages fetched. Build list of TicketContract records.
Phase 2: Age Classification
For each ticket, compute age_days = today - ticket.updatedAt:
THRESHOLD_DAYS = 14 # configurable via --threshold-days
def classify(ticket):
age_days = (today - ticket.updated_at).days
if age_days <= THRESHOLD_DAYS:
return "recent"
else:
return "stale"Recent tickets → Phase 3 (PR status check)
Stale tickets → Phase 4 (staleness flagging)
Phase 3: PR Status Check (recent tickets only)
For each recent ticket, look up its GitHub PR:
Step 3a: Extract repo slug
Extract repo from the ticket's Linear branch name field (branchName) or title prefix:
def extract_repo(ticket):
# 1. From branchName: "jonah/omn-2068-omniclaude-db-split-03-..." → "omniclaude"
if ticket.branch_name:
parts = ticket.branch_name.split("/", 1)
if len(parts) > 1:
slug = parts[1].split("-")[2] # omn-NNNN-SLUG-rest
if slug in KNOWN_REPOS:
return slug
# 2. From title prefix: "[omniclaude] ..." → "omniclaude"
import re
m = re.match(r'^\[([^\]]+)\]', ticket.title)
if m and m.group(1) in KNOWN_REPOS:
return m.group(1)
# 3. From labels
for label in ticket.labels:
if label.name in KNOWN_REPOS:
return label.name
return None # unknown repo — cannot PR-check
KNOWN_REPOS = [
"omnibase_core", "omniclaude", "omnibase_infra",
"omnidash", "omniintelligence", "omnimemory",
"omninode_infra", "omnibase_spi", "onex_change_control",
]Step 3b: Search for PR
# Search by ticket ID in PR title/body
gh pr list \
--repo omninode-ai/{repo_slug} \
--search "{ticket_id}" \
--state all \
--json number,title,state,mergedAt,url \
--limit 5If no results, also try branch name search:
gh pr list \
--repo omninode-ai/{repo_slug} \
--head "{branch_name}" \
--state all \
--json number,title,state,mergedAt,url \
--limit 3Step 3c: Determine action
| Linear State | PR State | Action |
|---|---|---|
| In Progress / In Review | PR merged | mark_done |
| In Progress / In Review | PR closed (unmerged) | search all repos for sibling merged PR mentioning ticket_id → if found: mark_done_superseded; else: flag stale + add note |
| In Progress / In Review | PR open | no_change |
| In Progress / In Review | PR not found | no_change (skip) |
| Backlog | PR merged | mark_done |
| Backlog | PR not found | no_change |
Evidence required for mark_done / mark_done_superseded: Merged PR URL + merge date.
For mark_done_superseded, also include the closed PR number. Never mark done without
confirmed evidence of a merged PR.
Step 3c-i: Sibling merged PR search (for closed-PR tickets)
When a PR is found closed (not merged), search all known repos for any merged PR that
mentions the same ticket ID:
for REPO in omnibase_core omniclaude omnibase_infra omnidash omniintelligence \
omnimemory omninode_infra omnibase_spi onex_change_control; do
gh pr list \
--repo OmniNode-ai/$REPO \
--search "{ticket_id}" \
--state merged \
--json number,title,mergedAt,url \
--limit 3
doneNote on --search scope: GitHub's PR search (gh pr list --search) queries both PR
title and body text, so PRs that mention the ticket ID only in the body (not the title) are
still found. This covers the superseded-PR pattern where the body references the original
ticket but the title references only the merging ticket.
If any result is returned:
- Action:
mark_done_superseded - Comment: "Auto-closed by linear-triage: work delivered via sibling PR #{number} in {repo} merged {mergedAt}\n{url}\n(Original PR #{closed_pr_number} was closed as superseded)"
If no merged sibling found:
- Action: flag stale + add note (existing behavior)
Step 3d: Apply mark_done (unless --dry-run)
mcp__linear-server__save_issue(
id=ticket_id,
state="Done"
)
# Add comment with evidence
mcp__linear-server__create_comment(
issueId=ticket_id,
body="✅ Auto-closed by linear-triage: PR #{number} merged {merge_date}\n{pr_url}"
)Phase 4: Stale Flagging
For stale tickets, compute a recommendation:
def recommend(ticket, age_days):
if ticket.state in ("In Progress", "In Review") and age_days > 60:
return "review_and_close"
if ticket.state == "Backlog" and age_days > 30:
return "review_and_close"
return "keep_open"Do NOT automatically close stale tickets. Only flag them in the TriageReport.
Human review required.
Phase 5: Orphan Detection
For all non-done tickets without a parent epic (parentId == null):
def is_orphaned(ticket):
return ticket.parent_id is None and ticket.state not in ("Done", "Cancelled")Infer proposed_epic_group from naming pattern:
import re
def infer_epic_group(ticket):
# Pattern: "[repo] PREFIX-NN: title" → group by PREFIX
m = re.match(r'^\[[^\]]+\]\s+([A-Z][A-Z0-9-]+?)-\d+:', ticket.title)
if m:
return m.group(1) # e.g., "DB-SPLIT"
# Pattern: "PREFIX-NN: title" (no repo prefix)
m = re.match(r'^([A-Z][A-Z0-9-]+?)-\d+:', ticket.title)
if m:
return m.group(1)
return None # ambiguous — needs human groupingPhase 5b: Epic Completion Detection
For each non-done ticket that has child tickets (i.e., any ticket where other tickets
have parentId == this_ticket.id):
def check_epic_complete(epic_ticket, all_tickets):
children = [t for t in all_tickets if t.parent_id == epic_ticket.id]
if not children:
return False # no children — not an epic, skip
all_done = all(t.state in ("Done", "Cancelled") for t in children)
return all_doneIf check_epic_complete returns True:
- Action:
mark_done_epic(applied immediately unless--dry-run) - Comment:
Auto-closed by linear-triage: all {N} child tickets are Done. Children: {comma-separated OMN-XXXX list}
Note: Fetch children via:
mcp__linear-server__list_issues(parentId=epic_ticket.id, includeArchived=true, limit=50)Phase 6: Write TriageReport
Write report to ~/.claude/state/linear-triage/{run_id}.yaml (see TriageReport in@_lib/contracts/helpers.md for schema).
Print summary to stdout:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Linear Triage Report (run: {run_id})
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Scanned: {total_scanned} tickets
Recent (<{N}d): {recent} tickets
Stale (>{N}d): {stale} tickets
✅ Marked done: {marked_done} (includes {marked_done_superseded} superseded-PR, {epics_closed} epic completions)
⚠️ Stale flags: {stale_flagged} (human review needed)
🔗 Orphans: {orphaned} (no parent epic)
📦 Proposed new epics: {proposed_epics}
Report: ~/.claude/state/linear-triage/{run_id}.yaml
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━Dry-Run Mode
When --dry-run:
- All Linear queries execute normally
- No
save_issueorcreate_commentcalls are made - TriageReport is written with
dry_run: true - All
actions_appliedentries show dry-run prefixed actions:"marked_done"→"would_mark_done""marked_done_superseded"→"would_mark_done_superseded""marked_done_epic"→"would_mark_done_epic"
Rate Limits
Linear API has per-minute rate limits. If you have >100 tickets:
- Batch PR lookups: process 20 at a time with brief pauses
- Prioritize recent In-Progress tickets over Backlog
Composable Output
When invoked as a sub-skill (e.g., from linear-housekeeping), write TriageReport
to ~/.claude/state/linear-triage/{run_id}.yaml and return the path in output.
The orphaned_tickets list from the TriageReport is the input to linear-epic-org.
See Also
@_lib/contracts/helpers.md— TicketContract, TriageReport schemaslinear-epic-orgskill — consumes orphaned_tickets from this reportlinear-housekeepingskill — orchestrates triage → epic-org → ticket-plan --syncticket-plan --sync— uses triage output for MASTER_TICKET_PLAN.md sync- Linear MCP tools (
mcp__linear-server__*)