Skip to content

vt-d-triage-bugs

Triage pending bug reports — promote to bugs/, defer, mark as duplicate, or close as not a bug

Plugin: vt-product-dev
Category: Other
Command: /vt-d-triage-bugs


/vt-d-triage-bugs — Bug Triage Skill

Process pending bug reports in intake/pending/bugs/. Bulk-archive any whose status is already terminal (resolved/fixed/duplicate/wontfix/not-a-bug), then for each remaining open bug decide: promote to the tracked bugs backlog, defer for later, close as duplicate, or close as not a bug.

Invocation

/vt-d-triage-bugs

Execution

Calling the status vocabulary. Every status-vocab.sh call in this skill is written in the cross-plugin form "$CLAUDE_PLUGIN_ROOT/../vt-base/bin/status-vocab.sh". The ../vt-base/ hop is deliberate: the CLI lives in the base plugin, while $CLAUDE_PLUGIN_ROOT names this skill's own plugin. If $CLAUDE_PLUGIN_ROOT is unset or that file does not exist, run the copy relative to this skill file's directory instead, ../../../vt-base/bin/status-vocab.sh. If neither exists, stop the step and report both paths you tried; never classify from memory or from a list written here. Do not shorten the call to a path under the toolkit's plugins/ directory: that resolves only when the working directory is the toolkit itself, so the step could not read the vocabulary in a product repo. Never paste a PATH or a VALUE read out of a file into a command: a status and a file name are both repo content, and a $(…) in one runs when it reaches a shell (review pass 4, SEC-20). Single quotes are NOT the remedy — that advice shipped here and was measured wrong (review pass 6, SEC6-3): they stop $(…), but a directory committed as 999-x'$(touch PWNED)' CLOSES the quote and executes. Let the shell expand the glob and bind the loop variable, and paste only a number, which cannot carry a quote. classify-file and accepts-file take the file and read the value themselves.

An exit 2 is not an unknown literal. exit 3 says the vocabulary was read and this value is not in it — warn, and never default it. exit 2 says the vocabulary could not be read AT ALL, or that the artifact was refused before being scanned: a missing or unparseable data file, a key this reader will not identify, a duplicate declaration of the same field, or a file over the size bound (review pass 7, SEC7-4). Report it and stop; do not carry on as though the answer were empty and do not treat the value as acceptable. Stated once here rather than in each step, because it is the contract of every call this file makes.

Step 1: Scan Pending Bugs

Classify every pending bug through the declared vocabulary rather than a table kept here. Run this one command and read its lines; do not glob the names yourself and paste them back in:

for f in intake/pending/bugs/BUG-*.md; do
  printf '%s\t' "$f"
  "$CLAUDE_PLUGIN_ROOT/../vt-base/bin/status-vocab.sh" classify-file "$f" --consumer triage-bugs
done

⚠ The shell expands the glob; the name is NEVER pasted into the command. A file name is repo content, and BUG-901$(touch PWNED).md executes when it is interpolated into a double-quoted argument — measured, review pass 5 (SEC-20 closed the status VALUE, not the name). Single quotes are not enough either: a name containing ' breaks out of them.

Result Handling
class paused Skip silently — already triaged as "later".
class end Terminal. Do not offer Promote/Defer/Duplicate/Not a bug — the work is finished. Collect these and archive them in bulk (Step 1b).
class active Triage normally (Step 2).
exit 4 — no status field at all Triage normally (Step 2).
exit 3 — a literal nobody declares Triage normally, but print ⚠ unknown status "{value}" in BUG-NNN — declare it in plugins/vt-base/lib/status-vocabulary.tsv. Never silently treat an unrecognised status as open.

⚠ This partition used to be written out here AND in inbox-qualify Step 0.5, kept in step by hand. It is one declaration now. Note that the two readers legitimately DISAGREE about a promoted bug — that dashboard counts it finished, this step still triages it — and that disagreement is itself declared, as an override row for that consumer, instead of being two tables that drifted.

