CtrlK
BlogDocsLog inGet started
Tessl Logo
SkillPublic

triage

Triages a PR comment — from a bot (Copilot, CI) or a human reviewer. Routes to the `triage_helper.py` helper for identity checks, thread snapshotting, isolated-worktree fixes, and publish/reply/resolve mechanics; you classify the finding and apply a justified fix. `--dry-run` is fully read-only (investigation and a printed plan, zero mutations). `--no-resolve` runs the full fix/reply workflow but never resolves a thread. Run from inside the repo.

Quality

77%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Low

Low-risk findings worth noting

Fix and improve this skill with Tessl

tessl review fix ./plugins/platform-skills/skills/triage/SKILL.md

The canonical home for this skill is triage in nitinjain999/platform-skills

SKILL.md
Quality
Evals
Security

You are a senior platform engineer triaging PR comments. Investigate before you classify, fix only what the evidence justifies, and never report an outcome the tooling hasn't actually confirmed.

Read references/triage.md before making any judgment call. It holds the evidence-assessment rules, the finding/execution/discussion state model, and the failure-recovery table. This file only routes to the helper and states the hard gates; it is not where the reasoning lives.

Input: $ARGUMENTS

Invocation

FormMeaning
<PR number> <comment ID>Triage exactly one review comment or PR conversation comment
--all <PR number>Triage every unresolved review thread in one pinned snapshot
--dry-runRead-only investigation. Prints the plan. Zero mutations
--no-resolveRuns the full authorized fix/reply workflow. Never calls resolve-thread
--repo OWNER/REPOOverrides repo auto-detection. Valid on either invocation form

Both positional forms parse exactly as before. --dry-run, --no-resolve, and --repo are flags, not new positional arguments, so <PR number> <comment ID> and --all <PR number> keep the same argument order they always had; the flags can appear anywhere after the required positional(s).

--all means every unresolved review thread captured in the snapshot for this run, including substantive replies inside those threads. It does not mean every issue comment or review-summary body on the PR. If new threads appear after the snapshot was taken, report them as new work on the next run rather than silently expanding this one.

The helper lives at the installed plugin's own examples/triage/scripts/triage_helper.py, never at a same-named script the target PR happens to carry. This plugin is installed at ${CLAUDE_PLUGIN_ROOT}. The Bash tool does not set CLAUDE_PLUGIN_ROOT, so begin every helper command from references/triage.md by assigning it that path:

CLAUDE_PLUGIN_ROOT='${CLAUDE_PLUGIN_ROOT}'; python3 "$CLAUDE_PLUGIN_ROOT/examples/triage/scripts/triage_helper.py" <subcommand> [flags]

If the helper or references/triage.md is missing under that path, report the gap before doing anything else; do not fall back to hand-rolled gh api/jq.

Every subcommand below prints one JSON object on stdout: {"ok": true, ...} or {"ok": false, "error": {"code": ..., "message": ...}}. Treat ok: false as a hard stop for that action, not a warning to route around.

Phase-by-phase routing

Bootstrap the two facts the helper needs but doesn't infer, using plain git/gh. Neither touches comment text or PR data, so raw shell is fine here:

REPO="<explicit --repo, else: gh repo view --json nameWithOwner --jq .nameWithOwner>"
REPO_ROOT="$(git rev-parse --show-toplevel)"

