Triage-Feedback Skill

SkillCloud & infra

Lets your agent work through pull request review comments in one pass, replying and resolving them in place.

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 Triage-Feedback Skill skill

About this capability

Marigold repo, Make one triage pass over all feedback on a pull request or a merge request, from GitHub review threads, GitLab MR discussions and Vercel preview toolbar comments, then reply and resolve in whichever system each item came from. Use when the user asks to "triage feedback", "go through

What this skill tells your AI

The instructions your AI receives, as published by marigold-ui/marigold in .claude/skills/triage-feedback/SKILL.md and read by ahel’s review.

Feedback on a PR arrives in two places. GitHub review threads carry the code review, and Vercel preview toolbar comments carry everything someone noticed while clicking through the deployed preview. Working them by hand means two tabs, two idioms for "resolved", and steps that get missed.

This skill makes one pass over both, triages every item on the same three axes, and acts on each in the system it came from.

A GitLab merge request is a third source, on the same footing. Point the skill at an MR URL and it gathers that MR's discussions instead of a PR's review threads. Everything downstream is unchanged: same triage table, same gates, same reply-and-resolve. What it cannot do there is fix anything, for the reason step 1's mode table already gives for any target outside this checkout.

Two gates, and they are the shape of the skill. The triage table in step 4 is the approval for every reply and resolve. The push confirmation in step 6 is separate, because pushing is governed by a standing rule of its own. Steps 1 to 3 are read-only. Nothing before step 5 changes a file, and nothing before step 7 leaves this machine.

Two modes, and step 1 decides which. On your own PR you are the author, and feedback is work incoming. On someone else's you are a reviewer, and the feedback is yours: there is nothing to fix, only replies to write and threads to close. Getting this wrong produces a skill that tries to commit fixes to a branch it does not own.

Usage

/triage-feedback              # the current branch's PR
/triage-feedback 5776         # a PR by number
/triage-feedback --github     # one source only
/triage-feedback --vercel
/triage-feedback --repo marigold-ui/insights 116   # a PR in another repo

/triage-feedback https://git.reservix.io/core/main/-/merge_requests/32140
/triage-feedback --gitlab core/main 32140          # the same target, short form

Workflow

1. Resolve the platform, the repo, the target, the sources and the mode

Platform first, because it decides every command below.

TargetPlatform
nothing, or a bare numberGitHub, the current branch's PR
--repo <owner>/<name> [<number>]GitHub
https://<host>/<project path>/-/merge_requests/<iid>GitLab
--gitlab <project path> <iid>GitLab

--repo is GitHub only, and deliberately so. A GitLab project path is shaped exactly like owner/name, so there is nothing in core/main to tell the two apart. Naming the platform is the user's job, by pasting a URL or passing --gitlab.

From an MR URL the project path is everything between the host and /-/merge_requests/, which is what makes nested subgroups work (core/main, group/subgroup/project), and the trailing segment is the iid. URL-encode the path for every API call: core%2Fmain. Written <enc> below.

Keep the host too, and pass it on every call as --hostname. Nothing in this skill is bound to one GitLab server any more than it is bound to one project. Without the flag, glab falls back to the default in ~/.config/glab-cli/config.yml, which means a pasted gitlab.com URL would quietly be answered by whichever server that config names. The short form carries no host, so there the fallback is the answer: say which host it resolved to on step 4's line rather than leaving it implied.

GitHub target.

gh api user -q .login
gh repo view [<owner>/<name>] --json nameWithOwner -q .nameWithOwner
gh pr view [<number>] [--repo <owner>/<name>] --json number,title,author,headRefName,baseRefName,state,isDraft,headRefOid
git branch --show-current

Stop if the PR is merged or closed. Resolving threads on a landed PR is noise, and the fixes have nowhere to go. A draft is fine, proceed and say so.

Repo. Nothing in this skill is bound to one repository. --repo <owner>/<name> names it explicitly, otherwise gh repo view reads it from the checkout's remote. Record the result: every gh call below takes it, and the GraphQL query in step 2 needs the owner and the name as separate values.