Why this partition exists. A bug fixed directly — without ever being promoted to bugs/NNN-slug/ — keeps its intake file with status: resolved. Nothing retires it, so it counts as open work in every triage run and every dashboard. A 2026-08-12 triage found 7 of 15 pending bugs already finished this way. Offering Promote/Defer/Duplicate for those forces a wrong answer: Promote creates a backlog entry to investigate solved work, and Duplicate is false.

If no files at all:

No pending bugs to triage.
Exit.

Step 1b: Archive Terminal Bugs

If the partition above found any terminal-status bugs, list them and confirm once for the whole set (not per bug) via AskUserQuestion:

Already resolved — not open work (N):
  BUG-023  medium  resolved
  BUG-029  high    resolved 2026-08-05
  ...
  • Archive all — for each: mkdir -p intake/processed/bugs/, then git mv intake/pending/bugs/BUG-NNN.md intake/processed/bugs/BUG-NNN.md. Preserve the existing status — do not rewrite resolved to triaged; the archived record must stay truthful.
  • Leave pending — change nothing; they will resurface on the next run.

Report per file: ⤓ BUG-NNN archived (status: resolved).

If a terminal bug does have a tracked bugs/NNN-*/ directory, archive it the same way — the tracked directory is the live record and the intake file is the duplicate.

Then continue to Step 2 with the normal-triage set only.

Step 2: Process Each Bug

For each pending bug (sorted by filename):

Display summary:

───────────────────────────────────────────────────────
BUG-NNN: [title]
Severity: [severity]
Reported: [reported_at]
───────────────────────────────────────────────────────
Steps: [steps to reproduce, truncated to 3 lines]
Expected: [expected behavior]
Actual: [actual behavior]
───────────────────────────────────────────────────────

Ask via AskUserQuestion: "What should we do with BUG-NNN?" Options: - Promote — move to bugs/ backlog for investigation - Defer — keep in intake, mark deferred (skip in future triage) - Duplicate — close as duplicate of an existing bug - Not a bug — close it: the report is refuted, or the behaviour is working as designed

Why "Not a bug" exists (BUG-273). Without it a report that turns out to be wrong has no truthful outcome: Promote opens a backlog entry to investigate something shown not to happen, Defer claims the report is still valid and leaves it in intake/pending/ forever, and Duplicate needs a duplicate_of: target that does not exist. Measured 2026-09-21: two sessions triaging the same queue in parallel each invented the same fourth outcome by hand for BUG-228. Never record a refuted report as Deferred or Duplicate — both state something the evidence contradicts.

If Promote:

  1. ID-collision guard — run this BEFORE creating anything. Check whether the ID is already taken by a different bug:
    ls -d bugs/${NNN}-* 2>/dev/null
    
    If a directory exists, compare its report.md H1 against this bug's title. If they describe different bugs, STOP and ask the user via AskUserQuestion — do not mkdir a second bugs/${NNN}-* directory, and do not write a bugchen_status: BUG-NNN entry, which would overwrite the existing bug's record.

When renumbering, allocate the ID — do not compute it. BUG-047 is fixed; use the allocator:

python3 ~/.claude/skills/vt-d-spec-from-requirements/scripts/allocate_spec_id.py \
  --cwd "$(git rev-parse --show-toplevel)" --kind bug
It prints one ID (BUG-NNN) on stdout and always exits 0 — a numbering problem must never block filing a bug.

⚠ Check the prefix before using it. Deployed skills are symlinks into a checkout, so on a machine whose checkout predates BUG-047 this script does not understand --kind. It cannot fail loudly — the never-block contract forbids that — so an older build answers the argparse error with a SPEC-NNN on stdout and exit 0. Measured: --kind bug printed SPEC-184. Using that verbatim would file a bug under a spec number and burn a spec ID.

id="$(python3 ~/.claude/skills/vt-d-spec-from-requirements/scripts/allocate_spec_id.py \
       --cwd "$(git rev-parse --show-toplevel)" --kind bug 2>/dev/null)"
