Triage frontend ticket
Input: one Linear ticket URL or identifier (e.g. <ISSUE-ID>). If none was given, ask with AskUserQuestion and stop until given.
Repo: the lago-front checkout (front/ in the lago monorepo). If the session is not in lago-front, STOP — this skill reasons about this codebase only.
Output: one comment posted on the ticket, and — only for a ticket sitting in Triage
that the analysis actually resolved — a move to Backlog. Nothing else on Linear is touched.
Who reads that comment: whoever picks the ticket up next — a human deciding whether to schedule it, a developer opening the editor, or an agent implementing it. Assume none of them will redo the analysis. So the comment is a handoff document: everything needed to start work is in it, in a form that reads as well to a person as it parses to an agent. That is what step 6's Implementation handoff block is for.
This skill is deliberately pipeline-agnostic. It ends at the posted comment and makes no
assumption about how the work gets done afterwards — by hand, by an agent, or by an
automated pipeline. If your team uses one that reads ticket comments (/loop-run does,
through loop-spec), the handoff block is already in the shape it needs; if not, it is
simply a well-specified ticket.
Read LEARNINGS.md (alongside this file) before you judge anything. It holds rules learned
from real outcomes and overrides the defaults here where they conflict — within the limits stated
in that file: a learning may tune judgement, never soften a guardrail.
Create a todo per numbered step below before starting.
1. Fetch, gate, classify
Extract the issue id ([A-Z]+-\d+), then reset the review budget so a re-triage starts clean:
front/scripts/iter-budget.sh <ISSUE-ID> reset triage-review
front/scripts/analysis-depth.sh <ISSUE-ID> reset
Fetch via Linear MCP get_issue (with includeRelations) and list_comments — repro steps, scope changes and decisions often live only in comments, and a Slack attachment subtitle often holds the original report.
Then enumerate the attachments and say what you could and could not read. A Loom or a screenshot is frequently the only place the actual gesture exists. You cannot watch a video: state that, ask the operator to describe what happens in it, and never replace it with a plausible reconstruction — a repro you invented is how an analysis ends up explaining a defect nobody reported.
Check every gate in order. Exactly one of them posts anything; the rest stop silently. Do not improvise a third outcome.
- Not analyzable → post the Needs info comment (step 6) with the footer (step 8), then stop: no independent review (there is no finding to re-derive) and no status move (the ticket is still waiting on the reporter).
- Every other failure → STOP, report to the operator, post nothing.
| Gate | Check | On failure |
|---|---|---|
| Already triaged | Any comment body contains <!-- triage-frontend-ticket: or opens with a ## Technical analysis heading (a hand-written or pre-footer analysis counts) |
Footer present → recompute both fingerprints (step 8). Both unchanged → STOP, report "already triaged, nothing new". Either changed → re-triage, and open the comment by saying which one moved. Footer absent → STOP and report that a human analysis already exists. |
| Not already in flight | The status is a pre-dev state (Triage, Backlog, Next to scope, Ready for dev) and the ticket carries no claude-* label and no linked PR |
Started, completed or cancelled (Dev in progress, In Review, Under QA, Done, …) → STOP: somebody or something is already on it, and an analysis arriving now is noise on work in progress. Scoping → STOP, a human is actively scoping it. A claude-* label or an attached PR → STOP, the org pipeline already picked it up. |
| Frontend | The responsible logic actually lives in lago-front. Do not decide this from the Front-end label — whoever files a ticket often cannot tell which layer owns the behaviour, and a UI symptom is regularly an API bug. Check the described behaviour against the code before accepting it |
Logic lives in the API → STOP, say so, and name what you found so the ticket can be re-routed. Genuinely split across both → STOP and ask the operator which half to scope. |
| Analyzable | Enough to locate the code: a named surface plus either a symptom (defect) or a desired outcome (change request) | Too vague → post the Needs info variant (step 6), then stop. Never guess a repro or invent requirements. |
| Not a duplicate | Search open tickets for the same problem — same Sentry issue, same error, same surface plus same symptom, or shared distinctive identifiers. Use Linear list_issues with a query, restricted to open states |
Clearly the same problem → STOP, report possible duplicate of <ID>, post nothing. Two analyses of one bug is worse than none, and merging them is a human call. Superficial keyword overlap is not a duplicate — confirm it is genuinely the same problem before stopping. |
Then classify the ticket, because it decides what the analysis has to prove:
| Type | Signals | The question to answer |
|---|---|---|
| Defect | Bug label; "wrong", "broken", "shows X instead of Y"; a repro |
Is the reported behavior real, and why does it happen? |
| Change request | Improvement / Feature label; "should also", "add", "allow", "rename", "instead of" |
What exists today, what has to change, and what stands in the way? |
Mixed ticket ("it's wrong, and while we're there make it configurable") → treat the defect as primary, and cover the request under Scope beyond the defect. When the label and the wording disagree, the wording wins; say which you picked.
Is there anything to analyse at all?
Some tickets already name what to change and where — "change this wording to that". An analysis would only restate them. Record that and stop:
front/scripts/analysis-depth.sh <ISSUE-ID> set skip
Post a one-line comment saying it is already actionable, or nothing at all, and finish.
Everything else gets the analysis. How deep it goes is not decided here — you cannot know what a change touches before reading the code. That call comes at step 5, once you do.
2. Ground it in the project's own rules, then read the code
The repo documents itself, and skipping that is how an analysis ends up correct about the symptom and wrong about the fix.
2a. Check the symptom against known regression classes
CLAUDE.md and the lago-* skills it points to are not only style — they record defect
classes together with the symptom they produce. The table below turns a reported symptom into the first place to look. Nothing
in it is a conclusion: a row that matches is a hypothesis, and the analysis stands or falls
on confirming it at the actual call site.
| Reported symptom | Documented cause |
|---|---|
| A list loads page 1 then stops; page 2 is empty | Field not registered in queryFieldPolicies with createSinglePageFieldPolicy() |
| Another org's data, logo flashing the wrong org, a webhook URL baking the wrong UUID, a value from another tab | A feature component reading currentOrganizationVar instead of useParams + memberships — the lago-organization-slug skill names this a known bug pattern |
| Route matching never fires | useMatch from react-router - the raw pathname includes the slug. Use matchPath + strippedPathname |
| The "X-Y of N" label disagrees with the rows | PaginatedContent pageSize ≠ the query limit |
| A previously-viewed page flashes when re-entering a customer tab | List query missing fetchPolicy: 'network-only' |
| The pager is missing entirely | metadata not passed to PaginatedContent, so totalCount is 0 |
| A menu / popper / tooltip "never opens", or opens in the wrong place | the element is usually in the DOM but positioned out of view: offsets computed from viewport rects while the element is absolutely positioned inside a scrolled position: relative container (the offset needs scrollTop/scrollLeft), clipping, or z-index. Reproduce with the container scrolled — at scroll 0 the broken math looks correct |
How to use a match: open the call site and check whether the documented cause is actually present there. Confirmed → cite the rule and take the prescribed fix rather than inventing one. Not present → the row was a wrong guess; drop it and keep reading the code. The symptom in a report is a description of what someone saw, and several causes produce the same description.
A row that matches never enters the comment as the cause on its own. It has to survive step 3 like any other claim: the evidence is what makes it a finding, and "the symptom matches a documented pattern" is not evidence.
No match means nothing at all — most defects are not in this table.
2b. Read what binds this area
CLAUDE.mdat the root — always. It is prescriptive, not advisory.- The subsystem skills
CLAUDE.mdpoints to, whenever the area is one of theirs:lago-pagination,lago-drawers,lago-dialogs,lago-organization-slug. They carry the full rules those sections used to hold inline. - The on-demand docs in
.agents/docs/for the area involved:folder-architecture,graphql-fragments,testing-practices,documentation. (typescript-conventionsis already loaded in every session.) - The nearest-neighbour code. The closest existing feature doing the same thing is the pattern of record. Read it before proposing a shape.
Two things this changes about the fix, not just the analysis:
- The fix must be the sanctioned pattern, not merely a working one. A new drawer is the
use<Feature>Drawerhook, never aforwardRef+DrawerRef; a dialog is one of the three hooks; a new translation key comes frompnpm translations:add, never hand-written. - A legacy pattern next door is not permission to copy it. The
lago-drawersskill says this outright, and it holds generally: three generations coexist and only one is allowed in new code. Cite the reference site the docs name, so whoever implements it copies from the right place.
For a change request, conventions are part of the answer, not a footnote: a request that fights a documented convention is a caveat at minimum, and sometimes the reason the honest verdict is feasible with caveats rather than feasible as asked.
2c. Then follow the chain
Generated files are read with a scalpel. src/generated/graphql.tsx holds single lines
tens of thousands of characters long — one permissions blob repeated per operation. A bare grep
for a common word there returns those whole lines and burns a large share of the run's budget
for nothing. Match the shape you want and print only that:
# the argument list of one query, not every line mentioning it
grep -n "QueryMembershipsArgs" -A 6 src/generated/graphql.tsx
# a field's declared type, without the surrounding blob
grep -o "issuingDate[A-Za-z]*?*: [A-Za-z'\[\]]*" src/generated/graphql.tsx | sort -u
The same applies to lockfiles, snapshots and translation bundles.
Read the actual code. Grep for the feature, the filter key, the component, the translation key — whatever the ticket names — and follow the chain end to end. Name real symbols and file.ts:line paths.
For a defect, the two ends are usually in different files and the bug is the disagreement between them:
- where the value is produced or stored (form handler, URL param writer, serializer, GraphQL variable builder)
- where it is displayed (formatter, label builder, cell renderer)
A default argument on the display side (fallback timezone, locale, currency, empty state) is the single most common root cause: producer and consumer each look correct in isolation.
For a change request, map instead:
- the current behavior, precisely — this is the baseline the request is measured against, and reporters are often wrong about it
- every call site that would have to change, not just the one the ticket names
- what constrains the change: GraphQL schema (does the API already return this field?), design-system components available,
CLAUDE.mdconventions that dictate the shape, translations, migrations - whether it is already possible by another route — some requests are answered by pointing at an existing feature
An analysis that describes behavior without citing code is not done.
3. Ground it in something re-runnable
A conclusion with no evidence beyond prose is not a conclusion. Produce at least one artifact a reader can re-run or point at:
- a
node -eone-liner through the real library (luxon, intl, currency helpers) showing input → actual vs expected - an existing test whose expectations reveal the assumption (quote path + line)
- a scoped
pnpm test <path>run — never the full suite - for a change request: the GraphQL schema field (or its absence) in
src/generated/graphql.tsx, the existing component that already does the thing, the full grep of call sites to be touched - for visual/layout claims, the concrete DOM/class/style that produces it
Build the input from what the code actually stores, not from the ticket's screenshot. Reconstruct that value from step 2 first.
3a. For interaction, layout and rendering symptoms, a harness is not a reproduction
A jest harness or a node -e snippet proves a mechanism exists. It cannot prove that mechanism is what
the reporter hit — jsdom has no layout, no scroll and no paint, which is exactly where this class of defect
lives. When the symptom is "the menu doesn't open", "nothing happens when I click", "it's in the wrong
place", the evidence has to come from the running app on the reported surface (the /qa-session skill
resolves the worktree and port). Until then every code-derived cause is a hypothesis, however well it reads.
3b. Precondition check — does the reporter's repro actually trigger your cause?
The single check that catches a plausible-but-wrong analysis. For each cause you propose, write down:
| Cause | |
| Exact precondition | <the state or gesture required to trigger it, in code terms> |
| Reporter's steps satisfy it? | yes, because … / no, because … |
A cause whose precondition is narrower than the report explains less than it looks like it does — say so instead of letting the reader assume it covers the ticket. If no cause is triggered by the reported steps, the real cause is still unfound: the verdict is cannot determine on the reported symptom, plus whatever adjacent defects you did confirm, listed as adjacent. Never present them as the answer.
Worked failure this rule exists to prevent: a report said "paste markdown, the block menu never opens". The
analysis found two genuine defects — a stale captured position in a widget, and an unwrapped <hr> — and
both were real. But the first needs an edit that leaves doc.childCount unchanged and the second only ever
affected dividers, while a plain multi-block paste rebuilds every decoration and works fine on unfixed code.
The reported symptom was a third defect, in a file the analysis had explicitly fenced off as correct.
4. Verdict
A ticket reporting several symptoms gets a verdict per symptom. They routinely have different causes and different answers — one fixable today, another blocked on an API change. Collapsing them blocks work that could ship, or promises work that cannot. Decompose the report into its separate claims and judge each on its own; a combined "partly X, partly Y" headline is fine, the body has to keep them apart.
Defect — pick one:
- confirmed — real, with the mechanism
- not a bug — plus the reason the reporter saw what they saw (a stale cache, a different org setting, correct-but-confusing UX)
- cannot determine — plus what is missing
Change request — pick one:
- feasible as asked — the change is contained; say what it touches
- feasible with caveats — name each caveat: a schema change needed API-side, a convention that forces a different shape, a migration, a design decision left open
- blocked — name the specific blocker and who unblocks it (API field missing, product decision, dependency)
5. Blast radius / scope
Whatever mechanism is involved, grep for every other call site sharing it. Split into affected and unaffected, and say why each unaffected one is safe.
When the mechanism is a shared key or identifier — a URL param, a localStorage key, a constant, a cache key, an enum value — enumerate both directions: every consumer that reads it, and every producer that writes it, deep links built in unrelated pages included. Grep the exported constant, then the bare literal too in case somewhere hardcodes it.
Changing the shape of a shared value while missing one producer is a silent break: the writer keeps emitting the old shape, the reader matches nothing, and no test fails because the two live in different files. A fix list that changes a key's format without naming every writer is incomplete.
A file may be declared out of scope only after the symptom has been reproduced, and only for code proven unrelated to that reproduction. "This guard is correct" is a claim about one line, not a licence to stop reading the file — the defect is regularly a few lines below the part you verified, and a non-goal written early becomes a blindfold the implementer inherits.
This is where triage earns its keep — the reporter sees one surface, the code usually has more. Report affected siblings even when out of the ticket's scope. For a change request the same sweep answers "where else does this have to change to stay consistent", which is usually the real cost of the ticket.
Now price the review
You know what the change touches, so this is the moment to decide whether it needs an independent review. That review is the expensive part of this skill: spend it where being wrong would go unnoticed, not on everything.
front/scripts/analysis-depth.sh <ISSUE-ID> set <shallow|full>
full — the review runs. Any one of:
- the fix reaches more than one area of the code
- a shared key or identifier, or a derived value
- pagination, cache, timezone or currency semantics
- the failure mode is silent — a wrong number, a filter matching nothing, a link that quietly stops working
shallow — the review is skipped. The change is contained to one area, nobody else reads or
writes the value, and being wrong is obvious to whoever opens the page. CLAUDE.md and the
conventions are check enough at this size.
Do not count files. A source file and its test are one unit of work, not two — counting them
would put everything in full and make shallow unreachable, which is not the intent. Count
areas: a component plus its own test is one; a component, a hook and a cache policy is three.
The test is the failure mode, not the size of the diff. One line changing how an amount is
formatted is full — trivial diff, invisible when wrong, and the review is what catches it. A
twenty-line layout rewrite can be shallow — wrong is obvious at a glance.
Uncertain between the two → full.
Escalation is one-way and enforced. analysis-depth.sh refuses to move back down: shallow
is the tier that skips the review, and the moment to be tempted into skipping it is exactly when
the work turns out bigger than hoped. If anything surfaces while drafting — a second area to
touch, a value read somewhere else, a way to be wrong unnoticed — escalate and take the review.
6. Draft the comment
Markdown, English, these sections in order. Omit a section only when it would be empty.
Defect:
## Technical analysis — <confirmed bug | not a bug | cannot determine>
### Reported repro
<the reporter's steps, and how they were reproduced: running app on <surface> | harness only | not reproduced
| media unread (video). Then, per cause: precondition, and whether the reported steps satisfy it (step 3b).
Not reproduced, or a cause narrower than the report → say which part of the ticket stays unexplained.>
### Root cause
<the disagreement, stated once. Files, symbols, line refs.>
### Evidence
<the step-3 artifact. Table of input → current output → expected, or the quoted test/code.>
### Blast radius
- ❌ <affected surface> — <why>
- ✅ <unaffected surface> — <why it is safe>
### Proposed fix
<numbered, per file, with the actual snippet. Explain why the edge cases hold.>
### Rejected alternatives
<the approach a reader would reach for first, and the specific way it fails.>
### Implementation handoff
<the block specified below.>
### Open questions
<only genuine blockers for a human, each marked (blocking) or (non-blocking). Omit if none.>
Change request:
## Technical analysis — <feasible as asked | feasible with caveats | blocked>
### Current behavior
<what the code does today, with file:line. Correct the ticket if it assumes otherwise.>
### What has to change
<numbered, per file, what changes there and why. Include the actual snippet where it clarifies.>
### Constraints
<schema gaps, conventions that force a shape, translations, migrations. "None" is a valid answer — say it.>
### Scope
- <every call site that must change to stay consistent>
- <what is deliberately left out>
### Rejected alternatives
<the approach a reader would reach for first, and the specific way it fails.>
### Implementation handoff
<the block specified below.>
### Open questions
<product/design decisions this cannot settle, each marked (blocking) or (non-blocking). Omit if none.>
The Implementation handoff block
Both templates end with this block. It is the part someone acts on, so it states one chosen approach in directly actionable form — exact paths, exact symbols, runnable commands. It is written to serve either reader: a developer can work straight through it, and an agent can parse it without inference.
(It also happens to map 1:1 onto the spec.md that /loop-run's spec phase writes, so
that pipeline consumes it as-is. Nothing here depends on using it.)
### Implementation handoff
**Approach:** <one sentence: the single chosen approach. Not a menu.>
**Precedent:** <one of — `<path>` plus the spec covering it, when the fix transplants an
existing, test-covered pattern · `no precedent — designed`, when the shape is new here. State
which; a reader cannot tell a copied pattern from an invented one by looking at the snippet, and
it is the difference between low-risk and needs-a-second-opinion.>
**Files to touch** (paths relative to `front/`):
- `src/path/to/file.ts` — <what changes here, naming the exact symbol>
- `src/path/to/other.test.ts` — <the cases to add>
**Acceptance criteria:**
1. <testable statement: given <input>, <surface> shows <exact expected value>>
2. <...>
**Non-goals:**
- <what a reasonable implementer might touch and must not>
**Verification:**
- `pnpm lint`
- `pnpm types`
- `pnpm translations:inspect` · `pnpm translations:ensure-consistency`
- `pnpm test src/<scoped path>` <only the touched domain, never the full suite>
- `pnpm codegen` <only if a GraphQL document changed — then the diff must be clean>
**Conventions that apply:** <the rules this change is subject to, from `CLAUDE.md` and the
`lago-*` skills — new translation keys via `pnpm translations:add <n>` and never
hand-written, pagination field policy registration, slug-aware router imports, direct MUI
imports, and so on.
Cite only the ones that actually bind here; "none beyond the defaults" is valid.>
Rules for the block, in order of how often they are what goes wrong:
- One approach, no menu. Alternatives belong in Rejected alternatives. An agent handed two options picks arbitrarily.
- Every path exact and complete — relative to
front/, no globs, no "and related files", no "etc.". If the sweep in step 5 found six call sites, all six are listed. - Snippets apply as written. No
...inside lines that change. The surrounding context must be enough to locate the edit unambiguously. - Acceptance criteria are assertions, not intentions, and assertable in this repo's test setup. "The chip shows
8/20/2026for a+02:00bound" — not "the chip shows the right date". Two traps:translateis mocked in jest, so a criterion phrased against user-facing copy ("the label reads Admin") cannot be asserted — phrase it against the translation key or an exporteddata-testconstant. And every criterion needs a concrete expected value, so "restores the full list" has to name the rows or the count. - Name the conventions.
CLAUDE.mdand thelago-*skills are there for whoever implements this, but stating which rules bind this change prevents the classic misses (hand-written translation keys, missing cache field policy, barrel MUI imports) — an agent skips them, a developer new to the area does not know them. - Unresolved decisions go to Open questions, marked (blocking). Never leave a decision implicit inside the handoff for the implementer to guess. The one that hides most easily: a derived value that can fail to resolve. If the fix compares against something looked up at runtime — a code mapped to a name, an id mapped to a record — say what happens while the lookup is still loading and when it never resolves (a stale link, a deleted record).
list.includes(undefined)is false for every row, which renders as a confidently empty screen rather than an error. Decide it in the handoff, or raise it as blocking.
Verify the references mechanically, before posting
Two checks, both cheap, both over the whole comment and not just this block. Reference rot is the failure that sends an implementer hunting, and it discredits the analysis around it.
Every path resolves:
# every path in "Files to touch"; a missing one is a defect in the analysis, not a typo to ignore
ls front/src/path/to/file.ts
A path that must be created by the change is fine — mark it (new) in the list so the check
is not mistaken for a miss.
Every file:line still points at what you say it does. Line references drift the moment
you read around a file, and a citation off by two lands on a closing brace or an if. Print
each one and confirm the symbol is there:
# for every file:line the comment cites, in every section
sed -n '41p' front/src/path/to/file.ts
Cite the line where the value originates, not where the enclosing block starts — that is
the line the implementer has to edit. If a claim spans a range, cite the range (:66-76). A
citation you did not print is a citation you did not verify.
Omit the whole block for cannot determine and blocked verdicts — there is nothing to hand off yet, and a speculative handoff is worse than none. For not a bug, omit it too unless a small clarifying change (a tooltip, a label) is genuinely warranted.
For cannot determine / blocked, replace the middle sections with Needs info: the exact missing facts as a checklist (org timezone, browser, exact steps, expected vs actual, the product decision owner), plus what was already ruled out.
Never propose closing the ticket, changing its state, assigning it, or editing code. Analysis only; the decision stays human.
7. Independent review — always at full depth
Read the latch first, and do what it says rather than what you remember choosing:
front/scripts/analysis-depth.sh <ISSUE-ID> get
full → review, no exceptions. shallow → skip this step and go to step 8; there is no
finding here whose cost of being wrong justifies the review. Anything else means the tier was
never recorded: go back to step 5 and record it.
The cap is MECHANICAL, not a number you keep in your head. front/scripts/iter-budget.sh
is a standalone repo utility — an attempt counter on disk, no pipeline attached — and it is
what refuses the round past the cap.
Before every review, the first one included, charge the budget:
front/scripts/iter-budget.sh <ISSUE-ID> triage-review
Exit 0 → run the review. Exit 1 → the budget is spent: go straight to the exhausted path
below. Do not review again. The triage-review counter is capped at 2, so the sequence is
at most: review → fix → review.
If the script is missing (a checkout that predates it), cap at 2 by hand and say so in the report to the operator — never silently drop the cap.
Each review dispatches a fresh subagent with codebase access (Agent tool, general-purpose). Give it: the ticket text, the draft comment, the repo path. Do not give it your reasoning — it must re-derive. On the second review, also give it the previous review's defects and what you changed.
Instruct it to return, per claim: CONFIRMED / REFUTED / UNVERIFIABLE, with the file:line it checked, plus an overall PASS / FAIL, plus any missed call site. Tell it explicitly that agreeing without opening the cited files is the failure it must avoid.
It must also review the Implementation handoff block as its own deliverable, answering:
- Does every listed path exist (or is it marked
(new))? - Is the sweep complete — any call site it found that the list misses?
- Is each acceptance criterion an assertion with a concrete expected value, testable as written?
- Could the block be read as offering more than one approach?
- Is any decision left implicit that the implementer would have to guess?
- Does the proposed fix follow the pattern
CLAUDE.md,.agents/docs/and thelago-*skills prescribe for this area — rather than whatever the neighbouring code happens to do?
A FAIL on the handoff counts exactly like a FAIL on the analysis. An analysis that is
correct but hands off badly still produces a wrong implementation.
PASS → proceed to step 8.
FAIL, budget left → fix the specific defects, then charge and review again.
FAIL, budget exhausted → post the analysis with this leading line, and do not attempt another review:
⚠️ Automated analysis — independent review could not confirm: <what>. Needs human verification.
Never post a silently-failed review as clean, and never re-run a review the script refused.
8. Post
Post with Linear MCP save_comment (issueId = the identifier), no confirmation needed — the operator invoked this deliberately.
Close the comment with a provenance line, above the footer:
---
Generated by `/triage-frontend-ticket` · independently reviewed
At shallow depth, drop · independently reviewed — it did not happen. State what the output
is; never tell a reader or another system what weight to give it. That is their rule to
write, not this skill's to impose.
Then append this footer as the last line, so the step-1 gate can recognise it:
<!-- triage-frontend-ticket: v1 · type=<defect|change-request> · desc-sha=<hash> · cmt-sha=<hash> -->
Two fingerprints, because a ticket changes in two places. Step 1 fetches the comments precisely because scope changes, repro details and decisions land there and never make it back into the description — so a description-only key reports "nothing new" on a ticket whose comments have since redefined the work.
# what the ticket says
printf '%s' "$DESCRIPTION" | shasum -a 256 | cut -c1-12
# which comments exist, in order — ids only, so editing prose in a comment is not a re-triage
# trigger while a NEW comment is. Exclude comments carrying this skill's footer, otherwise
# posting immediately invalidates the fingerprint it just wrote.
printf '%s' "$COMMENT_IDS" | shasum -a 256 | cut -c1-12
With no qualifying comments, use cmt-sha=none.
Then report to the operator in chat: type, verdict, one-line reason, review outcome, comment URL, and whether the status moved (step 9). Short and plain — the analysis already lives on the ticket.
9. Promote out of Triage — only when earned
A ticket in Triage is waiting for someone to establish whether there is work here. A
resolved analysis answers that, so the ticket can move on to Backlog — where the team
prioritises it. Anything further (Ready for dev, an assignee, a cycle) is a scheduling
decision this skill does not make.
Re-fetch the ticket immediately before moving it. The analysis takes minutes, and a human
may have triaged it in the meantime; acting on the status captured at step 1 would overwrite
their decision. If the re-fetch no longer says Triage, leave it alone and report that
somebody moved it first.
Move it only when every one of these holds:
- the status at re-fetch time is exactly
Triage— a ticket already inBacklog,Scopingor beyond is left alone - the verdict is confirmed bug, feasible as asked, or feasible with caveats
- the independent review returned
PASS - the comment posted successfully
save_issue: { id: <ISSUE-ID>, status: "Backlog" }
Leave the ticket in Triage for every other outcome, and say why in the report:
| Outcome | Why it stays in Triage |
|---|---|
| not a bug | Someone has to decide whether to close it, and that is not a technical call |
| cannot determine / blocked | Still waiting on the missing facts the comment asked for |
review exhausted its budget without PASS |
The analysis is unconfirmed; promoting it would launder that |
A failed status update is a warning, never a failure of the run: the comment is the deliverable and it is already posted. Report that the move did not go through and move on.
Hard rules
- On Linear, only the comment and the one
Triage→Backlogmove. No assignee, no labels, no cycle, no closing, and never a status change from anywhere other thanTriage. Why the label ban is not arbitrary: anai-augmentedorclaude-*label, and theScopingstate, are entry gates for the org's existing automated pipeline — it skips any ticket carrying them. Applying one here would make the ticket permanently invisible to that pipeline, silently.Backlogis safe because it is a pre-dev state that pipeline accepts. Do not add a convenience label to mark analysed tickets; the footer already does that. - No code edits. This skill analyses; implementing is a separate task — by hand or through whatever pipeline the team uses.
- Never post twice on the same ticket state. The footer gate is the mechanism; respect it.
- Print every
file:lineyou cite before posting. An unverified citation is reference rot waiting to happen. - A matching symptom is where to look, never what to conclude. The regression table in step 2a produces hypotheses; only the call site produces findings.
- A real defect is not automatically the reported defect. Every cause carries its precondition and whether the reporter's steps meet it (step 3b). Unmet → the reported symptom is still unexplained, and saying so is the deliverable.
- For interaction, layout and rendering symptoms, reproduce in the running app. jsdom has no layout, no scroll and no paint; a green harness there is not evidence about what the reporter saw.
- Scope fences come after reproduction, never before. Declaring a file correct up front is how the actual cause survives triage, implementation and QA untouched.
- Never dress a guess as a finding. No evidence → cannot determine or blocked, not a confident story.
- Someone will edit code straight from the handoff block. Exact paths, one approach, assertions not intentions. A vague handoff produces a wrong implementation, which is worse than no analysis.
- Never accept the ticket's description of current behavior. Verify it in the code — for change requests this is the most common error in the report.
- The project's documented patterns outrank both the neighbouring code and your own instinct. A fix that works but violates
CLAUDE.mdis a finding to reject, not to propose. - The review budget lives on disk, not in your memory. Every review charges
iter-budget.sh; a non-zero exit is final. - Comment in English regardless of the language the operator is using.