The repo argument is positional on gh repo view and a flag on gh pr view. There is no --repo flag on the former, and GH_REPO does not override it either: it reports the remote regardless, so it is not a second way in. Verified on gh 2.92.0. Resolve once, in this step, and pass the result explicitly everywhere after it.

GitLab target. The GitLab half runs through the glab Docker wrapper in the rx-baseline plugin. Load that skill once, here, and use the absolute glab.sh path it gives you for every call below, written <glab>:

Skill(rx-baseline:gitlab)

Never hardcode that path. The plugin cache is version-pinned (.../rx-baseline/<version>/skills/gitlab/scripts/glab.sh) with no stable alias, so a literal path works until the next rx-baseline release and then quietly stops existing.

If the skill is not available, stop, and say what to run:

This needs the GitLab wrapper from rx-baseline: claude plugin install rx-baseline@rx-ai-suite.

There is no degraded mode here. review-pr can skip its Vercel step because that step is an extra, whereas on a GitLab target the wrapper is the only source there is. A skill in .claude/skills/ has no dependencies field to declare that with, so the check has to be explicit and it has to be early.

<glab> api "user" --hostname <host>
<glab> api "projects/<enc>/merge_requests/<iid>" --hostname <host>   # author, state, diff_refs, web_url, source_branch

The first call doubles as the preflight. The wrapper refuses to start without Docker and refuses without glab credentials, in both cases with the remedy in the error text. Pass that text on verbatim and stop, rather than continuing into a gather that cannot work.

Stop if the MR is merged or closed, for the same reason a landed PR stops.

Record diff_refs.head_sha. It is this side's headRefOid: every code read in step 3 uses it.

Sources. On a GitLab target there is exactly one: previews build from the GitHub repo, so an MR has no toolbar threads, and --github or --vercel alongside --gitlab is a contradiction rather than a filter. Say so in one line and stop.

On a GitHub target, both by default. --github runs the GitHub half alone, --vercel the Vercel half alone, and passing both is the same as passing neither. A source that is switched off is not gathered in step 2 and contributes no rows. Step 4 names which sources ran, so an empty table is never mistaken for a PR with no feedback.

Mode, because it decides which half of this skill runs:

ConditionModeWhat the skill does
PR author is you, branch checked outauthorFull workflow, steps 1 to 8
PR author is you, branch not checked outauthor, no applyAsk them to switch to it and re-run. Until then every apply row becomes needs-human. Replies do not need the code, fixes do
PR author is someone elserespond-onlySteps 1 to 4, then 7 and 8. Skips 5 and 6 entirely
No PR for the branchauthorThe GitHub source is absent, which is not an error. Gather Vercel only and say so
PR is in a repo other than the checkoutauthor, no apply or respond-only, by authorThe worktree is a different repository, so no fix can land there. Replies and resolves still work
Target is a GitLab MRauthor, no apply or respond-only, by authorThe same rule seen from the other side. This skill loads only in the marigold checkout, so an MR is always a different repository

Mode is about who owns the branch, not about who opened a PR. That is why a branch with no PR is still author mode: previews build per branch, so toolbar feedback can arrive before a PR exists.

--repo pointing elsewhere is that same constraint from the other side. The feedback is reachable, the code is not, so the pass is a reply pass whatever the authorship says. Never offer to fix something in a repository you are not standing in.

A GitLab target is that constraint with no exception at all. There is no arrangement in which this skill is standing in the MR's repository, so the GitLab half is a reply pass by construction rather than by circumstance.

The skill does not check the branch out itself. Steps 1 to 3 are read-only, and swapping someone's worktree under them is the largest side effect in the whole pass. Asking keeps that invariant true, which is why no checkout command appears in allowed-tools.

In respond-only mode there is nothing to apply, nothing to commit, nothing to push, and no changeset. Saying "I will now apply 3 fixes" on a branch you do not own is the failure this table exists to prevent.

Record headRefOid. Every code check in step 3 reads that commit, not your worktree.

2. Gather

Every source selected in step 1 is read-only here. Run them in parallel.

GitHub