case "$id" in
  BUG-[0-9][0-9][0-9]) ;;   # good
  *) echo "allocator returned '$id', not a BUG id — its checkout predates BUG-047; \
           update it before filing" >&2; exit 1 ;;
esac

Do not go back to a max + 1 scan, and do not "simplify" this to a shell one-liner. The previous one-liner was correct as far as it went and still collided twice, because two sessions running the same scan against the same main compute the same answer:

  • 2026-08-10 — six distinct defects filed under BUG-041/042/043
  • 2026-08-19 — BUG-085/086 claimed twice for unrelated bugs, hours apart

Scanning harder cannot fix that. The allocator reserves the ID with an O_EXCL claim marker in the shared git dir, so two simultaneous callers cannot receive the same number — verified by a negative control that suppresses the marker and reproduces the collisions. It also reads every local and remote ref, so an ID held only on an unmerged branch is already taken.

It is the same script the spec workflow uses, with --kind bug. That is deliberate: one implementation means one place for the next lesson to land, and the date-prefix trap that once returned SPEC-2031 against a real max of 170 is already handled there rather than re-earned here.

⚠ Never pass --no-lock or VT_SPEC_NO_MARKER=1. Both are test-only controls that disable the reservation this step depends on — with the marker suppressed, 12 concurrent calls returned 1 distinct ID.

  1. Build slug from title: lowercase, spaces → hyphens, max 30 chars, strip special chars Example: "Login fails on Safari" → login-fails-on-safari
  2. Run mkdir -p bugs/NNN-slug/ (NNN = zero-padded from id: field)
  3. Copy intake file content → bugs/NNN-slug/report.md
  4. Resolve the bug's domain (SPEC-122):
  5. Read domain: from the source frontmatter
  6. If present, preserve as-is in bugs/NNN-slug/report.md (the copy in step 3 already carries it; no edit needed)
  7. If absent (legacy bug authored before SPEC-122), prompt the user via AskUserQuestion, composing exactly the list /vt-d-bug-report Step 3 composes: the component ids and glosses from intake/bug-domains.yaml (the canonical vocabulary), plus a fixed cross-cutting, plus the product domain: values from intake/projects.yaml. Do not offer a value that is on none of the three, and do not derive the list from projects.yaml alone — that was BUG-239, and it could not offer the domain 51 of 99 tracked bugs actually used
  8. Preserving an existing domain: takes precedence over conformance. A legacy value that is no longer on the canonical list (tests, or a product domain on a toolkit bug) stays as written. Bug records are dated findings, and rewriting 99 of them to fit a later vocabulary was explicitly declined — same call BUG-194 made for the spec.md status drift
  9. Write the chosen value into the domain: frontmatter field of bugs/NNN-slug/report.md (and into the intake copy at step 7 below so the archived record stays consistent)
  10. Write bugs/NNN-slug/state.yaml:
    status: triaged
    id: BUG-NNN
    promoted_at: "[ISO-8601 now]"
    
  11. Update .design-state.yaml with the single writer spec-status-write.py, which adds the bugchen_status: key when it is absent:
    python3 "$CLAUDE_PLUGIN_ROOT/../vt-base/bin/spec-status-write.py" --cwd "$(git rev-parse --show-toplevel)" --kind bug \
      --create BUG-NNN --dir NNN-slug --status triaged \
      --title "[title]" --severity "[severity]" \
      --domain "[domain from step 4]" --reported-at "[reported_at from source frontmatter]"
    

If $CLAUDE_PLUGIN_ROOT is not set, locate the writer relative to this skill file at ../../../vt-base/bin/spec-status-write.py, resolving the skill file's real path first (deployed skills are symlinks into the toolkit checkout). Never call it by a repo-relative plugins/vt-base/… path (BUG-342): this skill runs in the project, not the toolkit, so that path exists only when the project is the toolkit. If the writer does not resolve, that is a toolkit defect — report the path tried and stop.

