Technical Debt Triage skill for Codex

Rank debt by the interest it charges every month. Codex builds a tech debt register from evidence rather than opinion: git churn times complexity from a bundled hotspot script, bug and incident links, CI times, outdated dependencies and team interviews. Each item gets an interest estimate, effort and risk score and a paydown strategy, and the top few become tickets with a measurable definition of done that managers can approve.

  • Engineering Workflow
  • $technical-debt-triage
  • 161 lines
  • 3 files
  • ~1,850 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- technical-debt-triage

Installs to ~/.agents/skills/technical-debt-triage/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $technical-debt-triage, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds a technical debt register from evidence - git churn and complexity hotspots, incident and bug links, slow builds, outdated dependencies, TODO markers and developer pain - scores each item by cost of delay against effort and risk, and turns the top items into scoped, fundable work with a definition of done, instead of a wish list of rewrites. Includes a hotspot script. Use when asked to assess tech debt, decide what to refactor first, make the case for a cleanup sprint, prioritise a debt backlog, or explain to managers why delivery is slowing down.

Use it when

  • Delivery is slowing down and nobody can say exactly why
  • You need to choose which refactor to fund this quarter
  • A debt backlog exists but has no priorities or owners
  • Managers ask for evidence before approving cleanup work

Use something else when

How to check the result

Every register row has a location, evidence and an interest estimate with its assumption, and rerunning the hotspot script after paydown shows targeted files falling in score.

The mistake it prevents

Ranking debt by how ugly code looks, so stable untouched modules get rewritten while the weekly-changed hotspot keeps producing bugs.

~/.agents/skills/technical-debt-triage/SKILL.md

--- name: technical-debt-triage description: Builds a technical debt register from evidence - git churn and complexity hotspots, incident and bug links, slow builds, outdated dependencies, TODO… license: MIT ---

Triage is done when every item on the register names where the debt lives, what it costs today in hours, incidents or lost velocity, what fixing it takes, and what “paid” looks like, and the top few items are scheduled work with owners. Debt that slows nobody down is not urgent, however ugly it looks.

Principles

  • Debt is measured by interest, not by ugliness: the extra time, defects and risk it causes every month. Code that never changes accrues almost no interest.
  • Hotspots (high change frequency combined with high complexity) are where interest is paid. Start there.
  • Prefer incremental paydown inside feature work over big-bang rewrites. A rewrite is a last resort that needs its own business case (see strangler-fig-migration).
  • Some debt was a good trade. Record the decision; do not shame it.

Workflow

- [ ] 1. Agree scope and horizon with the user (one service? whole repo? next quarter?)
- [ ] 2. Gather evidence (hotspots, incidents, build times, dependencies, team pain)
- [ ] 3. Build the register: one row per debt item, with location and evidence
- [ ] 4. Score each item (interest, effort, risk) and classify
- [ ] 5. Pick a paydown strategy per item
- [ ] 6. Turn the top 3-5 into tickets with a definition of done
- [ ] 7. Agree how progress will be measured

Step 2: gather evidence

Run the hotspot script first; it needs only git and the standard library:

python3 scripts/debt_hotspots.py --since 12.months --top 25
python3 scripts/debt_hotspots.py --path services/billing --ext .py --csv hotspots.csv

It ranks files by commits in the window times an indentation-weighted size (a cheap complexity proxy), and shows author count and TODO/FIXME/HACK markers per file. Paths are relative to the repository root. Read the top ten files before believing the ranking: generated code, fixtures and large config files can rank high and should be excluded.

Other evidence, cheapest first:

SignalHow
Bug densitygit log --since=12.months --format=%s -- <path> | rg -ci "fix|bug"; issue tracker labels by component
IncidentsPostmortems and incident tickets that name a component
Change couplingFiles that always change together across modules signal a missing abstraction (git log --name-only and count pairs)
Build and test timeCI duration trends; slowest test files
Dependency agenpm outdated, pip list --outdated, go list -m -u all, cargo outdated (network needed)
Known-vulnerable depsnpm audit, pip-audit, govulncheck ./... (network needed)
Type and lint suppressionsrg -c "@ts-ignore|eslint-disable|# type: ignore|noqa|nolint"
Developer painAsk the user or team: “Which area do you dread changing, and why?”

Interviews matter: the hotspot script cannot see a fragile deployment process or a manual release step that costs a day every week.

Step 3: the register

Keep it as a Markdown table or CSV in the repo (docs/tech-debt.md), not only in a tracker, so it is reviewed with code.