Skip this half when --vercel was passed, or when the branch has no PR.

REST does not expose whether a review thread is resolved, so this has to be GraphQL:

gh api graphql -f query='
query($o:String!,$r:String!,$n:Int!){
  repository(owner:$o,name:$r){
    pullRequest(number:$n){
      reviewThreads(first:100){
        pageInfo{hasNextPage}
        nodes{
          id isResolved isOutdated path line
          comments(first:50){totalCount nodes{databaseId author{login} body createdAt url}}
        }
      }
    }
  }
}' -f o=<owner> -f r=<name> -F n=<number>
  • Skip threads where isResolved is true. They are done, and reopening them to say so is noise.
  • isOutdated means the diff moved under the comment. It is the single strongest stale signal available, so carry it into Validity rather than re-deriving staleness from the diff.
  • line is null on outdated threads. Do not treat that as a malformed thread.
  • Record who spoke last in each thread, from the final entry in comments.nodes. Step 3 turns it into the Turn column, and it is the difference between feedback nobody has answered and feedback already answered that is waiting on you.
  • Read the caps back. hasNextPage true, or a thread whose comments.totalCount exceeds the 50 fetched, means the gather is partial. Say so in step 4. A table that silently drops the 101st thread looks complete and is not, which is the one failure this skill cannot afford.
  • Also fetch gh pr view <n> --json reviews for review bodies with no inline comment attached. They carry the summary objections and are easy to miss.
Vercel

Skip this half when --github was passed.

Resolve the team at run time. Never hardcode the id: it is account state, and this file is committed.

list_teams                        -> teamId
list_toolbar_threads              -> teamId, branch: <headRefName>, status: unresolved, limit: 100
get_toolbar_thread                -> full messages when a thread is truncated in the list

Pass limit explicitly. It defaults to 20, which a visual-heavy docs PR reaches, and nothing in the response says the list was cut short. If a full page comes back, page with offset before triaging.

Filter by branch, never by projectId. One repo's preview feedback can land in several Vercel projects, and a project filter silently drops whichever one you did not name. Branch spans all of them in a single call. Marigold is the worked example: marigold-docs and marigold-storybook both build from this repo.

.vercel/project.json is not a prerequisite, whatever an older ticket may say. teamId is the only required argument on these tools, list_teams supplies it, and .vercel is gitignored so the file can never be committed anyway.

A toolbar thread's branch name is our git branch name, which is what makes the join work. Pass headRefName from step 1 verbatim. branch is an exact match, not a substring one: DST-1665 returns zero threads where feat/DST-1665-listview-selection returns six. Naming is not uniform either (dst-1745_fix-popover, fumadocs, multiselect-recipe), so never rebuild the name from the ticket key or assume a feat/ prefix.

An empty result is ambiguous, and the fix is not to drop the filter. Zero threads means either no feedback or a branch string that did not match, and the two are indistinguishable. Do not settle it by listing the team unfiltered: that returns every open thread in every project and overflows a single tool result, 64 threads and roughly 244k characters when this was measured. Narrow instead, with search for wording you expect in the comment or page for the preview path. limit: 100 is the right cap with a branch filter and the wrong one without.

Each thread carries context worth keeping: webUrl for the table, context.href for the exact preview page, context.pageTitle, a CSS selector, a React component tree, and often screenshot attachments. Carry all of it into the triage row. The component tree in particular usually identifies the file faster than grepping.

GitLab

The only source on a GitLab target, and skipped entirely on a GitHub one.

