File a tech-debt issue

SkillFiles & storage

File a new tech-debt GitHub issue for an out-of-scope code-review finding, building the dedup key, checking for an existing open or declined-closed match, and only if none exists, creating the issue with the right labels and touching the debt-count staleness sentinel. Trigger on natural-language asks like "file a tech-debt issue", "record this as tech-debt", "open a tech-debt issue for this out-of-scope finding", or "file this finding as debt". Do NOT trigger on draining, fixing, listing, or prioritizing existing debt (that's `/gaia-debt`), nor on general "clean up the code" or "fix this bug" asks that aren't about filing a new tracked issue.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the File a tech-debt issue skill

What this skill tells your AI

The instructions your AI receives, as published by gaia-react/gaia in .claude/skills/file-tech-debt/SKILL.md and read by ahel’s review.

This skill is the single source of truth for turning one out-of-scope finding (a real problem spotted while reviewing something else, and therefore not fixed in place) into a durable, deduplicated GitHub issue. It covers building the key, checking for a prior match, filing when there is none, and nudging the debt-count display to refresh. It does not decide which findings are out-of-scope, does not classify security-sensitivity, and does not fix anything, it only files.

Callers own their own bookkeeping around this recipe. Some callers record their own disposition-ledger entry and gate their own downstream state on it after filing succeeds; others file and stop. That bookkeeping is caller-specific and lives in the caller, not here. Follow the steps below exactly as written; do not invent a bookkeeping record, a completion flag, or a run-tracking step of your own on top of them, that would duplicate (or fight with) whatever the caller already does.

1. Build the dedup key

Every filed issue's body carries exactly one dedup-key line: a single HTML comment, byte-for-byte in this form:

<!-- gaia-debt-key: v1 class=<finding_class> path=<repo-relative-posix-path> line=<integer> -->
  • v1 is the schema version. Bump it only for a breaking change to the key's shape, not for routine use.
  • <finding_class> is the finding's seeded class, or holistic/unclassified when the finding maps to no seeded class.
  • <path> is a repo-relative POSIX path (forward slashes, never an absolute machine path).
  • <line> is a plain integer.

This line is what every later step (dedup, re-filing checks, any caller-side ledger) matches against, so build it first and keep it verbatim in the body you construct in step 4.

2. Check for an existing match (dedup)

Never rely on gh's full-text search. GitHub's search tokenizes on / : @, so it cannot reliably match a key containing those characters. Query and match locally instead, and match on the parsed path= and line= fields alone, ignoring class=: a finding reclassified from holistic/unclassified to a seeded class (or the reverse) still carries the same path=+line= and must resolve to the same issue, not a new one.

  1. gh issue list --label tech-debt --state open --limit 1000 --json number,title,body. For each issue's gaia-debt-key comment, parse out its path= and line= fields and compare them against the finding's own path and line: path= as a string, line= as a parsed integer, so line=4 never matches line=42. Two keys equal on both fields are the same finding regardless of what class= either one carries.
  2. Also check --state closed with the same --limit 1000: the same path+line comparison on a closed issue that carries the wontfix label (or was closed as not-planned) means the finding was declined, not merely resolved. Do not re-file it.
  3. Keyless fallback for issues a human filed by hand (no machine key present): scan open tech-debt issue bodies for the bare <path>:<line> substring. Anchor the match so the line number is followed by a non-digit or end-of-string, otherwise foo.ts:4 false-matches a sibling foo.ts:42. This is the same path+line identity as 1 and 2, sourced from a bare-text scan instead of a parsed key; a hit here suppresses re-filing even with no key line at all.

On any match (1, 2, or 3), hand back to the caller the matched issue's number, its open/closed state, and, when the match came from a parsed key (1 or 2), that key's existing verbatim inner key (v1 class=… path=… line=…). This recipe records nothing itself; callers own their bookkeeping (see above).

Accepted tradeoff: two genuinely distinct findings that land on the exact same path:line with different root-cause classes collapse to one issue under path+line dedup. This is the same residual risk the keyless path:line fallback already accepted; matching on path+line alone extends it to the machine-keyed case too.

3. Idempotency: skip if a match exists

If step 2 found a matching open issue, or a declined-closed one, stop, do not file. The finding already has a disposition; re-filing would create a duplicate. For an open match, the caller records the matched issue's number and its existing inner key (both returned by step 2) in its own bookkeeping, not a freshly-built key that may carry a different class=. For a declined-closed match, the caller adds no bookkeeping entry, exactly as an unmatched-skip is today.

4. Otherwise, file the issue

If no match exists:

  1. Create the labels idempotently first (step 6), a pre-existing label is not an error.

  2. Build the full issue body (step 5) in a gitignored body-file, not inline. Give the file a per-run-unique name under .gaia/local/audit/ (for example .gaia/local/audit/issue-body-<something-unique>.md). The name must be unique because the create-and-cleanup sub-step below deletes it: two runs sharing one fixed name (CI plus a local run, the same pair sub-step 3 below guards against) would race, and one run's cleanup would delete the other's in-flight body out from under it.

  3. Re-check the dedup query from step 2 immediately before creating, this shrinks the race window where a concurrent run (CI plus a local run, for instance) files the same finding twice. It is the same path+line matching basis as step 2, so a reclassification that lands between your first check and now still resolves to the already-open issue. Prefer a search-or-update path over a blind create when your environment supports it.

  4. Check the metadata before creating, and do not create on a finding. Pass the exact label set the create call is about to carry, comma-separated, together with the body file built in sub-step 2:

    # Graded filing, when a grade is in hand:
    bash .gaia/scripts/check-debt-issue-metadata.sh --pre-file \
      --labels "tech-debt,severity:<tier>,handler:<class>,difficulty:<grade>" \
      --body-file "$body_file"
    
    # Ungraded filing, when no grade is available:
    bash .gaia/scripts/check-debt-issue-metadata.sh --pre-file \
      --labels "tech-debt,severity:<tier>,handler:<class>" \
      --body-file "$body_file"
    

On the GAIA maintainer repository both --labels strings also carry surface:<side>; see the note under sub-step 5. Omitting it there fails this very check, which gates on the surface: count.

Two forms, matching sub-step 5's two gh issue create forms exactly. The ungraded form drops the difficulty: entry rather than passing it empty or with the placeholder still in it, for the same reason the create call does. The check rejects both of those, correctly: an unfilled placeholder is the shape an omitted grade most often arrives in, and letting it through would file the literal text as a label.

Exit 0 is clean, 1 names one finding per line, 2 is a usage or environment error. On 1, fix the label set or the body and re-run; do not file. On 2, the check itself could not run: report that and do not treat it as a pass.

Why this blocks rather than advises. Every rule the check enforces was already written in the prose above before the check existed, and every one of them was violated anyway. The label set is the one part of a filing that no later step re-reads, so a mistake there is silent until a drainer trips over it weeks later, by which time the code that would have justified the right grade has moved. The check reads no network and needs no gh, so this gate costs one local call and cannot fail for a reason outside the filing.

What it does not check. It verifies the label vocabulary, the counts, and the key's shape. It cannot verify that the grade you chose is the grade the rubric gives: whether a fix carries a design decision is a judgment about code, and a passing check is not evidence that step 7 was applied honestly. The mechanical half is enforced here; the rubric half stays yours.

  1. Create the issue with the form that matches whether a grade is available, then delete the body file in a second, separate Bash tool call. A filing that has a difficulty grade in hand (step 7) uses the graded form; a filing with no grade drops the --label difficulty:<grade> flag entirely rather than passing it empty or with a placeholder:
body_file=.gaia/local/audit/issue-body-<something-unique>.md

# Graded filing, when a grade is in hand:
gh issue create --label tech-debt --label severity:<tier> --label handler:<class> --label difficulty:<grade> --body-file "$body_file"

# Ungraded filing, when no grade is available:
gh issue create --label tech-debt --label severity:<tier> --label handler:<class> --body-file "$body_file"

The handler:<class> flag is on both forms because it is not optional the way the grade is: a filing that has not read the cited code still knows how far its own suggested fix reaches.

On the GAIA maintainer repository every filing carries one more label, surface:<side> (step 6). It rides in both --labels strings above and on both gh issue create forms as --label surface:<side>, immediately after severity:<tier>. Like the handler class it is not optional the way the grade is: a filing that has not read the cited code still knows which side of the adopter/maintainer split its cited path sits on.

Never pass --body <argv> here. CI runs this command with --verbose, and --verbose echoes argv into the public Actions log, so an inline --body string leaks the finding (and anything sensitive quoted inside it) into a public log. Always route the body through --body-file (or stdin); the body must never reach argv.

Then, as its own tool call, spelling the path literally:

rm -f .gaia/local/audit/issue-body-<something-unique>.md

The body-file is scratch, and this recipe is its only owner: nothing else reaps it, so a file left behind is permanent litter in the adopter's working tree. Delete it unconditionally, whether the create succeeded or failed. The body is fully reconstructible from step 5, so there is nothing worth keeping on a failed create, and the cleanup cannot mask that failure: gh's own output and exit status are what you report.

Two tool calls, not one. A PreToolUse hook returns a single allow/deny decision for an entire Bash invocation before any of it reaches the shell, so a hook that denies the cleanup drops the create standing beside it too: no issue filed, and no output naming the cause. Splitting them keeps a denied cleanup from costing you the filing. One consequence for how the second call is written: shell variables do not survive between tool calls, so spell the path literally rather than reusing $body_file. Either spelling of it works, relative or absolute, and the destructive-command guard whitelists this directory both ways.

Provenance line

Beside the dedup-key line, the issue body (or a waived finding's pull-request-body entry) carries a second HTML comment recording the branch the finding was surfaced from, byte-for-byte in this form:

<!-- gaia-debt-key: v1 class=holistic/unclassified path=app/services/foo.ts line=42 -->
<!-- gaia-debt-origin: branch=debt/1121-marker-sep mode=drain unit=1121 changed=1 head=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 -->

Both are HTML comments, so neither appears in the rendered issue. Fields are key=value pairs separated by single spaces, in the order above. The order is canonical for readability only: the pairs are self-describing, so a reader must not depend on position, and adding or removing a field breaks no reader.

There is no version prefix, ever. The dedup key carries one because it is an identity that must match across time. Provenance matches nothing, so a version would imply a versioned contract and invite the lockstep discipline this design exists to avoid.

The field table:

fieldvaluesurvives branch deletion
branchthe raw branch verbatim, or unknownyes
modeone of drain, plan, maintenance, adhoc, unknownyes
unitthe issue numbers or plan/spec id encoded in the branch name, or unknownyes
changed0, 1, or unknownyes
headthe reviewed HEAD sha, or unknownno

mode and unit describe the branch, not the filer. Both are derived from the branch name alone, by the convention table below, so they record the branch the filing resolved against, an explicit branch a caller supplies, the pull request head ref in continuous integration, or otherwise the checkout's own branch, rather than the work the session was doing. Concurrent work in one checkout inherits that branch's stamp: a session filing a finding from a checkout parked on someone else's debt/* branch is stamped with that branch's unit. Do not read either field as authorship.

Reserved characters. Two are percent-encoded in a value: >, because a git branch name may legally contain it and an unencoded one would terminate the HTML comment early and leak the remainder as visible text, and % itself, so the encoding is invertible and a reader can recover the exact branch name. % is encoded first, then >; that order is what makes the round trip exact. "Verbatim" above means the raw name after that reversible encoding, not a normalized or truncated one. This is the same reasoning gaia_key_slug applies in .gaia/scripts/audit-key-lib.sh, with a far smaller reserved set because this value is read by humans rather than used as a filename.

Why head is carried despite rotting. While the commit is reachable it makes a cited path:line resolvable with git show <sha>:<path>, a partial mitigation for the line drift that makes older keys stale. Everything else on the line is a stored conclusion rather than a coordinate, so it stays readable after the branch is gone.

The convention table. branch is normalized for matching only, the stored branch field always keeps the raw name: strip a single leading worktree-, then replace every + with / in what remains, both steps unconditional, in that order. The normalized name is matched against this table, first matching row wins:

#normalized branchmodeunit
1debt/<members>-batch, <members> matching ^[0-9]+(-[0-9]+)*$drain<members>
2debt/<rest>drainthe leading [0-9]+ of <rest>, else unknown
3spec-<nnn> or spec-<nnn>-<rest>, <nnn> matching ^[0-9]+$planSPEC-<nnn>
4plan-<nnn> or plan-<nnn>-<rest>, <nnn> matching ^[0-9]+$planplan-<nnn>
5chore/<rest> or chore-<rest>maintenance<rest>
6harden/<rest> or harden-<rest>maintenance<rest>
7wiki-sync/<rest>maintenance<rest>
8audit-<rest>maintenance<rest>
9anything else, including main, fix/<rest>, docs/<rest>, feat/<rest>adhocunknown

Any derived unit that comes out empty becomes unknown. Row 9 routes fix/, docs/, and feat/ to adhoc deliberately: those are hand-named human work with no unit encoded in the branch name. The table is keyed on prefix families rather than exact per-command branch names because a table of exact names matches almost no real branch: roughly twenty real chore/* and harden/* branches would fall through to adhoc otherwise. A new branch family that later deserves its own mode is a change to this file, not a new field and not a version bump.

When branch resolves to unknown (no explicit branch argument, no head-ref environment variable, and no current branch from git), mode and unit are also unknown, never adhoc. adhoc means a branch resolved and matched no row, a different fact from no branch resolving at all.

The derivation. One shared helper, .gaia/scripts/debt-origin-lib.sh, owns the encoding, the classification, and the line assembly. Each route calls it once per finding, in the spelling its own surface gives. Bare:

origin="$(bash .gaia/scripts/debt-origin-lib.sh --changed "<0|1|unknown>" 2>/dev/null || true)"

It fails open throughout: each field it cannot resolve becomes the literal unknown, it exits zero regardless, and a caller never treats its output as a precondition.

The fail-open rule, stated as a rule. Never block, fail, retry, or defer a filing or a waive because provenance is partial, absent, or malformed. If the helper prints nothing, omit the line and continue. Omitting the line is reserved for a route that predates provenance or for a helper that could not run: a working route must never omit the line as a way of expressing that nothing resolved, because a line of unknowns and no line at all must stay distinguishable.

One route cannot call it. In continuous integration the audit agent's tool policy grants no shell for this helper, so the audit workflow resolves provenance in a step of its own, ahead of the agent, and writes the finished lines to disk for the agent to read. The agent never re-derives them and carries no prose copy of the rules. One implementation, not two.

The changed field, precisely. It reports whether the cited path is in the pull request's fork-point changed-file set. Two nearer sets are explicitly wrong: not the filtered review scope (the frontend audit agent's own changed variable is pathspec-limited to TypeScript sources, so a finding on a non-TypeScript file the pull request touched would read 0), and not the incremental audit base (the last cleared ancestor, which on a re-audit covers only the delta since the previous round, while the touched-file waive rule anchors on the whole-PR fork point). A route that does not already hold a fork-point set records changed=unknown and derives nothing. When the fork point does not resolve, changed is unknown and never 0, because 0 asserts that the work did not touch the file and an unresolvable base asserts nothing.

The emitting routes:

routeinstruction surfacechanged
audit agent disposition pipeline, local.claude/agents/code-audit-frontend.mdresolved
audit agent disposition pipeline, continuous integration.github/workflows/code-review-audit.ymlresolved, by the workflow
pre-merge orchestrator cross-remit dispositionwiki/concepts/PR Merge Workflow.mdresolved
knowledge-audit filing block.claude/skills/gaia/references/audit.mdunknown
comprehensive-audit filing offer and direct human invocationthis fileunknown

Known limitation: the routes with a reviewed diff run on the branch under review, so their branch, mode, and unit track that work. The routes with no reviewed diff run wherever the session happened to sit, so on those rows the fields are the disposing agent's checkout and nothing more.

What the record does not answer. It supports attribution, not causation. It says which branch a finding was surfaced from; it does not say the work on that branch caused the defect, and for a pre-existing defect found during a visit it usually did not. Overreading it is the failure mode to avoid.

Waived findings. A finding recorded as waived rather than filed carries the same line, from the same helper, on its pull-request-body entry beside the dedup key already listed there. That entry is the waived finding's only durable surface: the disposition sidecar is gitignored, janitor-reaped, and dropped on the next digest rotation. The line is an HTML comment, so review-time visibility is unchanged. Note what this does not buy: changed does not separate the machinery waive from the touched-file waive, because a pull request fixing gate machinery is normally touching the machinery path it waives, so both arms usually read changed=1.

Ownership. This file is the contract's sole owner. Every other route references it and restates neither the vocabulary nor the table. .gaia/scripts/debt-origin-lib.sh is the implementation of the contract rather than a second statement of it.

5. Issue body schema

Build a self-contained issue body with these parts, in order:

  • The dedup-key comment line from step 1, present verbatim.
  • The provenance line (see "Provenance line" above), present verbatim, on its own line immediately after the dedup-key line and never merged into it.
  • The file:line location. The cited line must resolve to a real line in the named file, don't cite a location you haven't confirmed.
  • A concrete, non-empty description of the failure mode: what input or state triggers it, and what the bad outcome is. "Could be cleaner" is not a failure mode; "a null userId reaches this branch and throws" is.
  • A suggested fix.

The body carries no classification fields of its own. Every classification axis steps 6 and 7 define rides as a label, so a body line restating one of them is a second representation of a value the labels already hold, and the two drift.

6. Labels

Every out-of-scope non-security issue this recipe files carries tech-debt plus exactly one severity label, plus exactly one handler label; a filing that carries a difficulty grade (see step 7) carries exactly one difficulty label as well. Map the finding's report tier to the severity label like this:

Report tierLabel
Criticalseverity:critical
Importantseverity:important
Suggestionseverity:suggestion

Maintainer repository only. Every filing on the GAIA maintainer repository carries exactly one surface: label as well. It records who can observe the defect, which is a different question from how bad it is and from how hard it is to fix:

LabelThe defect is
surface:adopterobservable by an adopter: something GAIA ships misbehaves, misleads, or blocks them.
surface:maintainerobservable only in the GAIA maintainer repository: continuous integration, release-excluded tests, maintainer-only tooling.

Resolve it from the cited path first: a release-excluded path is surface:maintainer, a shipped path is surface:adopter. Then override that default when the failure mode contradicts it, because the two do come apart. A defect in a shipped file that is only reachable through a maintainer-only runner is surface:maintainer even though the file ships, and a maintainer-only script whose wrong output is copied into an adopter-facing artifact is surface:adopter even though the script does not. The path is the prior, the failure mode is the verdict.

Unlike severity, this label has no fallback: an unlabeled issue is not sorted into a default band, it is simply unfiled against the split. Exactly one is required on every filing.

The handler label records how far the fix reaches, which decides how the eventual drain approaches it:

LabelThe fix is
handler:prompta single logical unit confined to one file, with no public-contract change and no cross-module ripple.
handler:plananything larger or more structural.
handler:specdesign-first: it must begin with a design SPEC, a new subsystem, a schema or contract decision, or a cross-cutting redesign. /gaia-debt resolves a spec-class issue by printing a /gaia-spec handoff and stopping, not by opening a fix PR.

The three share one color family, a violet ramp that deepens with reach. wiki/concepts/GitHub Labels.md documents the family, and gaia labels sync applies it.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
23
Forks
4
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
file-tech-debt
Source
github.com/gaia-react/gaia