| ID | Item | Location | Evidence | Interest (per month) | Effort | Risk if ignored | Type |
|---|---|---|---|---|---|---|---|
| TD-1 | Order pricing in one 1,900-line function | src/orders/pricing.py | #1 hotspot (score 48k); 7 of 12 pricing bugs last year | ~3 dev-days slower features, 0.6 bugs | M (2 wks) | High: pricing errors hit revenue | Reckless-inadvertent |
| TD-2 | CI takes 38 min | .github/workflows/ci.yml | p50 38 min; 25 PRs/day | ~16 dev-hours waiting | S (3 days) | Medium | Tooling |
| TD-3 | Python 3.9 runtime (EOL) | Dockerfile, pyproject | EOL Oct 2025; blocks library upgrades | Security exposure | M | High | Platform |

Quantify interest even roughly; “about 3 developer-days per month” persuades in a way “messy code” never does. State the assumption behind every number.

Step 4: score and classify

Use a simple, explainable score. Recommended default:

priority = (interest x 3 + risk x 2) / effort        each on a 1-5 scale
Score bandAction
Top 3-5 by prioritySchedule as explicit work this quarter
High interest, high effortBreak into incremental steps; pay down inside features touching it
Low interest, any effortLeave it; record and revisit in 6 months
High risk, low interest (security, EOL runtimes, unsupported deps)Treat as risk work with a date, not a debt preference

Tag each item with a cause so patterns show up: deliberate shortcut, outdated design (the domain changed), missing tests, tooling, platform/dependency, knowledge loss.

Step 5: paydown strategies

StrategyWhen
Boy-scout rule inside featuresDebt is in a hotspot that is changed weekly anyway; add 10-20% to those tickets
Dedicated slice of capacity (for example 20%)Steady backlog of medium items
Focused project with a goalOne item dominates (CI time, a framework upgrade)
Strangler-fig replacementComponent is beyond incremental repair and business-critical
Accept and documentLow interest; or the code is scheduled for retirement
DeleteThe feature is unused (see dead-code-removal)

Pair every structural change with tests first (legacy-code-characterization) and small behaviour-preserving steps (safe-refactoring).

Step 6: make it fundable

Each scheduled item becomes a ticket:

### TD-1: Split order pricing into tested rules
**Problem:** pricing.py is the #1 hotspot; 7 of 12 pricing bugs in 2025 originated here.
**Cost today:** pricing features take ~2x estimate; 0.6 production bugs per month.
**Plan:** characterization tests -> extract discount, tax, shipping rules -> remove legacy path.
**Done when:** each rule module < 300 lines with unit tests; pricing bug rate tracked for 2 releases.
**Effort:** ~2 weeks, 1 engineer. **Risk while doing it:** medium; behind flag `pricing-rules-v2`.

Gotchas

  • Lines of code and cyclomatic complexity alone rank stable, ugly, harmless code as urgent. Always combine with churn or defect data.
  • Rewrites justified only by taste routinely take 3x their estimate and freeze features. Require measured interest before approving one.
  • A register nobody revisits becomes a graveyard. Review it quarterly; close items that stopped costing anything.
  • Do not start refactoring during triage. Triage produces decisions and tickets; changes follow in their own reviewed PRs.

Output

## Technical debt triage: <scope> (<date>)
**Method:** 12-month hotspots, incident review, CI metrics, interviews with <n> engineers
**Register:** docs/tech-debt.md (14 items)

### Top items to schedule
1. TD-2 CI time (priority 4.2) - 3 days, saves ~16 dev-hours/month
2. TD-1 Pricing hotspot (3.6) - 2 weeks incremental
3. TD-3 Python 3.9 EOL (risk) - deadline-driven

### Accepted for now
- TD-9 Legacy admin UI - low churn, retiring Q3

### Measures of progress
- CI p50 under 12 min; pricing bugs per month; hotspot score of pricing.py

Verification

  • Every register row has location, evidence and an interest estimate with its assumption.
  • The top items each have a definition of done that can be checked (a metric or a test).
  • Rerunning the hotspot script after paydown shows the targeted files dropping in score.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Technical Debt Triage"
  short_description: "Rank debt by the interest it charges every month"
  default_prompt: "$technical-debt-triage Assess the technical debt in this repo and tell me what to fix first"