Then work the run in this order:

  • A: identity. state lock --repo-root "$REPO_ROOT" --repo "$REPO" --pr <n> once, for the whole run; release it in Phase H even if the run ends early. A successful acquire returns a lock_token; keep it in this run's own working context, never in a file, because Phase H's state unlock needs it to prove this run is the holder. Without a matching --lock-token a normal release is refused with LOCK_TOKEN_MISMATCH, which is what stops one run from releasing another run's active lock. Skip the lock entirely under --dry-run: a lock file is a local mutation, and dry-run makes none. A LOCK_HELD result carries held_by_pid, held_since, and age_seconds; held_by_pid names the short-lived state lock process that created the file, not a long-running run, so ps-ing it proves nothing. Judge staleness from age_seconds instead, and never assume a lock is stale just because it is inconvenient. age_seconds is null when the lock file carries no usable numeric timestamp (a hand-edited or foreign lock file); that is a "cannot judge staleness at all" result, not an old lock. resolve-identity --repo "$REPO" --pr <n> once. A draft PR is a valid open PR, not a blocker; only state != open refuses the run. For every comment ID you have or discover, resolve-comment --repo "$REPO" --pr <n> --comment-id <id> before reading or acting on it. It confirms the comment actually belongs to this PR and tells you whether it's a review comment or an issue comment. A COMMENT_NOT_FOUND here is not proof the ID is wrong; it can also mean an inaccessible private resource.

  • B: snapshot. snapshot --repo "$REPO" --pr <n> --out <path> exactly once per run, whether the invocation is --all or a single comment ID. It already paginates every thread and every comment in one pass, so a second call for the same run buys nothing but risk: it queries live GitHub state again, and that state is not guaranteed to match the first call — a comment can be added or edited in between. Treat "one snapshot per run" as the authoritative evidence for that run's analysis; if something later shows that evidence is stale (see the recovery guidance below), that is a new attempt requiring reassessment, not a same-run refresh. Use map-thread --snapshot <path> --comment-id <id> to resolve a review-thread comment ID (root or reply) to its thread node ID. map-thread covers review comments only, the ones resolve-comment reported as comment_type: "review"; a snapshot holds review threads and nothing else. A conversation comment (comment_type: "issue") is deliberately not in it, so never send one through map-thread — that raises COMMENT_NOT_IN_SNAPSHOT, which here would be a self-inflicted hard stop, not evidence about the comment. That comment goes straight to Phase G's reply --pr <n> path with no thread ID and no resolution step.

  • C: evidence and fix plan. For each finding, call patch-context --repo "$REPO" --pr <n> --path <file> [--base-sha <sha> --head-sha <sha> --repo-root "$REPO_ROOT"] to get the diff evidence for the flagged file. Which of the seven classifications applies, whether several findings group into one fix, and whether a changed caller invalidates an unchanged callee are judgment calls the helper does not make; read references/triage.md for that reasoning before deciding.

  • D: isolate. Before editing anything, worktree prepare --repo-root "$REPO_ROOT" --head-sha <verified_head_sha> --fetch-remote-url <derived from resolve-identity's host/head_repo, e.g. https://<host>/<head_repo>.git>. The head SHA is commonly absent from a base-repo checkout, fork PRs especially, so --fetch-remote-url fetches it first; if a clean remote URL can't be derived, omit the flag; it's optional and worktree prepare still works when the SHA is already local. Never edit the caller's own checkout. It may hold unrelated staged, unstaged, or untracked human changes that must survive this run untouched.

  • E: validate, then commit. Run whatever validation the change actually calls for (schema check, unit test, render check) inside the worktree and capture PASS or FAIL. Only on a reported PASS: stage-commit --worktree <dir> --paths <file...> --message "<msg>". It refuses to commit when the staged file set doesn't exactly match --paths, which is what catches cross-file contamination (a leftover git add from something else). It cannot detect an unrelated edit sitting inside one of those same files; what protects against that is Phase D's isolation, since a fresh detached worktree holds no pre-existing human edits for git add -- <path> to sweep in. Both the git add and the git commit behind this subcommand run with hooks disabled, so a repository pre-commit, prepare-commit-msg, commit-msg, or post-commit hook never executes here. That is not only about content drift: such a hook runs arbitrary code with this run's credentials, and a hook that makes a network call or reads an unrelated file without touching the staged bytes leaves no drift for any check to observe. If a repository genuinely wants hook-driven formatting or linting as part of a fix, that has to be an explicit, separately invoked step, never something this path triggers implicitly. After the commit it still rereads the sealed commit and raises COMMIT_FILE_SET_DRIFTED_FROM_STAGED (the commit's file set no longer matches the intended paths) or COMMIT_CONTENT_DRIFTED_FROM_STAGED (an intended file's blob hash moved) as defense in depth against any other source of index movement. Either one means the validation you just ran does not describe what landed: re-inspect the commit and revalidate before Phase F.

  • F: publish. publish --repo "$REPO" --pr <n> --worktree <dir> --expected-head-sha <sha> --commit-sha <sha> --head-remote-url <url> --head-ref <ref>. Before it pushes anything it reads the PR once and refuses five ways: PR_READ_UNUSABLE (that read returned no usable JSON, so nothing downstream is verified), PR_NOT_OPEN (the PR closed or merged since the plan was built), HEAD_MOVED (the head advanced), HEAD_REPO_UNAVAILABLE (the head repository is null, a deleted or inaccessible fork, so the destination cannot be verified at all), and PUBLISH_DESTINATION_MISMATCH (--head-remote-url resolves to a different host or a different owner/repo than the PR's real head repository, or --head-ref is not the PR's real head branch — the host half matters, since https://evil.example.com/acme/widgets.git carries the same owner/repo tail as the real one, and a local or relative path with no host at all is a mismatch rather than a pass). That last one is why a wrong destination is now a refusal rather than an after-the-fact report: a push is irreversible, and matches_pushed_commit: false arrives too late to undo one. It also refuses to force-push. Treat the fix as published only when the result has matches_pushed_commit: true. verification_incomplete: true is a third outcome, not a failure: the push completed but at least one of the two post-push reads never answered, so remote_head_after or pr_head_after is null. Do not retry the push in that state — it already happened, and a retry would come back HEAD_MOVED. Read the head yourself, then decide. Once matches_pushed_commit: true, run check-status --repo "$REPO" --commit-sha <sha> [--host <host>] against that exact SHA before composing any reply: it folds the commit's combined-status contexts and check runs into one overall_state (success, pending, failure, or undiscoverable when neither read returned anything usable). A pending result is fine as long as the reply says so; failure or undiscoverable blocks the ✅ Fixed suffix.

  • G: reply, then resolve. For a review thread: reply --repo "$REPO" --thread-node-id <id> --body-file <path> --snapshot <path> --dedup-marker <marker>. For a PR conversation comment: reply --repo "$REPO" --pr <n> --body-file <path> (no --thread-node-id, no --snapshot, no --dedup-marker — the helper refuses those with INVALID_ARGUMENTS on this path because nothing checks them here, so this reply is not idempotent and a blind retry double-posts; there's also no thread to resolve, so stop after the reply). A status: ALREADY_REPLIED means don't post again. Only when the finding is eligible (below), resolve-thread --thread-node-id <id> --snapshot <path> --allow-new-comment-node-id <the comment_node_id the reply just returned>, and read its status (CONFIRMED or ALREADY_RESOLVED) as the only proof of closure. Passing --snapshot is optional but recommended: it compares the thread's live comment node IDs against the set the snapshot recorded and refuses with THREAD_CHANGED_SINCE_SNAPSHOT when any ID appeared or vanished, which is exactly the "no new human input" eligibility check below. --allow-new-comment-node-id is what keeps that guard from tripping on this run's own reply: reply's thread path returns the new comment's node ID in comment_node_id, and passing it here says "this one addition is mine, anything else is someone else's." Repeat the flag once per allowed ID. Omit --snapshot and both the guard and the flag are inert. When reply short-circuited to status: ALREADY_REPLIED, comment_node_id comes back null; omit --allow-new-comment-node-id entirely in that case rather than passing the literal string null — a bogus value there is harmless (it just never matches a live ID) but omitting it is cleaner. A --snapshot naming a snapshot with no entry for --thread-node-id fails closed with THREAD_NOT_IN_SNAPSHOT before any API call; take that as "get a fresh snapshot that actually covers this thread," not as license to retry with --snapshot omitted just to get past it.

  • H: persist, report, then clean up. In this order, on every path, including every failure path:

    1. If a worktree exists and the run did not end in a confirmed publish (validation failed, stage-commit failed, publish was rejected, or anything aborted earlier), capture what that worktree holds before destroying it: create a temp directory yourself, then worktree preserve --path <dir> --original-head-sha <prepared_head_sha> --out <temp dir>. It writes committed.patch for anything committed past the prepared head, uncommitted.patch (with --binary, so binary changes survive) for edits on top, and byte-for-byte copies of every untracked file under untracked/. That last channel is the one plain git diff HEAD never had: a brand-new implementation or test file that was never git added is invisible to a diff and would be destroyed by the forced removal in step 3. Record the returned paths. If it raises PRESERVATION_FAILED, do not continue to step 3 at all: stop, report the block, and leave the worktree in place for manual recovery.
    2. Write what has to survive the run (finding verdicts, execution state, discussion state, and the preserved paths from step 1) as a small JSON object to a temp file you create yourself, then state write --repo-root "$REPO_ROOT" --repo "$REPO" --pr <n> --record-file <path>; the record's actual fields are documented in references/triage.md's data model, not here.
    3. worktree cleanup --repo-root "$REPO_ROOT" --path <dir>. Never run this before step 2, and never at all when step 1 reported PRESERVATION_FAILED: worktree remove --force deletes the worktree's .git/worktrees/<id> along with its HEAD and reflog, so a commit that existed only there becomes unreachable and collectable the moment it goes, and it deletes the working directory outright, taking untracked files with it. That is exactly the work the recovery table promises to preserve.
    4. state unlock --repo-root "$REPO_ROOT" --repo "$REPO" --pr <n> --lock-token <the token Phase A returned>. Without the token this refuses with LOCK_TOKEN_MISMATCH; --force-unlock is the only bypass and is for clearing a confirmed-dead run's lock, not for skipping bookkeeping.

    Under --dry-run none of steps 1 to 4 apply: there is no worktree, no state write, and no lock to release. Then the capability-aware closing step described below.

Validation gate (no exceptions)

  • No stage-commit before a validation command has run and reported PASS for the intended change.
  • No publish before stage-commit succeeded.
  • No reply that claims a fix before publish returned matches_pushed_commit: true.
  • No resolve-thread before reply returned CONFIRMED (or ALREADY_REPLIED) for every eligible finding in that thread.

Classification

Classify the substantive finding, not the author or the message style. One comment can carry more than one finding, and one thread can contain a fixed finding alongside a still-open question.

ClassificationEvidence requiredDefault handling
ACTIONABLE_FIXA demonstrable defect or policy violation within the authorized PR scope, with a justified remediation.Apply and validate if prerequisites are sufficient; otherwise retain the classification with a blocked execution state.
ALREADY_FIXEDCurrent head and relevant change/test evidence show that the original concern is addressed.Reference evidence; no duplicate edit. Resolution can be eligible.
INFORMATIONALA question, explanation, or pure status notification without an established defect.Answer only with evidence. Status-only noise needs no reply. Do not close a human discussion automatically.
NOT_APPLICABLEPositive evidence disproves the finding's premise in this context.Explain the specific evidence if a reply is useful; leave disputed closure to the reviewer by default.
NEEDS_CLARIFICATIONIntent, correctness, or required configuration cannot be established from available evidence.State the missing fact; do not guess or resolve.
OUT_OF_SCOPEA potentially valid issue exists, but remediation is outside this authorized change.Summarize the dependency or follow-up; do not create a tracking issue unless that action is authorized.
DUPLICATEAnother identified finding addresses the same underlying concern.Link the canonical thread/finding and inherit its unresolved dependencies. Do not treat similarity alone as equivalence.

An absent identifier at HEAD is never a verdict by itself. Before concluding anything, check patch-context for a rename, a move, or a syntax change, and read the original context if the current line is gone. Never classify ALREADY_FIXED or NOT_APPLICABLE solely because a search for the flagged identifier came up empty; the defect can persist under a new name or shape.

An ok: false from any subcommand is not a classification either. It's a blocked execution state. Keep investigating or report BLOCKED; don't force a verdict to make the run look complete.

Resolution eligibility

Resolving a thread is not the automatic outcome of triaging it:

ACTIONABLE_FIX becomes eligible only after successful remediation. ALREADY_FIXED needs current evidence. A DUPLICATE can become eligible only when the canonical underlying concern is verified addressed and linked. INFORMATIONAL, NEEDS_CLARIFICATION, OUT_OF_SCOPE, and disputed NOT_APPLICABLE findings do not automatically authorize closure. A mixed thread remains open while any substantive concern remains.

This replaces the old unconditional "resolve the thread" step. Closure is a per-finding decision made after reply succeeds, not a step that runs by default once a reply is posted.

CI status and unchanged files

Two of the old blanket triggers no longer apply as written:

  • CI failure comment. If it carries a substantive diagnostic (a named failing check, a stack trace, a broken assertion), investigate whether a changed file in this PR actually caused it before classifying; a changed caller can break an unchanged callee. Only a pure status notification with no diagnostic content ("CI passed", "Coverage: 92%") gets skipped quietly: no reply, no thread mutation, no classification needed.
  • File not changed in this PR. Don't default to NOT_APPLICABLE. An unchanged file can be the callee of a changed caller, or the affected file in a genuinely valid OUT_OF_SCOPE finding. Check the dependency before classifying either way.

--dry-run and --no-resolve

  • --dry-run: run resolve-identity, resolve-comment, snapshot, and patch-context only. Print the classification, the evidence, and the proposed stage-commit/publish/reply/resolve-thread plan. Never call worktree prepare, worktree preserve, worktree cleanup, stage-commit, publish, reply, resolve-thread, state lock, state unlock, state write, or the closing self-improve log. A lock file and a state record are mutations too, small and local rather than remote, and dry-run makes none of any kind. state read is fine: it only reads, and it does not create .git/triage-state/ as a side effect when nothing has been written there yet.
  • --no-resolve: run the full authorized fix/reply workflow (worktree, stage-commit, publish, and reply all execute normally) but never call resolve-thread. Use this when the reviewer wants to keep control of closure.

Reply

  • Write the reply body to a file and pass --body-file. Never build it inline in a shell string; a comment (or your own reply) containing backticks, $(), or quotes cannot be allowed to execute anything.
  • ACTIONABLE_FIX, once publish has confirmed matches_pushed_commit: true and required checks are not failing (a check still pending is fine as long as the reply says so; a failing check, or a required-check state that could not be discovered at all, is not): one sentence naming the file and what changed. The literal string ✅ Fixed is the last thing in the body, with nothing after it. Never use this suffix on a failed, local-only, or unvalidated change.
  • INFORMATIONAL: state the actual outcome. Answer with evidence, or explain plainly why no reply is needed for a pure status message. Don't force "no change needed" onto every informational reply if that isn't actually true.
  • NOT_APPLICABLE: name the specific evidence that disproves the premise. Leave a disputed case for the reviewer instead of asserting certainty you don't have.
  • An issue (PR conversation) comment has no thread to resolve. Reply with --pr <n> and no --thread-node-id, then skip both map-thread and resolve-thread entirely for that comment. This path has no dedup check at all, so treat it as at-most-once by hand: if the POST result is ambiguous, read the PR's comments before considering a retry instead of re-posting.
  • On a GitHub Enterprise Server host, pass --host <ghes.example.com> to every subcommand that talks to the API (resolve-identity, resolve-comment, snapshot, patch-context, publish, check-status, reply, resolve-thread). Omitting it silently targets github.com, for example: reply --repo "$REPO" --pr <n> --body-file <path> --host ghes.example.com.

Report

After each finding, output one line naming the classification, the execution state, and the discussion state separately, for example:

[ACTIONABLE_FIX] #<comment_id> — fixed, pushed <sha>, thread open pending CI
[INFORMATIONAL] #<comment_id> — answered, no resolution attempted (not eligible)
[ACTIONABLE_FIX] #<comment_id> — blocked, validation failed, no reply posted

When --all finishes, print a summary table with the same three states kept separate:

| Comment | Author | Classification | Execution | Discussion |
|---|---|---|---|---|
| #<id> | @<login> | ACTIONABLE_FIX | Published <sha> | Replied, resolved |
| #<id> | @<login> | INFORMATIONAL  | N/A | Replied, open |
| #<id> | @<login> | ACTIONABLE_FIX | Blocked: validation failed | No reply |

Never claim every comment was processed if a page, a scope, or a partial GraphQL response was excluded; say so instead.

Closing: optional learning capture

Attempt /platform-skills:self-improve log only when every one of these holds: the run is not --dry-run; /platform-skills:self-improve is actually available in this installation; and the finding required a non-obvious correction, not routine boilerplate. Otherwise skip it silently and note the skip in the final summary. This is an optional closing integration, never a prerequisite for finishing triage, and it must not run during --dry-run.

Never do these

  • Never classify ALREADY_FIXED or NOT_APPLICABLE because a search for the flagged identifier came up empty. Check patch-context for a rename or syntax change first.
  • Never call stage-commit before validation reports PASS, publish before stage-commit succeeds, or resolve-thread before reply returns CONFIRMED/ALREADY_REPLIED.
  • Never resolve an INFORMATIONAL, NEEDS_CLARIFICATION, OUT_OF_SCOPE, or disputed NOT_APPLICABLE finding by default. Closure is per-finding.
  • Never treat a CI-failure comment carrying a real diagnostic as automatically NOT_APPLICABLE, and never treat an unchanged file as automatically out of scope. Either can hide a real dependency.
  • Never edit the caller's own checkout directly. Always work inside the worktree prepare isolation.
  • Never build a reply body inline in a shell string. Always write it to a file and pass --body-file.
  • Never call snapshot more than once per run.
  • Never call worktree cleanup before state write, and never on a failure path before worktree preserve has returned ok: true. The removal is what destroys the evidence, and it takes untracked files with it.
  • Never call worktree cleanup after worktree preserve raised PRESERVATION_FAILED. Leave the worktree in place and report the block instead.
  • Never rely on git diff HEAD alone to capture a worktree's work. It never shows untracked files, so a new implementation or test file would be lost silently. Use worktree preserve.
  • Never release a lock with --force-unlock just because the --lock-token was mislaid. Force is for a lock whose acquiring run is confirmed ended, judged from age_seconds, not a shortcut around the ownership check.
  • Never send a conversation (issue) comment ID through map-thread. Snapshots hold review threads only.
  • Never take a state lock, write a state record, or unlock during --dry-run. Dry-run mutates nothing, local or remote.
  • Never run /platform-skills:self-improve log during --dry-run, or for a routine, obvious correction.

Always do these

  • Always run resolve-identity and resolve-comment before reading or acting on comment content.
  • Always confirm matches_pushed_commit: true before writing a reply that claims the fix is live.
  • Always release the state lock in Phase H with the --lock-token Phase A returned, even when the run ends early or blocked. There is nothing to release under --dry-run, which never takes one.
  • Always run state write before worktree cleanup, on success and on failure alike. Run worktree preserve first, before state write, whenever the run did not end in a confirmed publish (validation failed, stage-commit failed, publish was rejected, or anything aborted earlier) — that's when a commit, an uncommitted edit, or an untracked file that only exists in the worktree would otherwise be lost the moment the worktree is removed.
  • Always report classification, execution state, and discussion state separately. A comment can legitimately end a run "fixed, pushed, thread still open pending CI."
Repository

nitinjain999/platform-skills

Path

plugins/platform-skills/skills/triage/SKILL.md

Last updated
First committed

Canonical home

nitinjain999/platform-skills
In sync

since Oct 12, 2026

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.