Triage Command
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
| Form | Meaning |
|---|---|
<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-run | Read-only investigation. Prints the plan. Zero mutations |
--no-resolve | Runs the full authorized fix/reply workflow. Never calls resolve-thread |
--repo OWNER/REPO | Overrides 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 alock_token; keep it in this run's own working context, never in a file, because Phase H'sstate unlockneeds it to prove this run is the holder. Without a matching--lock-tokena normal release is refused withLOCK_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. ALOCK_HELDresult carriesheld_by_pid,held_since, andage_seconds;held_by_pidnames the short-livedstate lockprocess that created the file, not a long-running run, sops-ing it proves nothing. Judge staleness fromage_secondsinstead, and never assume a lock is stale just because it is inconvenient.age_secondsisnullwhen 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; onlystate != openrefuses 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. ACOMMENT_NOT_FOUNDhere 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--allor 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. Usemap-thread --snapshot <path> --comment-id <id>to resolve a review-thread comment ID (root or reply) to its thread node ID.map-threadcovers review comments only, the onesresolve-commentreported ascomment_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 throughmap-thread— that raisesCOMMENT_NOT_IN_SNAPSHOT, which here would be a self-inflicted hard stop, not evidence about the comment. That comment goes straight to Phase G'sreply --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; readreferences/triage.mdfor 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-urlfetches it first; if a clean remote URL can't be derived, omit the flag; it's optional andworktree preparestill 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 leftovergit addfrom 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 forgit add -- <path>to sweep in. Both thegit addand thegit commitbehind this subcommand run with hooks disabled, so a repositorypre-commit,prepare-commit-msg,commit-msg, orpost-commithook 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 raisesCOMMIT_FILE_SET_DRIFTED_FROM_STAGED(the commit's file set no longer matches the intended paths) orCOMMIT_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 isnull, a deleted or inaccessible fork, so the destination cannot be verified at all), andPUBLISH_DESTINATION_MISMATCH(--head-remote-urlresolves to a different host or a differentowner/repothan the PR's real head repository, or--head-refis not the PR's real head branch — the host half matters, sincehttps://evil.example.com/acme/widgets.gitcarries the sameowner/repotail 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, andmatches_pushed_commit: falsearrives too late to undo one. It also refuses to force-push. Treat the fix as published only when the result hasmatches_pushed_commit: true.verification_incomplete: trueis a third outcome, not a failure: the push completed but at least one of the two post-push reads never answered, soremote_head_afterorpr_head_afterisnull. Do not retry the push in that state — it already happened, and a retry would come backHEAD_MOVED. Read the head yourself, then decide. Oncematches_pushed_commit: true, runcheck-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 oneoverall_state(success,pending,failure, orundiscoverablewhen neither read returned anything usable). Apendingresult is fine as long as the reply says so;failureorundiscoverableblocks the✅ Fixedsuffix. -
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 withINVALID_ARGUMENTSon 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). Astatus: ALREADY_REPLIEDmeans 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 itsstatus(CONFIRMEDorALREADY_RESOLVED) as the only proof of closure. Passing--snapshotis optional but recommended: it compares the thread's live comment node IDs against the set the snapshot recorded and refuses withTHREAD_CHANGED_SINCE_SNAPSHOTwhen any ID appeared or vanished, which is exactly the "no new human input" eligibility check below.--allow-new-comment-node-idis what keeps that guard from tripping on this run's own reply:reply's thread path returns the new comment's node ID incomment_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--snapshotand both the guard and the flag are inert. Whenreplyshort-circuited tostatus: ALREADY_REPLIED,comment_node_idcomes backnull; omit--allow-new-comment-node-identirely in that case rather than passing the literal stringnull— a bogus value there is harmless (it just never matches a live ID) but omitting it is cleaner. A--snapshotnaming a snapshot with no entry for--thread-node-idfails closed withTHREAD_NOT_IN_SNAPSHOTbefore any API call; take that as "get a fresh snapshot that actually covers this thread," not as license to retry with--snapshotomitted just to get past it. -
H: persist, report, then clean up. In this order, on every path, including every failure path:
- If a worktree exists and the run did not end in a confirmed publish (validation failed,
stage-commitfailed,publishwas rejected, or anything aborted earlier), capture what that worktree holds before destroying it: create a temp directory yourself, thenworktree preserve --path <dir> --original-head-sha <prepared_head_sha> --out <temp dir>. It writescommitted.patchfor 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 underuntracked/. That last channel is the one plaingit diff HEADnever had: a brand-new implementation or test file that was nevergit added is invisible to a diff and would be destroyed by the forced removal in step 3. Record the returned paths. If it raisesPRESERVATION_FAILED, do not continue to step 3 at all: stop, report the block, and leave the worktree in place for manual recovery. - 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 inreferences/triage.md's data model, not here. worktree cleanup --repo-root "$REPO_ROOT" --path <dir>. Never run this before step 2, and never at all when step 1 reportedPRESERVATION_FAILED:worktree remove --forcedeletes 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.state unlock --repo-root "$REPO_ROOT" --repo "$REPO" --pr <n> --lock-token <the token Phase A returned>. Without the token this refuses withLOCK_TOKEN_MISMATCH;--force-unlockis the only bypass and is for clearing a confirmed-dead run's lock, not for skipping bookkeeping.
Under
--dry-runnone of steps 1 to 4 apply: there is no worktree, nostate write, and no lock to release. Then the capability-aware closing step described below. - If a worktree exists and the run did not end in a confirmed publish (validation failed,
Validation gate (no exceptions)
- No
stage-commitbefore a validation command has run and reported PASS for the intended change. - No
publishbeforestage-commitsucceeded. - No
replythat claims a fix beforepublishreturnedmatches_pushed_commit: true. - No
resolve-threadbeforereplyreturnedCONFIRMED(orALREADY_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.
| Classification | Evidence required | Default handling |
|---|---|---|
ACTIONABLE_FIX | A 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_FIXED | Current head and relevant change/test evidence show that the original concern is addressed. | Reference evidence; no duplicate edit. Resolution can be eligible. |
INFORMATIONAL | A 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_APPLICABLE | Positive 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_CLARIFICATION | Intent, correctness, or required configuration cannot be established from available evidence. | State the missing fact; do not guess or resolve. |
OUT_OF_SCOPE | A 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. |
DUPLICATE | Another 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_FIXbecomes eligible only after successful remediation.ALREADY_FIXEDneeds current evidence. ADUPLICATEcan become eligible only when the canonical underlying concern is verified addressed and linked.INFORMATIONAL,NEEDS_CLARIFICATION,OUT_OF_SCOPE, and disputedNOT_APPLICABLEfindings 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 validOUT_OF_SCOPEfinding. Check the dependency before classifying either way.
--dry-run and --no-resolve
--dry-run: runresolve-identity,resolve-comment,snapshot, andpatch-contextonly. Print the classification, the evidence, and the proposedstage-commit/publish/reply/resolve-threadplan. Never callworktree 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 readis 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 callresolve-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, oncepublishhas confirmedmatches_pushed_commit: trueand 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✅ Fixedis 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 bothmap-threadandresolve-threadentirely 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 targetsgithub.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_FIXEDorNOT_APPLICABLEbecause a search for the flagged identifier came up empty. Checkpatch-contextfor a rename or syntax change first. - Never call
stage-commitbefore validation reports PASS,publishbeforestage-commitsucceeds, orresolve-threadbeforereplyreturnsCONFIRMED/ALREADY_REPLIED. - Never resolve an
INFORMATIONAL,NEEDS_CLARIFICATION,OUT_OF_SCOPE, or disputedNOT_APPLICABLEfinding 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 prepareisolation. - Never build a reply body inline in a shell string. Always write it to a file and pass
--body-file. - Never call
snapshotmore than once per run. - Never call
worktree cleanupbeforestate write, and never on a failure path beforeworktree preservehas returnedok: true. The removal is what destroys the evidence, and it takes untracked files with it. - Never call
worktree cleanupafterworktree preserveraisedPRESERVATION_FAILED. Leave the worktree in place and report the block instead. - Never rely on
git diff HEADalone to capture a worktree's work. It never shows untracked files, so a new implementation or test file would be lost silently. Useworktree preserve. - Never release a lock with
--force-unlockjust because the--lock-tokenwas mislaid. Force is for a lock whose acquiring run is confirmed ended, judged fromage_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 logduring--dry-run, or for a routine, obvious correction.
Always do these
- Always run
resolve-identityandresolve-commentbefore reading or acting on comment content. - Always confirm
matches_pushed_commit: truebefore writing a reply that claims the fix is live. - Always release the
state lockin Phase H with the--lock-tokenPhase 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 writebeforeworktree cleanup, on success and on failure alike. Runworktree preservefirst, beforestate write, whenever the run did not end in a confirmed publish (validation failed,stage-commitfailed,publishwas 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."