scripts/debt_hotspots.py119 lines, 4.6 KB
#!/usr/bin/env python3
"""Rank code hotspots for technical-debt triage: files that change often AND are
large or deeply nested are where debt costs the most. Standard library only;
needs git and must run inside a git checkout. Read-only.

score = commits_in_window * complexity_proxy, where complexity_proxy is
non-blank lines weighted by average indentation depth. Also counts
TODO/FIXME/HACK/XXX markers and distinct authors.

Usage:
  python3 scripts/debt_hotspots.py [--since 12.months] [--top 25] [--path src/]
                                   [--ext .py,.ts] [--csv out.csv]
"""
from __future__ import annotations

import argparse
import csv
import re
import subprocess
import sys
from collections import Counter, defaultdict
from pathlib import Path

MARKER_RE = re.compile(r"\b(TODO|FIXME|HACK|XXX)\b")
DEFAULT_EXT = (".py,.ts,.tsx,.js,.jsx,.mjs,.go,.rs,.java,.kt,.rb,.php,.cs,.swift,"
               ".scala,.ex,.exs,.c,.cc,.cpp,.h,.vue,.svelte,.dart")
SKIP_PARTS = {"node_modules", "vendor", "dist", "build", "generated", "__generated__",
              "migrations", "third_party"}


def git(*args: str) -> str:
    r = subprocess.run(["git", *args], capture_output=True, text=True)
    if r.returncode != 0:
        raise SystemExit(f"git {' '.join(args)} failed: {r.stderr.strip()}")
    return r.stdout


def complexity(path: Path) -> tuple[int, float, int]:
    """Return (non-blank lines, indentation-weighted size, marker count)."""
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return 0, 0.0, 0
    loc, weighted, markers = 0, 0.0, 0
    for line in text.splitlines():
        stripped = line.strip()
        if not stripped:
            continue
        loc += 1
        expanded = line.expandtabs(4)
        depth = (len(expanded) - len(expanded.lstrip(" "))) / 4
        weighted += 1 + depth
        markers += len(MARKER_RE.findall(line))
    return loc, weighted, markers


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--since", default="12.months", help="git --since window (default 12.months)")
    ap.add_argument("--top", type=int, default=25)
    ap.add_argument("--path", default=".", help="limit to this path inside the repo")
    ap.add_argument("--ext", default=DEFAULT_EXT, help="comma-separated extensions to include")
    ap.add_argument("--csv", help="also write all rows to this CSV file")
    args = ap.parse_args()

    root = Path(git("rev-parse", "--show-toplevel").strip())
    exts = {e.strip().lower() for e in args.ext.split(",") if e.strip()}

    log = git("-C", str(root), "log", f"--since={args.since}", "--no-merges",
              "--format=@@%ae", "--name-only", "--", args.path)
    churn: Counter[str] = Counter()
    authors: defaultdict[str, set[str]] = defaultdict(set)
    author = ""
    for line in log.splitlines():
        if line.startswith("@@"):
            author = line[2:]
        elif line.strip():
            churn[line] += 1
            authors[line].add(author)

    rows = []
    for f, commits in churn.items():
        p = root / f
        if p.suffix.lower() not in exts or not p.is_file():
            continue
        if SKIP_PARTS.intersection(Path(f).parts):
            continue
        loc, weighted, markers = complexity(p)
        if loc == 0:
            continue
        rows.append({"file": f, "commits": commits, "authors": len(authors[f]), "loc": loc,
                     "avg_depth": round(weighted / loc - 1, 2), "markers": markers,
                     "score": round(commits * weighted)})

    if not rows:
        print(f"No matching source files changed since {args.since} under '{args.path}'")
        return 1
    rows.sort(key=lambda r: r["score"], reverse=True)

    print(f"Hotspots since {args.since} (score = commits x indentation-weighted size)\n")
    print(f"{'score':>9} {'commits':>7} {'authors':>7} {'loc':>6} {'depth':>5} {'todo':>4}  file")
    for r in rows[: args.top]:
        print(f"{r['score']:>9} {r['commits']:>7} {r['authors']:>7} {r['loc']:>6} "
              f"{r['avg_depth']:>5} {r['markers']:>4}  {r['file']}")
    total = sum(r["score"] for r in rows)
    top_share = sum(r["score"] for r in rows[: args.top]) / total if total else 0
    print(f"\n{len(rows)} files scored; top {min(args.top, len(rows))} hold {top_share:.0%} of total score.")

    if args.csv:
        with open(args.csv, "w", newline="", encoding="utf-8") as fh:
            w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
            w.writeheader()
            w.writerows(rows)
        print(f"Wrote {args.csv}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc