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¶
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 withstatus: 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:
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:
- Archive all — for each:
mkdir -p intake/processed/bugs/, thengit mv intake/pending/bugs/BUG-NNN.md intake/processed/bugs/BUG-NNN.md. Preserve the existing status — do not rewriteresolvedtotriaged; 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 aduplicate_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:¶
- ID-collision guard — run this BEFORE creating anything. Check whether the ID is already
taken by a different bug:
If a directory exists, compare its
report.mdH1 against this bug's title. If they describe different bugs, STOP and ask the user via AskUserQuestion — do notmkdira secondbugs/${NNN}-*directory, and do not write abugchen_status: BUG-NNNentry, 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
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.
- Build slug from title: lowercase, spaces → hyphens, max 30 chars, strip special chars
Example: "Login fails on Safari" →
login-fails-on-safari - Run
mkdir -p bugs/NNN-slug/(NNN = zero-padded fromid:field) - Copy intake file content →
bugs/NNN-slug/report.md - Resolve the bug's domain (SPEC-122):
- Read
domain:from the source frontmatter - If present, preserve as-is in
bugs/NNN-slug/report.md(the copy in step 3 already carries it; no edit needed) - If absent (legacy bug authored before SPEC-122), prompt the user via
AskUserQuestion, composing exactly the list
/vt-d-bug-reportStep 3 composes: the component ids and glosses fromintake/bug-domains.yaml(the canonical vocabulary), plus a fixedcross-cutting, plus the productdomain:values fromintake/projects.yaml. Do not offer a value that is on none of the three, and do not derive the list fromprojects.yamlalone — that was BUG-239, and it could not offer the domain 51 of 99 tracked bugs actually used - 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 - Write the chosen value into the
domain:frontmatter field ofbugs/NNN-slug/report.md(and into the intake copy at step 7 below so the archived record stays consistent) - Write
bugs/NNN-slug/state.yaml: - Update
.design-state.yamlwith the single writerspec-status-write.py, which adds thebugchen_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 addfirst (BUG-331).git mvrenames 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 inintake/processed/bugs/for weeks. Guard 136 fails on such a record. 10. Report:✓ BUG-NNN promoted to bugs/NNN-slug/
If Defer:¶
- Edit
intake/pending/bugs/BUG-NNN.mdfrontmatter: setstatus: deferred - Report:
→ BUG-NNN deferred
If Duplicate:¶
- Ask via AskUserQuestion: "Duplicate of which bug? (e.g. BUG-005)"
- Edit intake file frontmatter: set
status: duplicate, addduplicate_of: [answer] - Ensure
intake/processed/bugs/directory exists (runmkdir -p intake/processed/bugs/if needed) - Stage the frontmatter edit from step 2, THEN move the file (why: Promote step 9):
- Report:
✗ BUG-NNN closed as duplicate of [answer]
If Not a bug:¶
- 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.
- Edit intake file frontmatter: set
status: not-a-bug, addnot_a_bug_reason: [answer]. Writenot-a-bugitself — neverpremise-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. - Ensure
intake/processed/bugs/directory exists (runmkdir -p intake/processed/bugs/if needed) - Stage the frontmatter edit from step 2, THEN move the file (why: Promote step 9):
- 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