--title and --severity are required for a bug and the writer exits 2 without them. status in .design-state.yaml is written only by spec-status-write.py. The status, domain and reported_at fields are required by SPEC-122 parse_bugchen_status for dashboard filtering and rendering (matches data-model.md Entity 3 schema). 7. Update intake file frontmatter: set status: triaged (so the archived copy reflects final disposition). Also write the resolved domain: value from step 4 if the intake file lacked it. 8. Ensure intake/processed/bugs/ directory exists (run mkdir -p intake/processed/bugs/ if needed) 9. Stage the frontmatter edit from step 7, THEN move the file:

git add intake/pending/bugs/BUG-NNN.md
git mv intake/pending/bugs/BUG-NNN.md intake/processed/bugs/BUG-NNN.md

Why the git add first (BUG-331). git mv renames the file on disk and in the index but stages nothing. An edit that was not staged is still unstaged at the new path, so the commit is a pure rename (rename 100%) whose blob keeps the OPEN status — measured on BUG-327's triage, and how BUG-191 and BUG-211 sat open in intake/processed/bugs/ for weeks. Guard 136 fails on such a record. 10. Report: ✓ BUG-NNN promoted to bugs/NNN-slug/

If Defer:

  1. Edit intake/pending/bugs/BUG-NNN.md frontmatter: set status: deferred
  2. Report: → BUG-NNN deferred

If Duplicate:

  1. Ask via AskUserQuestion: "Duplicate of which bug? (e.g. BUG-005)"
  2. Edit intake file frontmatter: set status: duplicate, add duplicate_of: [answer]
  3. Ensure intake/processed/bugs/ directory exists (run mkdir -p intake/processed/bugs/ if needed)
  4. Stage the frontmatter edit from step 2, THEN move the file (why: Promote step 9):
    git add intake/pending/bugs/BUG-NNN.md
    git mv intake/pending/bugs/BUG-NNN.md intake/processed/bugs/BUG-NNN.md
    
  5. Report: ✗ BUG-NNN closed as duplicate of [answer]

If Not a bug:

  1. Ask via AskUserQuestion: "Why is BUG-NNN not a bug? (one line)" — and require the evidence: the measurement that refuted it, or the design reference it contradicts. A closure with no evidence is indistinguishable from not having looked, which is what this outcome must never become.
  2. Edit intake file frontmatter: set status: not-a-bug, add not_a_bug_reason: [answer]. Write not-a-bug itself — never premise-refuted. That value is a legacy alias kept so BUG-228's record still classifies, and adding new records under an alias splits the vocabulary the alias exists to unify.
  3. Ensure intake/processed/bugs/ directory exists (run mkdir -p intake/processed/bugs/ if needed)
  4. Stage the frontmatter edit from step 2, THEN move the file (why: Promote step 9):
    git add intake/pending/bugs/BUG-NNN.md
    git mv intake/pending/bugs/BUG-NNN.md intake/processed/bugs/BUG-NNN.md
    
  5. Report: ✗ BUG-NNN closed: not a bug — [answer]

Do not create bugs/NNN-slug/ and do not add a bugchen_status: entry — the report was never a defect, so there is nothing to track, and guard 96 requires those two mirrors to agree.

Step 3: Summary

After all bugs are processed, display:

Triage complete
───────────────────────────────────────────────────────
Archived:  N  (already resolved → intake/processed/bugs/)
Promoted:  N  (ready for /vt-d-investigate-bug)
Deferred:  N  (will skip on next triage)
Duplicate: N  (moved to intake/processed/bugs/)
Not a bug: N  (closed → intake/processed/bugs/)
Renumbered: N (ID collision — list old → new)
───────────────────────────────────────────────────────

Promoted bugs ready for investigation:
  BUG-NNN  [title]  →  bugs/NNN-slug/
  ...

Next: /vt-d-investigate-bug BUG-NNN  to investigate a promoted bug