<glab> api "projects/<enc>/merge_requests/<iid>/discussions?per_page=100" --hostname <host>
  • Drop every note where system is true, before anything else. This endpoint returns GitLab's whole activity feed alongside the actual discussion: "assigned to @x", "added 1 commit", "marked this merge request as ready", "changed title from", "resolved all threads". They arrive shaped exactly like comments, and they are not feedback. On the MR this was first tested against, six of nine discussions were system notes, and without this filter all six would have been triaged and replied to. GitHub has no equivalent because reviewThreads returns review threads and nothing else.
  • Skip discussions whose notes carry resolved: true. Same rule, same reason, as a resolved review thread. Read that flag literally: an unresolvable note carries resolved: null, not false, and null is not "still open, deal with it" so much as "this was never a thing that closes".
  • individual_note: true is a standalone comment rather than a thread. It is this side's equivalent of a review body with no inline comment attached, so keep it: that is where the summary objections land.
  • resolvable is the flag that decides whether a discussion can be closed, and the note type does not predict it. A thread is resolvable whether or not it is anchored to a diff, DiffNote and DiscussionNote alike. A plain comment is not. Carry the flag into the triage row: step 7 needs it before step 4 promises anyone a resolve.
  • Record who spoke last, from the final entry in notes[]. It feeds the Turn column unchanged.
  • position is the diff anchor on a DiffNote: new_path, new_line and head_sha. Keep head_sha, step 3 reads staleness off it.
  • Read the cap back. GitLab pages at 20 by default, which is why per_page=100 is written out. If exactly 100 discussions come back, fetch page=2 before triaging. A table that silently drops the 101st thread looks complete and is not, on either platform.

3. Triage

Every item gets all five columns. No item is skipped, including ones you intend to do nothing about.

ColumnValues
Validityconfirmed, stale, incorrect, unassessed
Severityblocker, should-fix, nice-to-have, question
Actionapply, push back, needs-human
Threadresolve, keep open
Turnyours when someone else spoke last, theirs when you did

The first three are the triage axes and are your judgement. Thread is a judgement too, but about the conversation rather than the code. Turn is the only one you do not decide: it is thread state, read off the data in step 2.

Reading an item against the code

Read each item against the PR head, not your worktree and not the diff that provoked the comment. In respond-only mode the worktree is a different branch entirely, and even in author mode it can be ahead of what the reviewer saw.

gh pr diff <number> --repo <owner>/<name>              # what the PR actually changes
gh pr diff <number> --repo <owner>/<name> --name-only  # fast check that a file exists
gh api -H "Accept: application/vnd.github.raw" \
  "repos/<owner>/<name>/contents/<path>?ref=<headRefOid>"    # one file at that commit

Send the raw Accept header. Without it the contents endpoint returns a JSON envelope with the source base64-encoded in .content, which is not something to read code from. Verified on gh 2.92.0.

On a GitLab target the same two reads, through the API for the same reason: the repository is not checked out.

<glab> api "projects/<enc>/merge_requests/<iid>/changes" --hostname <host>
<glab> api "projects/<enc>/repository/files/<enc path>/raw?ref=<head_sha>" --hostname <host>

Every path segment is URL-encoded here, the file path included: src/main/App.tsx becomes src%2Fmain%2FApp.tsx.

Quote any URL containing ?. Unquoted, zsh treats it as a glob and fails with no matches found before gh ever runs.

A comment is stale when the code it describes has since changed, and incorrect when the code is as described but the reader was wrong about it. Those need different replies, so do not collapse them.

GitLab has no isOutdated. The nearest thing is a diff note's position.head_sha against the MR's current diff_refs.head_sha. A mismatch says the diff moved under the comment at some point, which is a reason to go and read the code rather than a verdict on its own. Never set Validity stale from the SHAs alone: unlike isOutdated, this cannot tell whether what moved was the lines the comment is about.

unassessed, and when it is the only honest answer

Visual and design comments route straight to needs-human. Do not attempt to judge from a screenshot whether something is correctly aligned, sufficiently prominent, or visually balanced. This is a deliberate v1 limit rather than a gap: a wrong confident answer about a visual is worse than an honest hand-off.

Those items take Validity unassessed, because Validity is exactly the judgement the rule forbids. Do not put confirmed on something you did not verify.

unassessed is for that case only. It is never a shrug for an item you could have checked and did not.

Turn

Turn: theirs means you had the last word and nobody has answered. Those rows need no reply from you: adding one is nagging. The exception is a row you then fixed. "Fixed in abc1234" is news rather than a nudge, so an apply row earns a reply whichever way Turn points. They still belong in the table, because an unanswered blocker of yours is the thing most likely to have stalled the PR.

