| name | triage |
| description | Investigate a GitHub issue or pull request in colinhacks/zod and write up a durable verdict. Use whenever asked to triage, investigate, review, evaluate, or form an opinion on an issue or PR (by number, URL, or "the open PR queue"), and when sweeping many of them in bulk. For a draft SECURITY ADVISORY (a GHSA id, the Security tab, a private vulnerability report) use the `security-advisory` skill instead — it shares this file's conventions but its workflow lands the fix before drafting any comment. Covers where write-ups live on disk (.triage/issues/NNNN/, .triage/prs/NNNN/), the cheap-disqualifier pass that avoids spinning up a worktree for stale PRs, the worktree checkout procedure for PRs, the results.md format that keeps a 250-PR sweep greppable and resumable, how to report back in chat — the final message is all the user sees, so it leads with a code block showing the problem and a short outline of the fix — and the maintainer-voice comment it drafts whenever the verdict is to close. |
Investigating issues and PRs
Produce a write-up a maintainer can act on without re-reading the thread. The durable deliverable is always a file on disk — sweeps span many sessions and agents, so anything living only in a transcript is lost. But the file is not what the user reads first: your final chat message is, and for most tickets it is the only thing they will ever read. Both matter, and ## Reporting back below is not optional.
Read-only on GitHub. Never comment, label, assign, close, review, approve, or merge unless the user explicitly asks in this session. You are forming an opinion, not publishing one. Drafting the close comment described below is part of forming it — writing the text is required, sending it is not yours to do.
Where things live
Everything goes under .triage/ in the root repo, which is gitignored — write freely, it never shows up in git status.
.triage/
index.md # the original issue sweep index — hand-maintained prose, still the canonical issue queue
issues/index.md # generated from frontmatter by reindex.mjs
issues/<N>/results.md # the write-up; scratch files live beside it
prs/index.md # generated
prs/<N>/results.md
advisories/<GHSA-ID>/results.md # draft security advisories from the Security tab
signal-report.md # generated by scripts/triage-signal.ts — ranks the open backlog
One directory per ticket, named by bare GitHub number (no leading zeros, no issue-/pr- prefix) — or by bare GHSA id for an advisory. results.md is the write-up. Everything else you need — repro scripts, a saved gh pr diff, test fixtures, benchmark output, notes — goes in that same directory. Name scratch files descriptively (repro.ts, diff.patch, bench-before.txt); there is no naming rule beyond not colliding with results.md.
For an issue repro, nub run dev .triage/issues/<N>/repro.ts from the root repo works directly.
A repro must sit inside the checkout it is testing, because module resolution walks up from the file, not from the cwd — a script under .triage/ cannot resolve zod from a PR worktree. Keep the canonical copy in the ticket directory and copy it in to run: cp .triage/prs/<N>/repro.ts <worktree>/repro-<N>.ts. That is also what lets you run the identical file against both main and the PR and diff the output, which is the cheapest way to characterize a behavior change. For a probe needing GC or other flags, invoke Nub with the flags directly: nub --expose-gc --conditions=@zod/source repro-<N>.ts.
Writing to the root repo from a PR worktree
This is the one mistake that silently destroys work. A worktree has its own gitignored .triage/, so a relative write from inside it lands somewhere nobody will ever look.
Resolve the root once and always write through it:
ZOD_ROOT=$(git worktree list | head -1 | awk '{print $1}')
mkdir -p "$ZOD_ROOT/.triage/prs/<N>"
Every path you write must start with $ZOD_ROOT. Investigate in the worktree, record in the root.
results.md format
Open with YAML frontmatter. It is what makes a 250-PR sweep aggregatable and resumable — an interrupted sweep is resumed by grepping status, never by re-reading write-ups.
---
number: 5928
type: pr
status: done
verdict: merge-with-changes
title: "fix: preserve brand through .optional()"
author: someuser
url: https://github.com/colinhacks/zod/pull/5928
investigated: 2026-08-08
---
verdict for PRs — one of:
merge-as-is — correct, tested, in scope. Say so plainly.
merge-with-changes — right idea, needs specific edits. Enumerate them concretely enough to apply.
needs-author-changes — blocked on something only the author can supply (repro, rebase, scope cut).
decline — out of scope or wrong approach. Give the one or two strongest reasons, plus the escape hatch users actually have.
superseded — already fixed on main or landed via another PR. Name the commit or PR.
needs-maintainer-decision — the code is fine; the call is a product/API judgment that is Colin's to make. State the tradeoff and your recommendation.
verdict for issues — reuse the vocabulary already established in .triage/index.md: good-idea, already-fixed, needs-maintainer-input, not-recommended, needs-more-reproduction, duplicate (add a duplicate_of: NNNN key alongside it).
verdict for advisories — one of:
valid — a real vulnerability in zod, reproducible on current main. State the affected versions and the fix.
not-a-vulnerability — the behavior is intended, requires the app to already be doing something unsafe, or is a plain bug with no security consequence. This is the most common outcome; say which.
already-fixed — real, but no longer reproduces on main. Name the commit or release that fixed it.
duplicate — the same defect as another advisory (add duplicate_of: GHSA-xxxx-xxxx-xxxx). The __proto__ reports cluster heavily; check before concluding.
needs-reporter-info — the report is too vague to reproduce, or the PoC does not run.
needs-maintainer-decision — real behavior, but whether it counts as a vulnerability is a threat-model call that is Colin's to make.
Advisory frontmatter swaps number: for ghsa: and adds severity_claimed: (what the reporter asserted) alongside severity_actual: (your assessment, or none).
Set status: in-progress in a stub the moment you start, so a parallel agent or a later session does not duplicate the work. Flip to done before you report back.
One pass. Finish the ticket. A maintainer question is never a reason to leave a write-up unfinished — investigate fully, commit to a recommended verdict, write every section including ## Proposed comment, and flip status: done. Anything genuinely needing the maintainer goes in ## Open questions (below) inside the finished file, so their answer is a one-line amendment to a complete write-up rather than a second investigation. Never hand back a provisional verdict, a status: in-progress file, or a promise to finalize later; never wait on an answer before writing. verdict: TBD is not a verdict. The only thing that legitimately blocks done is evidence you could not obtain — a PR that will not build, a repro that needs credentials — and that is needs-author-changes or needs-more-reproduction, which are verdicts, not deferrals.
After the frontmatter, write prose under these headings. Match the density of the existing issue write-ups in .triage/issues/*/results.md — they are the house style; skim one before your first write-up.
## Request / ## Change — what is actually being asked or changed, in your own words. Include the thread's evolution when maintainers or the author already moved the discussion.
## Current state — what the code on main does today, with file.ts:line references. Most old tickets were filed against v3 or early v4 and are stale on arrival; this section is usually where the verdict comes from.
## Analysis — correctness, edge cases, type-level implications, perf, and whether the tests actually cover the claim.
## Recommendation — the action to take, and the substance of the reply to post. Do not draft it in maintainer voice unless asked, with one standing exception below.
## Proposed comment — required whenever the recommendation is to close the ticket, in a fenced block, ready to paste. See below.
## Open questions — only for calls that are genuinely the maintainer's: product/API direction, a breaking-change tradeoff, taste, or picking between two contributors' PRs. Omit the heading entirely when there are none, which is the common case. Never park a question here that the code, the tests, git log, or an existing write-up answers — that is research you owe, not a decision they owe. See below.
Writing ## Open questions
Each question is answerable cold, by someone who has not read the write-up and will not open it. One line for what is at stake, then 2–4 lettered options, each with a one-line consequence, with your recommendation marked and placed first. Write in behavior, not identifiers: "should a schema that declares a field named __proto__ still reject it?" not "should handleCatchall precede the keySet check?".
The verdict above already assumes your recommended option. Say which verdict each other option would produce, so the maintainer can see the cost of disagreeing without asking you to redo the analysis:
## Open questions
**Q1 — Should `.strict()` reject an own `__proto__` key, or keep silently dropping it?** Runtime breaking change for anyone posting JSON bodies through `.strict()`.
- **(A) Report it — take the PR.** ← recommended. Restores "strict rejects every unknown key". Verdict as written: `merge-with-changes`.
- (B) Keep dropping it, document the exemption. Zero breakage; the one key an attacker actually sends stays the one key strict does not flag. → `decline`.
- (C) Report it behind a config flag. No breakage; a permanent knob for a two-line behavior. → `needs-author-changes`.
When the answer arrives, amend the file in place: set the final verdict, fold the decision into ## Recommendation, rewrite ## Proposed comment to match, and leave the question and its answer recorded so nobody relitigates it. status stays done throughout — it was never not done.
Drafting the close comment
Any verdict whose action is "close this" gets a drafted reply: decline and superseded on a PR, not-recommended, already-fixed, and duplicate on an issue, and not-a-vulnerability, already-fixed, and duplicate on an advisory. Closing is the one outcome where a human is going to have to say something to a real person who spent time on this, so writing that sentence is part of the triage, not a follow-up task.
Follow the "Commenting on issues and PRs" section of AGENTS.md for the maintainer voice. Succinct, conversational, friendly. Lead with the decision, give the one or two strongest reasons, name the escape hatch the user actually has, and close warmly. Two or three sentences is usually the whole thing.
For an issue reporting that z.coerce.number() accepts null, the whole draft is:
Working as intended. `z.coerce.number()` runs `Number()` on the input before it validates, and `Number(null)` is `0` — the coercion happens first, so there is nothing left to reject by the time the number check runs.
If you want these rejected, use plain `z.number()` and do the coercion at the boundary where you actually know the input is a numeric string. Closing this out — thanks for the writeup 👍
Note what it does not do: no bullet list of every objection, no apology, no "great question", no asking whether the reporter agrees. Decision, reason, escape hatch, closer.
The investigation's depth never sets the comment's length
This is the rule that gets broken, and it gets broken hardest on exactly the tickets where the investigation went deepest. A week of benchmarks, a prior-art trail, a prototype, an ecosystem comparison — all of it feels load-bearing, so it leaks into the reply and the two-or-three-sentence bar quietly becomes five paragraphs. The evidence you are proudest of is the first thing to cut. It belongs in results.md, which is where a maintainer looks when someone pushes back; the comment is not the place to prove you did the work.
A worked case. A draft advisory reported ReDoS in z.fromJSONSchema() — real, reproducible, two sinks, exponential. The investigation ran long: measured timings, prior art in a closed issue and a closed fix PR, a 54MB dependency weighed, a zero-dep detector prototyped and evaluated, Ajv's published security contract pulled in as precedent. The drafted comment carried most of that. What the maintainer actually posted, in full:
Closing this out — declining. `z.fromJSONSchema` is designed to convert arbitrary JSON Schema to a Zod schema, and it up to the user to do their own out of band validation. Thanks for the report.
Three sentences, and this is what most declining comments should look like. Read what it drops and adopt each as a rule:
- No cross-references. No issue number, no PR number, no prior-art trail — even when a maintainer closed the identical report before. That history justifies the decision to you; it does not need to be shown to the reporter.
- No numbers, no engineering. No timings, no dependency sizes, no analysis of what a guard would cost or why the cheap version fails. None of it changes the answer.
- No external precedent. How another library documents the same contract is research that settles your verdict, not material for the reply.
- The reason is design intent, asserted — not an argument, defended. "Is designed to convert arbitrary JSON Schema" states the contract in one clause and stops. A maintainer declares scope; they do not litigate it. Once you find yourself building a case, you have written the wrong document.
- No concession and no promise. Do not offer "you have a fair point about X", and do not commit to a docs change, a new option, or any follow-up. Real follow-ups get filed as their own issue; a decline that promises work is no longer a decline.
Keep only: the decision, one clause of reason, what the user does instead ("their own out of band validation"), and a short closer. When a drafted comment runs past three or four sentences, that is the signal to cut, not to reorganize.
Drafting is not posting. The draft sits in results.md and in your chat message; you never send it unless the user explicitly asks in this session. For an advisory the draft is the note to the reporter, and it must not restate an unpatched exploit.
The length rule binds hardest on an advisory decline, which is where it is most often broken: a reporter who filed a real, reproducible defect invites you to walk them through the whole severity argument, and the analysis you just spent hours on is right there. Do not paste it. The reporter needs four things — it is fixed and where, you are not publishing, the one reason that actually decides it, and the offer of credit. Everything else belongs in results.md. A decline that runs to six paragraphs is a worse decline, not a more rigorous one.
Reporting back
results.md is the archive. Your final chat message is the interface, and for most tickets it is the only thing the user will ever read. Assume they see it hours later, in a queue, without the thread in front of them, without opening the file, and without any of your working context. "Investigated #5928, write-up is in .triage/prs/5928/results.md" has delivered nothing — it just moved the work onto them.
So the last message before you rest always carries these three things, in this order, plus a fourth when the verdict is to close:
1. The problem, as a code block. Not a paragraph describing the code block — the actual minimal snippet, with the wrong result written next to it as a comment. This is the single highest-value thing in the message: it is what lets the user confirm or reject your read in five seconds instead of re-deriving the whole ticket. Strip the reporter's app scaffolding down to the schema and the one call that misbehaves, and show actual versus expected.
const schema = z.coerce.number();
schema.parse(null);
schema.parse("");
schema.parse([]);
Type-level tickets get the same treatment, with the inferred type as a comment instead of the parse result:
type Out = z.infer<typeof schema>;
2. The shape of the fix, in two or three sentences. Where it goes (file.ts:line), what changes, and how big it really is — one-line type tweak, new method on ZodType, or a refactor that touches every wrapper. If the fix is not worth doing, say what the user does instead. Do not paste the patch; the user is deciding whether to spend time here, not applying it from chat.
3. The verdict and the path. The frontmatter verdict verbatim, plus a link to the ticket and the results.md path for the detail you left out.
4. The proposed comment, if the verdict is to close. Repeat the ## Proposed comment draft in the message, in its own fenced block, so the user can read and paste it without opening the file. Say plainly that you have not posted it.
5. The open questions, if the write-up has any. Repeat them from ## Open questions, options and all, so they are answerable without opening the file. Frame them as what they are — the ticket is triaged and the verdict is recorded; the answer only confirms or redirects it. Never present them as a request for permission to finish, never say the write-up is waiting on them, and never end on "send me your answer and I'll finalize". The work is already done.
Keep the whole thing short — the code block plus a few sentences. The message is a summary that stands alone, not a condensed replay of the write-up, and it is not the place for the investigation narrative, the commands you ran, or the branches you ruled out. Those are what the file is for.
Two adjustments by shape of the task:
-
Nothing reproduced — an already-fixed or not-a-vulnerability verdict still gets a code block. Show the reporter's own snippet and the correct output it now produces on main; that is the evidence, and without it the user just has your word.
-
A bulk sweep — one summary table of ticket, verdict, and a one-line reason, and code blocks only for the two or three tickets that genuinely need a decision. Twenty code blocks is the same as none. Every close still gets its drafted comment written into its own results.md; the message just says which tickets have one waiting instead of inlining them all.
-
More than one ticket has a comment to post — pair each one: the link on its own line, its fenced comment block directly underneath it, then the next link. Never collect the links in one place and the comment blocks in another, and never make the user match a block to a link by reading the prose between them. This is how a set of draft advisories is always handed back, since the user posts them one at a time with the link and its text side by side.
### [GHSA-xxxx-xxxx-xxxx](https://github.com/colinhacks/zod/security/advisories/GHSA-xxxx-xxxx-xxxx) — valid
<the comment for that advisory, in its own fenced block>
### [GHSA-yyyy-yyyy-yyyy](https://github.com/colinhacks/zod/security/advisories/GHSA-yyyy-yyyy-yyyy) — duplicate
<the comment for that advisory, in its own fenced block>
Investigating an issue
No worktree — read the code on main.
gh issue view <N> -R colinhacks/zod --comments and read the whole thread, including maintainer replies. A prior decision from Colin usually settles it.
- Check for duplicates and prior art:
gh search issues --repo colinhacks/zod '<keywords>', and grep .triage/issues/*/results.md — the sweep may already cover it. Cross-link with a relative ../NNNN/results.md.
- Reproduce against current source before believing the report. Write the repro to
.triage/issues/<N>/repro.ts and run nub run dev on it. A large share of open issues are already fixed.
- Brainstorm before concluding. Consider the fix, its blast radius, whether it belongs in core at all, and what userland escape hatch already exists.
- Write
results.md, then report back per ## Reporting back.
Investigating a draft advisory
Use the security-advisory skill instead — invoke it before you start. Draft advisories live in the Security tab, so nothing in gh issue list surfaces them, and the workflow diverges from this one in ways that matter: the report is private in both directions, the burden of proof sits with the reporter, the fix must be landed before any comment is drafted, and "real defect" and "publishable vulnerability" are separate calls.
What carries over from this skill: where files live, the results.md frontmatter and headings, the close-comment voice, and ## Reporting back. Advisory write-ups go to .triage/advisories/<GHSA-ID>/results.md with ghsa: in place of number:.
Never accept, publish, decline, or comment on an advisory. The write-up and the drafted note are the deliverables; the disposition is Colin's.
Investigating a PR
Cheap pass first — most of a 250-PR backlog is resolved without ever checking out code.
gh pr view <N> -R colinhacks/zod --json number,title,author,state,isDraft,mergeable,mergeStateStatus,baseRefName,headRefName,headRepositoryOwner,maintainerCanModify,createdAt,updatedAt,additions,deletions,changedFiles,labels,comments
gh pr diff <N> -R colinhacks/zod
Disqualify early and write the write-up straight from this, no worktree, when the diff is small and self-evident, or when the PR targets v3 / duplicates a landed change / is already superseded by main / is abandoned with unresolved conflicts. Verify superseded against actual code on main — do not infer it from dates.
Otherwise check it out (this repo's convention, per AGENTS.md):
git fetch origin pull/<N>/head:pr-<N>
git worktree add ~/.cursor/worktrees/zod/pr-<N> pr-<N>
cd ~/.cursor/worktrees/zod/pr-<N>
nub install --frozen-lockfile
Do not use gh pr checkout --detach — it detaches your current working tree instead of creating a worktree.
Then, in the worktree:
- Read the diff in full context, not just the hunks. What did it touch that the author did not think about?
git log --oneline main..HEAD — is this rebased on anything current, or written against a version of the code that has since moved?
- Run the tests the change implicates:
nub exec --node vitest run <path>. Run the full suite only when the change is broad.
- Judge test coverage against the repo's own bar: a feature or bug fix without a test is incomplete, and tests must be TypeScript.
- Probe the edge cases the author's tests skip — write them into
$ZOD_ROOT/.triage/prs/<N>/ and run them. A passing suite over a shallow test is not evidence.
- Write
$ZOD_ROOT/.triage/prs/<N>/results.md, then report back per ## Reporting back.
Clean up when done, or worktrees accumulate across a large sweep:
cd "$ZOD_ROOT"
git worktree remove ~/.cursor/worktrees/zod/pr-<N> && git branch -D pr-<N>
Keep the worktree only when the verdict is merge-with-changes and you have been asked to push the changes; AGENTS.md covers pushing back to a contributor's fork, including preserving their commits.
Sweeping in bulk
Seed the queue, then work it:
gh pr list -R colinhacks/zod --state open --limit 400 --json number,title,updatedAt
Resume by asking disk, not memory:
grep -l 'status: done' .triage/prs/*/results.md | wc -l
grep -h '^verdict:' .triage/prs/*/results.md | sort | uniq -c
nub --node .claude/skills/triage/scripts/reindex.mjs prs
reindex.mjs rebuilds an index table from frontmatter, so the index is derived and never hand-edited for PRs. Pass issues, prs, or advisories.
nub run dev scripts/triage-signal.ts regenerates .triage/signal-report.md, which ranks the whole open backlog by engagement, flags security content, and groups likely-duplicate clusters. Start a sweep there rather than at the top of gh pr list — roughly a third of the open backlog is duplicates, and the clusters collapse many tickets into one decision.
When parallelizing across sub-agents, give each agent a disjoint set of numbers and have each write only its own ticket directories. Never have two agents share a results file.