Point it at a bug and get back a real triage: a severity from P0 to P4 with a justification, the likely root cause traced down the stack, possible duplicates, and the tests that prove the fix. It started as a CrewAI three-agent crew. Here it is rebuilt as one portable skill any agent can run, with a CLI table and a shareable HTML report.
Triage is where most bug trackers go to die: a hundred open tickets, no agreed severity, no owner, and the same bug filed four times. This guide builds an agent skill that does the first pass for you: it classifies the bug, refuses to inflate the severity, traces where the fault most likely lives, points at the logs to check, flags the probable duplicates, and hands you the tests that stop it coming back. Then it stops, because assigning and closing are a human's call.
This is the pattern we are porting, from the AI Tester Blueprint 2x course: three CrewAI agents that run in sequence, each fed the previous one's output. A triage analyst classifies, a root-cause specialist investigates, a test strategist recommends.
from crewai import Agent, Task, Crew, Process
bug_analyst = Agent(
role="Senior Bug Triage Analyst",
goal="Accurately classify incoming bugs by severity, category, and priority",
backstory="""You are a veteran QA engineer with 15 years of experience.
You follow strict severity classification:
- P0 (Blocker): System down, data loss, security breach
- P1 (Critical): Major feature broken, no workaround
- P2 (Major): Feature impaired, workaround exists
- P3 (Minor): Cosmetic issue, minor inconvenience
- P4 (Trivial): Enhancement request, typo
You never inflate severity. You always justify your classification.""",
llm=groq_llm, allow_delegation=False)
root_cause_agent = Agent(
role="Root Cause Analysis Specialist",
goal="Identify the likely root cause and affected system components",
backstory="""You analyze bugs by tracing through: UI -> API -> Service -> Database.
You identify whether the issue is frontend, backend, infra, or third-party,
and suggest which logs or dashboards to check first.""",
llm=groq_llm, allow_delegation=False)
test_recommender = Agent(
role="Test Strategy Advisor",
goal="Recommend specific tests to validate the fix and prevent regression",
backstory="""For every bug you recommend smoke tests to verify the fix,
regression cases to prevent recurrence, and edge cases for the suite.
You specify tests in Playwright TypeScript style when applicable.""",
llm=groq_llm, allow_delegation=False)
# Three tasks, each one fed the previous one's output via context=[...]
triage_task = Task(description=f"Classify this bug: {bug_report} ...", agent=bug_analyst)
root_cause_task= Task(description=f"Find the root cause ...", agent=root_cause_agent,
context=[triage_task])
test_task = Task(description=f"Recommend tests ...", agent=test_recommender,
context=[triage_task, root_cause_task])
crew = Crew(agents=[bug_analyst, root_cause_agent, test_recommender],
tasks=[triage_task, root_cause_task, test_task],
process=Process.sequential)
result = crew.kickoff()
It is a great design. The catch: it needs the CrewAI runtime, a Groq key, and a Python process running. The idea, three specialists in sequence with context passing, is portable to any skill-aware agent, no framework required.
An agent skill already runs "in sequence with context": the model reads the whole SKILL.md and works the steps in order, carrying its own findings forward. So the three CrewAI agents become three phases of one skill. The model does the reasoning (the part LLMs are good at), and two small scripts do the I/O: fetch the bug, and render the result.
The single most common triage mistake is mixing up how bad a bug is with where it lives. They are independent axes. A one-line typo in the footer is trivial severity in the UI layer. A silent wrong-total at checkout is critical severity in the service layer. The skill keeps them apart and answers both.
Paste this into GitHub Copilot or Claude Code and it generates the whole skill folder. It is specific on purpose: the exact P0-P4 definitions and the "never inflate, always justify" rule are what turn a vague summarizer into a triage tool teams trust.
Create an agent skill called bug-triage-crew that triages a bug report the way
a three-person QA crew would, but as ONE skill an agent follows end to end.
Build it as a folder: SKILL.md, references/severity-rubric.md, and
scripts/fetch_jira.py + scripts/render.py. Python standard library only.
The skill does four things, in order, and writes the result to triage.json:
1. TRIAGE. Classify severity on a P0-P4 scale, WITH a written justification, and
never inflate it. P0 blocker (system down / data loss / security), P1 critical
(major feature broken, no workaround), P2 major (workaround exists), P3 minor
(cosmetic), P4 trivial (typo / enhancement). Also category (UI, Functional,
Performance, Security, Data), affected component, business impact, and an
advisory sprint priority.
2. ROOT CAUSE. Trace the bug down UI -> API -> Service -> DB -> Infra ->
Third-party, name the single most likely layer, the probable cause, related
components, investigation steps, and which logs/dashboards to check first.
State a confidence level; if low, say what evidence would raise it.
3. DUPLICATES. Compare against the open bugs provided and surface likely matches
as leads with a reason, never an auto-close.
4. TEST STRATEGY. A verification test that proves the fix, 3-5 regression cases,
edge cases, and a Playwright TypeScript automation approach.
fetch_jira.py <KEY> reads JIRA_BASE_URL/JIRA_EMAIL/JIRA_API_TOKEN from the env
(never hard-coded) and prints normalized bug JSON.
render.py triage.json [--html FILE]
accepts one bug or a list, prints a P0-first CLI table, and
writes a self-contained HTML report grouped by severity with
the layer trace and any duplicates.
SKILL.md rules: never fabricate a bug or a repro step, never inflate severity,
duplicates are leads not verdicts, and the skill never assigns, transitions, or
closes a ticket. It ends at a human review gate that states confidence and asks
before anything is assigned or closed.
SKILL.mdThe four phases, the JSON shape, and the guardrails. The description is what makes the agent reach for this skill when you say "triage this bug".
--- name: bug-triage-crew description: >- Triage a bug report end to end the way a QA crew does: classify severity (P0-P4) with a justification, categorize it, trace the likely root cause down the UI -> API -> Service -> DB stack, flag possible duplicates, and recommend the tests that verify the fix and stop it coming back. Works from a Jira key or a pasted report, and writes a CLI table plus a shareable HTML triage report. Use when a tester or lead says "triage this bug", "what severity is this", "why is this happening and how do we test the fix", "triage the bug backlog", or "is this a duplicate". Always ends at a human review gate before anything is assigned or closed. license: MIT metadata: author: TheTestingAcademy stlc-phase: Defect Management version: 1.0.0 --- # Bug Triage Crew Three specialists in one skill: a triage analyst, a root-cause investigator, and a test strategist, run in that order so each builds on the last. It classifies and explains a bug; it must not assign it, change its status, or close it as a duplicate on its own. ## When to use - A bug lands and needs a severity, a category, and a "so what" before sprint planning. - The backlog has a pile of untriaged bugs and you want them sorted P0-first with reasons. - A lead asks "is this really critical?" and wants the call justified, not just asserted. - You suspect a duplicate and want the likely matches surfaced before you close anything. ## Workflow 1. **Gather the bug (never invent it).** From a Jira key: `python scripts/fetch_jira.py VWO-48 > bug.json`. Otherwise ask the reporter to paste the title, steps, and what they saw. If the report is thin, that thinness is a finding: list what is missing (repro, environment, expected vs actual) rather than filling it in. 2. **Triage (the analyst).** Read `references/severity-rubric.md` and classify: - **Severity P0-P4**, with a written justification against the rubric. Never inflate. If you bump a P2 to P1, say why the workaround does not count, or leave it a P2. - **Category**: UI, Functional, Performance, Security, Data. - **Affected component/module** and a one-line **business impact**. - A suggested **sprint priority** (advisory: the lead owns the real call). 3. **Root cause (the investigator).** Trace the bug down `UI -> API -> Service -> DB -> Infra -> Third-party` and name the single most likely layer, the probable cause, the related components, concrete investigation steps, and which **logs or dashboards to check first**. State confidence; if it is low, say what evidence would raise it rather than guessing. 4. **Check for duplicates.** Compare against the open/recent bugs you were given (or ask for that list). Surface likely matches with a one-line reason and a match strength. These are leads for a human, never an auto-close. 5. **Test strategy (the strategist).** Recommend a **verification** test that proves the fix, **3-5 regression** cases so it cannot silently return, **edge cases** worth adding, and an **automation** approach (Playwright TypeScript when it applies). Tie each back to the bug and its root cause. 6. **Emit + HUMAN REVIEW GATE (mandatory).** Write the decision to `triage.json` and render it: `python scripts/render.py triage.json --html triage.html` Present it as a DRAFT. State the confidence on severity and layer, what you assumed, and that severity/priority are advisory and duplicates are unconfirmed. Ask the tester or lead to confirm before anything is assigned, reprioritized, or closed. Do not transition the ticket, assign an owner, or close a duplicate yourself. ## triage.json shape ```json { "bug": { "id": "VWO-48", "title": "...", "reporter": "...", "url": "..." }, "triage": { "severity": "P1", "severity_reason": "why this and not one higher or lower", "category": "Functional", "component": "checkout / coupon reducer", "business_impact": "...", "sprint_priority": "This sprint", "confidence": "high" }, "root_cause": { "likely_cause": "...", "layer": "Service", "related_components": ["..."], "investigation": ["step", "step"], "check_first": ["log or dashboard"] }, "tests": { "verification": "...", "regression": ["...", "..."], "edge_cases": ["..."], "automation": "Playwright TS: ..." }, "duplicates": [ { "id": "VWO-31", "why": "...", "similarity": "high" } ] } ``` `render.py` accepts one object or a list (batch triage). It prints a P0-first CLI table and writes a self-contained HTML report grouped by severity. ## Guardrails - Never fabricate a bug, a repro step, an environment, or a log line. A missing detail is a finding, not a blank to fill. - Never inflate severity. Every classification is justified against the rubric, and severity is technical impact, not urgency. - Duplicates are leads, not verdicts. Do not close, merge, or link a ticket as duplicate without a human confirming. - Do not assign, reprioritize, transition, or close the ticket. The skill decides and explains; a human acts. - Root cause is a hypothesis with a confidence level and the logs to confirm it, never a stated fact. - Retries and longer timeouts are not fixes and are not test recommendations. Recommend tests that would have caught the bug. - Stay in Defect Management. Writing the fix, and the flakiness question, belong to other skills.
The skill reads this reference before it classifies, so P0-P4 means the same thing every time. This is the triage analyst's backstory, turned into a shared table.
# Severity rubric (P0-P4) The one rule that matters: **never inflate severity, and always justify the call.** Severity is technical impact ("how badly broken"), not urgency. Priority is the business call about when to fix it, and a human owns that. | Code | Name | Definition | Typical example | |------|------|------------|-----------------| | P0 | Blocker | System down, data loss, or security breach. No usable workaround. | Login 500s for everyone; payment double-charges; PII leak. | | P1 | Critical | Major feature broken, no reasonable workaround. | Checkout fails for a whole payment method; cart empties on refresh. | | P2 | Major | Feature impaired but a workaround exists. | Filter returns wrong order; a form needs a retry to submit. | | P3 | Minor | Cosmetic issue or minor inconvenience. | Misaligned button; a tooltip that does not dismiss. | | P4 | Trivial | Enhancement request or typo. | Stale copyright year; a spelling fix. | Justify every classification against this table. If you are tempted to bump a P2 to P1, write down why the workaround does not count. If you cannot, it is a P2. ## Category (what kind of bug) `UI` - layout, styling, rendering, copy. `Functional` - behavior does not match the requirement. `Performance` - slow, times out, leaks, degrades under load. `Security` - auth, authorization, injection, data exposure. `Data` - wrong, missing, or corrupted data; state that persists incorrectly. ## Root-cause layers (where it lives) Trace the bug down the stack and name the single most likely layer: `UI -> API -> Service -> DB -> Infra -> Third-party` - **UI** - the browser/client: rendering, client state, a locator or event. - **API** - the request/response contract: status, payload, validation. - **Service** - business logic: a reducer, a calculation, a workflow step. - **DB** - the data layer: a query, a constraint, a migration, stale rows. - **Infra** - deploy, config, env, networking, secrets, scaling. - **Third-party** - an external dependency you do not control. For each bug, say which layer and which logs or dashboards to check first. ## Confidence State `high / medium / low` on the severity and root-cause calls. Low confidence is not a failure, it is honesty: say what evidence (a trace, a repro, a log) would raise it. A justified "P1, medium confidence, need the checkout logs to confirm the layer" is more useful than a confident guess.
fetch_jira.pyPulls one bug over the Jira REST API and prints normalized JSON: title, reporter, status, components, and the description flattened out of Atlassian's document format. The token comes from the environment, never the file.
#!/usr/bin/env python3 """Fetch one Jira bug and print normalized JSON the agent can triage. Usage: python fetch_jira.py VWO-48 > bug.json Reads JIRA_BASE_URL / JIRA_EMAIL / JIRA_API_TOKEN from the environment. Never hard-code the token. Stdlib only. """ import os, sys, json, base64, urllib.request def adf_to_text(node): """Atlassian Document Format -> plain text (recursive, best-effort).""" if node is None: return "" if isinstance(node, str): return node out = [] if node.get("text"): out.append(node["text"]) for ch in node.get("content", []) or []: out.append(adf_to_text(ch)) sep = "\n" if node.get("type") in ("paragraph", "listItem", "heading") else "" return sep.join(p for p in out if p) def main(): if len(sys.argv) < 2: sys.exit("usage: python fetch_jira.py <BUG-KEY>") key = sys.argv[1] base = os.environ["JIRA_BASE_URL"].rstrip("/") # https://your.atlassian.net email = os.environ["JIRA_EMAIL"] token = os.environ["JIRA_API_TOKEN"] auth = base64.b64encode(f"{email}:{token}".encode()).decode() fields = "summary,description,issuetype,priority,status,labels,components,reporter,created" url = f"{base}/rest/api/3/issue/{key}?fields={fields}" req = urllib.request.Request(url, headers={ "Authorization": f"Basic {auth}", "Accept": "application/json"}) with urllib.request.urlopen(req) as r: data = json.load(r) f = data.get("fields", {}) bug = { "id": data.get("key", key), "title": f.get("summary", ""), "reporter": (f.get("reporter") or {}).get("displayName", ""), "status": (f.get("status") or {}).get("name", ""), "reported_priority": (f.get("priority") or {}).get("name", ""), "type": (f.get("issuetype") or {}).get("name", ""), "labels": f.get("labels", []), "components": [c.get("name") for c in (f.get("components") or [])], "description": adf_to_text(f.get("description")), "url": f"{base}/browse/{data.get('key', key)}", } print(json.dumps(bug, indent=2)) if __name__ == "__main__": main()
render.pyTakes the triage.json the agent produced and presents it: a P0-first CLI table for the terminal, and a self-contained HTML report grouped by severity, with the layer trace and any duplicates. No dependencies, so nothing to install.
#!/usr/bin/env python3 """Render a triage result (JSON the agent produced) to a CLI table + HTML report. The agent does the thinking: it reads the bug, classifies severity, traces the root cause, and recommends tests, following SKILL.md and references/severity-rubric.md. It writes that decision to triage.json. This script only presents it: a summary table in the terminal and a self-contained HTML report to attach to the ticket. Usage: python render.py triage.json # CLI table python render.py triage.json --html triage.html # + HTML report triage.json may be one object or a list of them (batch triage). Stdlib only. """ import argparse, json, sys, html, datetime SEV = { # code -> (label, css class, cli colour) "P0": ("Blocker", "p0", "\033[41m\033[97m"), "P1": ("Critical", "p1", "\033[31m"), "P2": ("Major", "p2", "\033[33m"), "P3": ("Minor", "p3", "\033[36m"), "P4": ("Trivial", "p4", "\033[32m"), } LAYERS = ["UI", "API", "Service", "DB", "Infra", "Third-party"] def load(path): data = json.load(open(path)) return data if isinstance(data, list) else [data] def sev_code(t): return (t.get("triage", {}).get("severity") or "P3").upper().split()[0] # ---------- cli ---------- def cli(bugs, color=True): C = {"b": "\033[1m", "d": "\033[2m", "x": "\033[0m"} if not color: C = {k: "" for k in C} for k in SEV: SEV[k] = (SEV[k][0], SEV[k][1], "") rows = sorted(bugs, key=lambda t: sev_code(t)) idw = min(max([len(b.get("bug", {}).get("id", "")) for b in rows] + [6]), 12) titw = 34 out = ["", f"{C['b']}Bug Triage{C['x']} {C['d']}{len(rows)} bug(s) - {datetime.date.today()}{C['x']}"] hdr = f"{'BUG':<{idw}} {'TITLE':<{titw}} {'SEV':<4} {'CATEGORY':<12} {'LAYER':<11} {'PRIORITY':<12} DUP" out += ["-" * len(hdr), f"{C['b']}{hdr}{C['x']}", "-" * len(hdr)] for t in rows: code = sev_code(t) label, _, col = SEV.get(code, SEV["P3"]) bug = t.get("bug", {}) tri = t.get("triage", {}) rc = t.get("root_cause", {}) dup = t.get("duplicates") or [] title = (bug.get("title", "") or "")[:titw] dupmark = f"{len(dup)}" if dup else "-" out.append( f"{bug.get('id',''):<{idw}} {title:<{titw}} " f"{col}{code:<4}{C['x']} {(tri.get('category','') or '')[:12]:<12} " f"{(rc.get('layer','') or '-')[:11]:<11} {(tri.get('sprint_priority','') or '')[:12]:<12} {dupmark}") out.append("-" * len(hdr)) # legend + counts counts = {} for t in rows: counts[sev_code(t)] = counts.get(sev_code(t), 0) + 1 summary = " ".join(f"{SEV[k][2]}{k}{C['x']} {counts[k]}" for k in ("P0", "P1", "P2", "P3", "P4") if k in counts) out.append(f"{C['b']}Summary:{C['x']} {summary}") out.append(f"{C['d']}severity is advisory + justified - never inflated - dev/lead confirm{C['x']}") out.append("") return "\n".join(out) # ---------- html ---------- def li(items): return "".join(f"<li>{html.escape(str(i))}</li>" for i in (items or [])) def card(t): e = lambda s: html.escape(str(s or ""), quote=True) bug = t.get("bug", {}) tri = t.get("triage", {}) rc = t.get("root_cause", {}) ts = t.get("tests", {}) dup = t.get("duplicates") or [] code = sev_code(t) label, cls, _ = SEV.get(code, SEV["P3"]) conf = tri.get("confidence", "") confbadge = f'<span class="conf">{e(conf)} confidence</span>' if conf else "" layer_dots = "".join( f'<span class="layer {"on" if l == rc.get("layer") else ""}">{l}</span>' for l in LAYERS) dup_html = "" if dup: rows = "".join( f'<tr><td class="mono">{e(d.get("id",""))}</td><td>{e(d.get("why",""))}</td>' f'<td class="mono">{e(d.get("similarity",""))}</td></tr>' for d in dup) dup_html = f'''<div class="sec dup"><h3>Possible duplicates ({len(dup)})</h3> <table class="mini"><tr><th>Ticket</th><th>Why it may be the same</th><th>Match</th></tr>{rows}</table> <p class="hint">Confirm before closing anything as duplicate. This is a lead, not a verdict.</p></div>''' return f'''<article class="bug {cls}"> <div class="bug-head"> <div><span class="sev {cls}">{code} {e(label)}</span> {confbadge}</div> <div class="bug-id mono">{e(bug.get("id",""))}</div> </div> <h2>{e(bug.get("title",""))}</h2> <div class="chips"> <span class="chip">Category: <b>{e(tri.get("category",""))}</b></span> <span class="chip">Component: <b>{e(tri.get("component",""))}</b></span> <span class="chip">Priority: <b>{e(tri.get("sprint_priority",""))}</b></span> {f'<span class="chip">Reporter: {e(bug.get("reporter",""))}</span>' if bug.get("reporter") else ""} </div> <div class="sec"><h3>1. Triage</h3> <p><b>Severity {code} ({e(label)}).</b> {e(tri.get("severity_reason",""))}</p> <p><b>Business impact.</b> {e(tri.get("business_impact",""))}</p> </div> <div class="sec"><h3>2. Root cause</h3> <div class="layers">{layer_dots}</div> <p><b>Likely cause.</b> {e(rc.get("likely_cause",""))}</p> {f'<p><b>Related components.</b> {e(", ".join(rc.get("related_components",[])))}</p>' if rc.get("related_components") else ""} {f'<p><b>Investigate.</b></p><ol>{li(rc.get("investigation"))}</ol>' if rc.get("investigation") else ""} {f'<p><b>Check first:</b> {e(", ".join(rc.get("check_first",[])))}</p>' if rc.get("check_first") else ""} </div> <div class="sec"><h3>3. Test strategy</h3> {f'<p><b>Verification.</b> {e(ts.get("verification",""))}</p>' if ts.get("verification") else ""} {f'<p><b>Regression.</b></p><ul>{li(ts.get("regression"))}</ul>' if ts.get("regression") else ""} {f'<p><b>Edge cases.</b></p><ul>{li(ts.get("edge_cases"))}</ul>' if ts.get("edge_cases") else ""} {f'<p><b>Automation.</b> {e(ts.get("automation",""))}</p>' if ts.get("automation") else ""} </div> {dup_html} </article>''' def report(bugs): ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") counts = {} for t in bugs: counts[sev_code(t)] = counts.get(sev_code(t), 0) + 1 tiles = "".join( f'<div class="c {SEV[k][1]}"><div class="k">{k} {SEV[k][0]}</div><div class="v">{counts.get(k,0)}</div></div>' for k in ("P0", "P1", "P2", "P3", "P4")) cards = "\n".join(card(t) for t in sorted(bugs, key=sev_code)) return f'''<!DOCTYPE html> <html lang="en"><head><meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1"/> <title>Bug Triage Report</title> <style> :root{{--ink:#1a1c23;--sub:#5b6472;--line:#e4e7ec;--bg:#fbfaf7;--card:#fff;--accent:#7c3aed; --p0:#b91c1c;--p1:#dc2626;--p2:#ea580c;--p3:#ca8a04;--p4:#16a34a}} @media(prefers-color-scheme:dark){{:root{{--ink:#e9edf4;--sub:#9aa6b8;--line:#2a2f3a;--bg:#0d0f14;--card:#151922}}}} *{{box-sizing:border-box}} body{{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;padding:32px 20px 60px}} .wrap{{max-width:940px;margin:0 auto}} h1{{font-size:26px;margin:0 0 4px;letter-spacing:-.02em}} .meta{{color:var(--sub);font-size:14px;margin-bottom:22px}} .cards{{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-bottom:26px}} .c{{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;border-top:3px solid var(--line)}} .c.p0{{border-top-color:var(--p0)}}.c.p1{{border-top-color:var(--p1)}}.c.p2{{border-top-color:var(--p2)}} .c.p3{{border-top-color:var(--p3)}}.c.p4{{border-top-color:var(--p4)}} .c .k{{color:var(--sub);font-size:11px;text-transform:uppercase;letter-spacing:.06em;font-weight:600}} .c .v{{font-size:26px;font-weight:800;margin-top:4px}} article.bug{{background:var(--card);border:1px solid var(--line);border-left:4px solid var(--line);border-radius:12px;padding:20px 22px;margin:16px 0}} article.p0{{border-left-color:var(--p0)}}article.p1{{border-left-color:var(--p1)}}article.p2{{border-left-color:var(--p2)}} article.p3{{border-left-color:var(--p3)}}article.p4{{border-left-color:var(--p4)}} .bug-head{{display:flex;justify-content:space-between;align-items:center;gap:12px}} .bug-id{{color:var(--sub);font-size:13px}} .mono{{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}} .sev{{font-size:12px;font-weight:800;letter-spacing:.04em;padding:4px 10px;border-radius:7px;color:#fff}} .sev.p0{{background:var(--p0)}}.sev.p1{{background:var(--p1)}}.sev.p2{{background:var(--p2)}} .sev.p3{{background:var(--p3)}}.sev.p4{{background:var(--p4)}} .conf{{font-size:11px;color:var(--sub);margin-left:8px}} article h2{{font-size:19px;margin:12px 0 10px}} .chips{{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:6px}} .chip{{font-size:12.5px;color:var(--sub);background:var(--bg);border:1px solid var(--line);border-radius:7px;padding:4px 9px}} .chip b{{color:var(--ink)}} .sec{{border-top:1px solid var(--line);padding-top:12px;margin-top:14px}} .sec h3{{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--sub);margin:0 0 8px}} .sec p{{margin:6px 0}} .sec ul,.sec ol{{margin:6px 0;padding-left:22px}} .sec li{{margin:3px 0}} .layers{{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}} .layer{{font-size:12px;font-weight:600;color:var(--sub);background:var(--bg);border:1px solid var(--line);border-radius:999px;padding:3px 11px}} .layer.on{{background:var(--accent);color:#fff;border-color:var(--accent)}} table.mini{{width:100%;border-collapse:collapse;margin-top:6px;font-size:13px}} table.mini th{{text-align:left;color:var(--sub);font-size:11px;text-transform:uppercase;padding:6px 8px;border-bottom:1px solid var(--line)}} table.mini td{{padding:6px 8px;border-bottom:1px solid var(--line)}} .dup{{}} .hint{{color:var(--sub);font-size:12.5px;font-style:italic}} footer{{margin-top:26px;color:var(--sub);font-size:13px}} </style></head><body><div class="wrap"> <h1>Bug Triage Report</h1> <div class="meta">{len(bugs)} bug(s) - generated {ts} - severity is advisory and justified, a human confirms before assignment</div> <div class="cards">{tiles}</div> {cards} <footer>Generated by the bug-triage-crew skill - The Testing Academy. Severity never inflated; every call is justified and a human owns the final decision.</footer> </div></body></html>''' def main(): ap = argparse.ArgumentParser(description="Render agent triage JSON to CLI + HTML.") ap.add_argument("input", help="triage.json (object or list)") ap.add_argument("--html", metavar="FILE") ap.add_argument("--no-color", action="store_true") a = ap.parse_args() bugs = load(a.input) print(cli(bugs, color=not a.no_color and sys.stdout.isatty())) if a.html: open(a.html, "w").write(report(bugs)) print(f"HTML report: {a.html}") if __name__ == "__main__": main()
Real output from the script above on a three-bug sample, sorted P0 first:
python scripts/render.py triage.json --html triage.html
Bug Triage 3 bug(s) - 2026-07-18 ------------------------------------------------------------------------------------------ BUG TITLE SEV CATEGORY LAYER PRIORITY DUP ------------------------------------------------------------------------------------------ VWO-52 Login page throws 500 for all user P0 Functional Infra Hotfix now, - VWO-48 Coupon discount silently lost on s P1 Functional Service This sprint 1 VWO-57 Footer copyright year still shows P4 UI UI Backlog / ne - ------------------------------------------------------------------------------------------ Summary: P0 1 P1 1 P4 1 severity is advisory + justified - never inflated - dev/lead confirm HTML report: triage.html
One glance tells you the shape of the queue: a P0 to hotfix now, a P1 for the sprint that also has a likely duplicate, and a P4 for the backlog. Severity drives the order, the layer tells the fixer where to start, and the DUP column stops the same bug being worked twice.
The same triage as a file you can attach to the ticket or drop in a CI artifact. Severity tiles up top, then one card per bug with the justification, the layer trace, the test plan, and any duplicate leads. Self-contained and dark-mode aware.
# Claude Code cp -r bug-triage-crew ~/.claude/skills/ # then just ask "triage VWO-48 and tell me the severity, root cause, and tests"
Part of the free 36-skill QA Skill Suite, and a sibling of the flaky test detector.