Turn: yours is where the work is, in both modes. In author mode it is unaddressed review feedback. In respond-only mode it is the author answering you, and often asking you something back.

Thread

Action says what happens to the code. Thread says what happens to the conversation, and the two come apart more often than they look.

By default push back and needs-human keep the thread open, and apply resolves it. What breaks the last one is a partial apply: you did some of what was asked and deferred the rest. The code action is still apply, but resolving would decide on the reviewer's behalf that they are satisfied with half an answer. Those rows are apply plus keep open, and the reply has to say which half landed and where the rest went.

resolve needs the item to be both acted on and finished. Anything else keeps the thread open, and step 8 reports it as still waiting.

A GitLab discussion with resolvable: false takes keep open whatever you would have judged. It has no resolved state to move, in the API or in the UI. Mark the row as not resolvable so step 4 does not promise a resolve that cannot happen. Do not infer this from the note type: read resolvable.

Correlation

Correlate across sources but do not merge. The same problem raised in both a review thread and a preview comment is two rows, because each needs its own reply and its own resolve. Note the correlation in the table so the person can see it is one issue.

Only needs-human items interrupt. Do not stop to ask about each row. The table in step 4 is the single interrupt for the whole pass.

4. Confirm the triage table

Render the table as text and end the turn.

| # | Source | Where | Who | Item | Validity | Severity | Action | Thread | Turn |
|---|--------|-------|-----|------|----------|----------|--------|--------|------|
| 1 | GitHub | Popover.tsx:58 | @sebald | containerPadding is symmetric… | confirmed | should-fix | apply | resolve | yours |
| 2 | Vercel | /components/…/provider | @osama | scroll thumb only moves per category | unassessed | question | needs-human | keep open | yours |

State the platform and the mode in one line above the table, so it is never ambiguous which half of the skill is about to run, nor which host it is about to post to. If step 2 hit a cap, say on the same line that the gather was partial and which source it truncated.

Below the table, list every needs-human row again in full with its link, because those are the rows that actually need the person: webUrl for Vercel, the comment url for GitHub, and the MR's web_url with #note_<first note id> appended for GitLab, which has no per-note url of its own.

Then state plainly what the act phase will do, in the mode's own terms:

  • author — how many fixes, which files, how many replies, how many resolves
  • respond-only — how many replies and resolves, and that no code will change

If that count is zero, say so in as many words. A pass where every row is needs-human is a real and useful outcome, not a failure.

Never use AskUserQuestion, here or anywhere in this skill. It is resolved by the permission component, so under permissions.defaultMode: "auto" with skipAutoPermissionPrompt it never reaches a screen: it returns the first option, and nothing in the result says a human was never asked. It fails toward acting, which is the wrong direction for a gate to fail in. Ending the turn is the one gate no setting can answer on someone's behalf. See the convention in .claude/README.md.

Offer: Approve, Edit (change any row's Action, re-render, ask again), or Stop.

Approval here covers the replies and the resolves. It does not cover the push, which has its own gate.

5. Apply — author mode only

Skip this step entirely in respond-only mode. Go to step 7. There is nothing here in author-no-apply either, because the mode table in step 1 turns every apply row into needs-human. A GitLab target is always one of those two, so this step never runs there.

Only rows marked apply. Work them smallest-blast-radius first so a later failure does not strand a half-finished larger change.

Then run the repo's own verification and versioning steps. Read them out of that repo's CLAUDE.md rather than assuming this one's: a repo that is not built with pnpm has neither of the commands below. In marigold they are

pnpm typecheck:only
pnpm build          # only if a package's public surface changed

followed by a changeset whenever anything under packages/, themes/ or docs/ changed, its body starting with the Conventional Commits line (fix(DST-1234): …). A docs-only change still needs one (@marigold/docs: patch).

Run only the checks the change can actually fail. A markdown or JSON change never reaches pnpm typecheck:only or pnpm build, and running them anyway is a green tick that means nothing.

Commit, in the repo's own commit convention. Do not push.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
144
Forks
14
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
triage-feedback
Source
github.com/marigold-ui/marigold