triage — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited triage (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Third leg of the defending-code loop (/threat-model → /vuln-scan → `/triage` → /patch). Consumes /vuln-scan's VULN-FINDINGS.json, but any scanner output works, so this is also a valid standalone entry point for an existing backlog.
Adversarial triage of raw security-scanner output. Does four jobs: verify each finding is real, deduplicate across runs and scanners, rank survivors by what the attacker actually gains against a named asset times how easily they reach it — not by the scanner's claimed severity — and route each to a component owner. Output is a short, ranked, owned list instead of a raw dump.
Invoke with /triage <findings-path> [--auto] [--votes N] [--repo PATH] [--fp-rules FILE].
Arguments (parse from $ARGUMENTS; positional \$1/\$2 expansion is not stable across runtimes):
JSON files, a VULN-FINDINGS.json, a pipeline results/<target>/<ts>/ directory, or a markdown report.
--auto: skip the interview and use defaults. Default mode isinteractive.
--votes N: verifier votes per finding (default 3; use 1 for a quickpass, 5 for high-stakes batches).
--repo PATH: path to the target codebase, read-only (default cwd).Verification needs source access; the skill stops with an error if the cited files aren't reachable.
--fp-rules FILE: append the contents of FILE to the verifier'sexclusion-rule list (Phase 3a). Use for org-specific precedents: "we use Prisma ORM everywhere — raw-query SQLi only", "k8s resource limits cover DoS", etc. Plain text, one rule per line or paragraph.
--fresh: ignore any existing checkpoint in ./.triage-state/ and startfrom Phase 0. Without this flag the skill resumes from the last completed phase if a checkpoint is present.
Tools: Read, Glob, Grep, Write, Agent, AskUserQuestion. Bash is permitted only for git, find, wc, ls, jq, codegraph explore (read-only index query, Phase 3a), and python3 .claude/skills/triage/scripts/checkpoint.py (checkpoint I/O).
Do not execute target code. No building, running, installing dependencies, or sending requests. A proof-of-concept that accidentally works against something real is unacceptable, and "couldn't write a working PoC" is weak evidence of non-exploitability. Every conclusion comes from reading source. This applies to the orchestrator and every subagent: the triage-verifier / triage-ranker agent definitions carry it in their system prompts and read-only tool lists; for any general-purpose spawn (the 2b dedupe agent, or the fallback path), include the constraint in the Agent prompt. For high-confidence HIGH findings, recommend a human-built PoC as a follow-up instead.
Do not reach the network. No package-registry lookups, CVE-database queries, or upstream-commit fetches.
On large finding batches a full run can exhaust context or hit rate limits mid-way — particularly Phase 3, which spawns candidates × votes verifiers. Phase state persists to ./.triage-state/ so a fresh /triage session can resume without re-asking the interview or re-spawning verifiers.
All checkpoint I/O goes through python3 .claude/skills/triage/scripts/checkpoint.py (atomic writes, JSON-validated). Never use the Write tool for progress.json directly. Never pass payload via heredoc or stdin; target-derived strings could collide with the heredoc delimiter and break out to shell. The Write→--from pattern keeps repo-derived bytes out of Bash argv.
State files in ./.triage-state/:
progress.json — single source of truth for resume position:{"status": "running"|"complete", "phase_done": N, "shards_done": [...]}. Resume decisions read ONLY this file, never a glob of phase*.json or shard files (stale files from a prior run must not be trusted).
phaseN.json — data payload for phase N (schemas at the tail of each phasesection below).
_chunk.tmp — transient payload buffer; overwritten before everysave/shard/append call.
Start of run — resume check. Bash: python3 .claude/skills/triage/scripts/checkpoint.py load ./.triage-state
status == "absent" OR "complete", OR --fresh in $ARGUMENTS →fresh start. Bash: python3 .claude/skills/triage/scripts/checkpoint.py reset ./.triage-state, then proceed to Phase 0.
status == "running" with phase_done == N → resume. Read./.triage-state/phase0.json through phaseN.json in order (and any shard_*.json files listed in shards_done), merging keys into working state (later files override earlier — checkpoints may be deltas). Print Resuming from checkpoint: Phase N complete (./.triage-state/phaseN.json), and skip directly to Phase N+1.
End of every phase N. Two tool calls:
./.triage-state/_chunk.tmp containing the phase's outputJSON (schema at the tail of each phase section).
python3 .claude/skills/triage/scripts/checkpoint.py save ./.triage-state <N> <name> --from ./.triage-state/_chunk.tmpEnd of run. After writing TRIAGE.json and TRIAGE.md, Bash: python3 .claude/skills/triage/scripts/checkpoint.py done ./.triage-state 6
From $ARGUMENTS: extract the findings path (first positional), --auto flag, --votes N (default 3), --repo PATH (default .), --fp-rules FILE (default none). If no findings path was given, ask for one and stop. If --fp-rules was given, Read the file now and carry its contents as context.extra_fp_rules for injection into the Phase 3a verifier prompt.
Unless --auto was passed, use AskUserQuestion to gather context that shapes verification and ranking. Batch into one or two calls of up to four questions. Expect free-text answers via "Other"; the multiple-choice options are prompts, not constraints.
Round 1 (single AskUserQuestion call):
Environment, single-select)What kind of system are these findings from, and where does untrusted input enter it? Options: Internet-facing web service (HTTP is untrusted), Internal service (callers are authenticated peers), Library / SDK (caller is the trust boundary), CLI / batch tool (operator inputs trusted, file inputs not), Embedded / firmware (physical access in scope). Reachability is judged against this boundary; "command injection from env var" is a true positive in a multi-tenant web service and a rule-8 false positive in an operator CLI.
Threat model, multi-select)What does a worst-case attacker look like for this system, and what must never happen? Free text is best. Options: Unauthenticated remote code execution, Tenant-to-tenant data leakage, Privilege escalation to admin, Supply-chain compromise of downstream users, Denial of service against a paid SLA, Compliance-scoped data exposure (PII / PCI / PHI). Phase 4 boosts findings that map onto a stated threat.
Scoring, single-select)How should severity be expressed in the output? Options: Derived HIGH/MEDIUM/LOW from preconditions (default), CVSS v3.1 vector + base score, CVSS v4.0 vector + base score, OWASP Risk Rating (likelihood x impact), Organization bug-bar (describe in Other). The precondition rule is always computed; this controls what severity_label additionally shows.
Noise tolerance, single-select)When verifiers disagree, which way should ties break? Options: Precision: drop anything not majority-confirmed (fewer FPs, may miss real bugs), Recall: keep split votes as needs_manual_test (more to review, fewer misses), Ask me per-finding when it happens.
Round 2 (conditional): if the threat-model answer was empty or generic, or the scoring answer was Organization bug-bar, ask one targeted follow-up.
Record the answers as a context dict carried through every phase and echoed in the output under triage_context.
When --auto is set, do not call AskUserQuestion. Use:
untrusted; flag trust-boundary assumptions explicitly in rationale.`
If {repo}/THREAT_MODEL.md exists (or the user points at one), Read it and extract its three severity inputs:
context.purpose: 1-2 sentences from Section 1 — what the system isFOR and who uses it. Purpose governs severity: an outcome that is the system's job is not an impact — "open redirect" on a URL shortener, "executes user-supplied code" on a CI runner, "serves stranger-uploaded files" on a file-sharing host.
context.assets: the Section-2 asset table, one bullet per row:"<asset> — <description> (sensitivity: <level>)".
context.gating_questions: the Section-6 open-question bullets — theseare the unresolved facts that gate severity (mounted secrets? auth in front? multi-tenant?).
Both are passed into the Phase-4a ranking prompt (ASSET INVENTORY and SEVERITY-GATING QUESTIONS blocks). Section 3 entry points remain scoping input only. If no THREAT_MODEL.md, set both to empty — the interview's threat_model answers are separate and unaffected.
Checkpoint: Write tool → ./.triage-state/_chunk.tmp:
{"phase": 0, "context": {mode, environment, purpose, threat_model, assets, gating_questions, scoring, noise_tolerance, votes_per_finding, repo, findings_path}}Then Bash: python3 .claude/skills/triage/scripts/checkpoint.py save ./.triage-state 0 interview --from ./.triage-state/_chunk.tmp On resume past Phase 0, the interview is not re-asked; context is restored from this file.
Turn the input into a flat findings[] list with stable ids, regardless of source format.
Inspect the findings path:
**/*.json and **/*.jsonl. Recognizedcontainers, in priority order:
VULN-FINDINGS.json (a {findings: [...]} container): read.findings[].
reports/bug_*/report.json or reports/manifest.jsonl (an executionharness's pipeline output — e.g. the defending-code reference pipeline): one finding per bug_NN. Map crash.crash_type → category, verdict.severity_rating → severity, the prose report → description, crash file from the ASAN top frame → file/line.
found_bugs.jsonl: one finding per line.*.json whose top level is a list of objects, or an objectwith a findings/results/issues/vulnerabilities array: that array.
--- rules; for eachsection, extract file, line, category, severity, description by pattern (File:, Line:, Severity: labels or path:NN spans). Best-effort; mark source_format: "markdown_heuristic".
If nothing parseable is found, stop and report what was seen.
For each raw record, build a finding dict. Pull what's present; never guess what's absent. Field map (source-key aliases → canonical):
| Canonical | Also accept |
|---|---|
file | path, location.file, filename, ASAN top-frame file |
line | line_number, location.line, lineno |
end_line | line_end, location.end_line, endLine, line_range end |
source_ref | source, taint_source, entry_point (as file:line) |
sink_ref | sink, taint_sink, dangerous_call (as file:line) |
threat_ids | threat_id, threats, rule_tags (list of ids) |
category | type, cwe, rule_id, crash_type, vulnerability_class |
severity | severity_rating, level, priority, risk |
title | name, summary, message |
description | details, report, body, evidence |
exploit_scenario | attack_scenario, poc, reproduction |
preconditions | requirements, assumptions |
recommendation | fix, remediation, mitigation |
scanner_confidence | confidence, score, certainty (normalize to 0.0-1.0) |
threat_ids is which threat-model rows the scanner's scope came from (/vuln-scan stamps them). Carry it through; Phase 4's threat_match is computed independently from the operator's own threat model, and the two disagreeing is signal, not an error to reconcile at ingest.
source_ref / sink_ref are the scanner's data-flow evidence — the file:line where untrusted input enters and the file:line where it is used unsafely (/vuln-scan emits both; most third-party scanners emit neither, and a taint-tracking one emits both). Ingest them verbatim. Never synthesize a ref from the description, the file:line, or a guess: dedup and verification below anchor on these, and a manufactured ref asserts a flow nobody traced. A finding whose two refs are equal is a context-free finding (hardcoded secret, weak constant), not a malformed one. end_line likewise: absent means "the scanner named one line", not "the region is one line" — the difference matters in 2a.
Attach to every finding:
id: f001, f002, ... in ingest order. If scanner_confidence ispresent on most findings, order ingest by it descending so high-signal findings get verified (and surface in partial output) first; otherwise keep source order. This is a scheduling prior only — it does not affect verdicts.
source: relative path of the file it came from, plus source format.missing_fields: list of canonical fields that were absent. If file ismissing or does not resolve under --repo, the finding is unlocatable: it skips dedup and verification and is emitted directly with verdict: false_positive, verify_verdict: needs_manual_test, confidence: 0, refute_reasons: ["doesnt_exist"], rationale: "no source location in input; cannot verify statically; human review required". Never emit a confident verdict on a finding you could not locate, and never let it absorb or be absorbed by dedup.
Resolve --repo (default cwd). For the first 5 findings with a file, check the path resolves under the repo. Try, in order: (a) repo/file as-given; (b) file as an absolute or cwd-relative path; (c) repo/file with common prefixes stripped from file (src/, app/, ./, or the repo's own basename, e.g. myapp/server.py with --repo myapp). Record which resolution worked, then apply it to every finding individually. A finding whose path still resolves to nothing on disk is unlocatable — exactly the 1b semantics (mechanical false_positive / doesnt_exist / needs_manual_test, no verifier votes, excluded from dedup), with the rationale naming the unresolvable path. One bad scanner row must not cost verifier votes or stall the batch. If NO finding resolves, stop: tell the user verification needs source access and the cited files aren't reachable, and suggest a --repo value based on the longest common suffix you can see.
Checkpoint: Write tool → ./.triage-state/_chunk.tmp:
{"phase": 1, "context": {...}, "findings": [ {normalized finding dicts with id/source/file/line/category/...} ], "path_resolution": "<which of a/b/c worked>"}Then Bash: python3 .claude/skills/triage/scripts/checkpoint.py save ./.triage-state 1 ingest --from ./.triage-state/_chunk.tmp
Collapse repeats so duplicate findings don't each burn N verifiers.
Cluster findings that share both of:
file (after path normalization), ANDcategory (case-insensitive, punctuation stripped),and then meet any one of these three location tests:
line values within 10 of each other. Both-missingmatches; one-side-missing does NOT (a line-less record must not absorb a located one).
a.start <= b.end AND b.start <= a.end, where a finding's range is [line, end_line] and end_line falls back to line when absent. This catches the same region re-detected at different boundaries — a scanner reporting a whole vulnerable function as 40-120 and another reporting 95-130 inside it are one finding, though their start lines are 55 apart and rule 1 misses them.
source_ref AND a sink_refand both refs match. Same entry, same sink, same category is one bug at any line distance; the line a scanner chose to anchor on is arbitrary along a flow. This test alone is exempt from the same-file gate above: one scanner anchors a finding on its source file and another on its sink file, and matching refs pin the flow harder than file does.
Ref conflict blocks a deterministic collapse. If both findings carry a sink_ref and the two differ, do NOT collapse them here even when rule 1 or 2 matches — hand the pair to 2b. Two distinct sinks a few lines apart in one function are two fixes, and a ±10 window is wide enough to swallow the second one silently. This is what makes any line window unsafe on its own; the refs are what make the collision detectable without spending a model on it.
Within each cluster, the canonical is the record with the fewest missing_fields; ties break to lowest id. (Because source_ref / sink_ref are canonical fields, this already prefers the record that carries data-flow evidence.) Every other member gets verdict: duplicate, duplicate_of: <canonical id>, and is removed from the working set. Record duplicate ids on the canonical as absorbed: [...]; where the canonical lacks a ref that an absorbed member carries, copy that ref onto the canonical — the evidence survives the collapse even though the record does not.
Spawn ONE Agent with subagent_type: "general-purpose" and the prompt in `references/prompts.md` § Dedupe prompt (Phase 2b) — Read it now; fill CANDIDATES with one line per surviving finding, inside the nonce-tagged <untrusted_data> block (§ {nonce}: fresh nonce, both tags, sanitize closing-tag lookalikes).
Parse GROUP: lines. For each, mark the listed dup ids with verdict: duplicate, duplicate_of: <canonical>, append them to the canonical's absorbed, and drop them from the working set.
Carry forward candidates[] = the surviving canonicals. Report the split in the terminal — "dedup: N deterministic, M semantic, K canonical" — so a run that collapsed half its input on the ±10 window is visible as such, not as a quiet drop in the verifier bill.
Checkpoint: Write tool → ./.triage-state/_chunk.tmp:
{"phase": 2, "context": {...}, "findings": [ {all findings; duplicates carry verdict/duplicate_of} ], "candidates": ["f001", "f003", "..."]}Then Bash: python3 .claude/skills/triage/scripts/checkpoint.py save ./.triage-state 2 dedup --from ./.triage-state/_chunk.tmp
For each candidate, N independent adversarial verifiers re-derive the claim from the code and vote. Each verifier's stance is "find any reason this is wrong." Each starts from the code at the cited location, not the scanner's description, and never sees the other verifiers' reasoning (shared context propagates blind spots).
The full verifier instructions are the system prompt of the `triage-verifier` agent definition (../../agents/triage-verifier.md relative to this skill directory — same layout in the repo and in a plugin install). As the agent's system prompt they sit in the prompt-cache prefix every verifier in the batch shares; each spawn's prompt carries only the per-finding block in 3b. The per-spawn tail template (context header + finding block) lives in `references/prompts.md` § Verifier tail (Phase 3a).
Call-graph context (only when the target is indexed). If <repo>/.codegraph/ exists, spare each verifier the from-scratch caller hunt: per candidate, run codegraph explore --path <repo> "<cited function, or file:line>" (the --path form matches the allowed-tools rule; a cd <repo> && prefix does not), trim to a compact excerpt — direct callers/callees of the cited function and any entry-point paths, ≤ ~40 lines, no verbatim source bodies — and add it to that candidate's tail as the optional CALL GRAPH CONTEXT block (template in references/prompts.md). All N votes for a candidate share the same excerpt. If there is no index, or the command fails, omit the block and say nothing — never index the target yourself. The verifier is instructed to treat the graph as a starting point and read the actual call sites; this changes where it looks first, not what counts as evidence.
For each finding in candidates[], build N Agent calls (N = --votes, default 3) with subagent_type: "triage-verifier" (plugin installs: defending-code:triage-verifier) and description: "verify {id} vote {k}/{N}". Fallback: if neither agent name resolves, Read the agent definition file above and spawn general-purpose verifiers with its body pasted above the per-spawn tail.
Always set `subagent_type`; never fork. Omitting subagent_type forks the orchestrator, and a fork inherits the full conversation context: every other finding's description, the scanner's prose, and any prior verifier results. That defeats verifier independence and re-introduces the inherited-framing failure mode this phase exists to prevent. Each verifier must start with a fresh, empty context and receive only the verifier instructions plus the single finding under review. The same applies to the ranking subagents in 4a.
Put all verifier Agent calls in a single assistant message so they run concurrently. Do not set run_in_background; you need the final text, not an async handle. If len(candidates) * N exceeds ~40, shard into sequential batches of ~40, but keep each batch a single message.
Prompt size at scale — fallback path only. When the triage-verifier agent type resolves, the ~1200-word instructions live in the shared cached system prompt and per-spawn cost stays flat at any batch size — never switch to the compact form there. On the general-purpose fallback, every spawn re-pays the full instructions inline; when candidates * votes > ~50 on that path, use the compact form in `references/prompts.md` § Compact verifier (fallback path) instead (same procedure and output contract, prose stripped).
Findings with a file but no line get one verifier vote regardless of --votes (a file-level sweep is expensive and doesn't benefit from voting).
If any Agent call returns `status: "async_launched"` instead of the verifier's text, the runtime backgrounded it (some runtimes do this automatically for large parallel batches). Pick one recovery and use it for the whole batch:
verifier's VERDICT block from its notification result as it lands. Do not end your turn until every vote is accounted for.
the missing verifiers in a fresh Agent batch (smaller shard size, e.g. 10) and use the synchronous results. The same recovery applies to the dedupe subagent in 2b and the ranking subagents in 4a.
For each candidate, parse the trailing block from each of its N verifiers (tolerate code fences and whitespace). If a verifier errored, timed out, or produced no parseable VERDICT block, re-spawn it once. If the retry also fails, count that vote as cannot_verify with confidence: 0 and note "verifier_error" in refute_reasons. The remaining N-1 votes still decide.
Build:
vote_breakdown: `{"true_positive": x, "false_positive": y,"cannot_verify": z}`
confidence: mean CONFIDENCE across votes that agree with the majority,rounded to one decimal.
exclusion_rule: the modal EXCLUSION_RULE among FALSE_POSITIVE votes,else null.
refute_reasons: sorted unique REFUTE_REASON values from FALSE_POSITIVEvotes.
first_links: unique FIRST_LINK values across all votes (reachabilityaudit trail).
rationale: the RATIONALE from the highest-confidence vote on thewinning side, verbatim.
Decide `verdict`:
verdict: true_positive. Proceeds to Phase 4.verdict: false_positive. Skips Phase 4.precision → verdict: false_positive; append"(split vote, dropped under precision policy)" to rationale.
recall → verdict: true_positive withverify_verdict: needs_manual_test. Proceeds to Phase 4.
ask → collect all split findings and present them inone AskUserQuestion call at the end of Phase 3 (header: id + title, options: keep / drop), then apply the user's choices.
Run-level sanity guard. After tallying, count verifier_error votes across the whole batch. If they exceed a third of all votes cast, or any sharded batch returned zero parseable verdicts, stop before Phase 4: the verifier harness itself is failing (agent type not resolving, tool permissions, wrong --repo), and verdicts produced under those conditions are noise, not evidence. Report the error rate and the first raw failure to the user instead of writing a TRIAGE.json where "everything errored" silently reads as "everything was verified". A batch with a sub-threshold error count proceeds, but carries the count into the summary as verifier_errors — never launder an error into a false-positive.
Build confirmed[] = candidates with verdict == true_positive.
Checkpoint: Write tool → ./.triage-state/_chunk.tmp:
{"phase": 3, "context": {...}, "findings": [ {all findings with verdict/vote_breakdown/confidence/refute_reasons/first_links/rationale/exclusion_rule} ], "confirmed": ["f001", "..."]}Then Bash: python3 .claude/skills/triage/scripts/checkpoint.py save ./.triage-state 3 verify --from ./.triage-state/_chunk.tmp
This is the most expensive checkpoint. When len(candidates) * votes exceeds ~40 and verifier spawns are sharded into sequential batches, additionally checkpoint per candidate as its votes are tallied:
./.triage-state/_chunk.tmp = that finding's post-tally dict.python3 .claude/skills/triage/scripts/checkpoint.py shard ./.triage-state <id> --from ./.triage-state/_chunk.tmp
On resume at phase_done == 2, the Phase-3 entry point reads progress.json:shards_done (default [] — do not glob shard files on disk; stale shards from a prior run may exist), loads the corresponding shard_{id}.json files, and spawns verifiers only for candidates[] ids from phase2.json that are NOT in shards_done. Once every candidate is in shards_done, write the consolidated phase3.json checkpoint as above.
Recompute severity as impact-on-a-named-asset times exploitability — what the attacker gains in this deployment, times how easily they reach it — rather than from the category name, and judge the scanner's claimed severity separately. Verification and severity are independent judgments; "this is real" must not inflate into "this is critical," and easy reach must not inflate an empty asset into a HIGH.
Spawn one Agent per confirmed finding (subagent_type: "triage-ranker"; plugin installs: defending-code:triage-ranker; all in one message). The full ranking instructions are that agent definition's cached system prompt; each spawn's prompt is only the tail in `references/prompts.md` § Ranker tail (Phase 4a) — {REPO_PATH}, the {context.*} values, and the per-finding fields. Fallback: if neither agent name resolves, Read ../../agents/triage-ranker.md (relative to this skill directory) and spawn general-purpose rankers with its body pasted above the tail.
For each confirmed finding, parse the block and attach preconditions (replacing any scanner-supplied list), access_level, asset, impact, exploitability, severity (recomputed), severity_label, deployment_condition (null if "none"), threat_match, severity_alignment, verify_verdict, and append RANK_RATIONALE to rationale (separated by a blank line from the Phase-3 rationale).
For findings that did NOT reach Phase 4 (false_positive, duplicate, unlocatable): set severity: null, verify_verdict: null, severity_alignment: null, preconditions: [], asset: null, impact: null, exploitability: null, deployment_condition: null.
Checkpoint: Write tool → ./.triage-state/_chunk.tmp:
{"phase": 4, "context": {...}, "findings": [ {all findings with severity/severity_label/preconditions/access_level/threat_match/severity_alignment/verify_verdict} ]}Then Bash: python3 .claude/skills/triage/scripts/checkpoint.py save ./.triage-state 4 rank --from ./.triage-state/_chunk.tmp
Tag each confirmed true-positive with the most specific component or owner inferable. For each finding in confirmed[], stop at the first hit:
--repo for CODEOWNERS, OWNERS,.github/CODEOWNERS, docs/CODEOWNERS. If found, match the finding's file against its patterns (last match wins). Hint: "CODEOWNERS: <pattern> -> <owner(s)>".
--repo is a git checkout, rungit -C {REPO} log --format='%an' -n 50 -- "{file}" and tally the author lines yourself — no shell pipeline (sort/uniq/head are not whitelisted). Hint: "top committer: <name> (<n>/<total> recent commits); no CODEOWNERS entry".
CODEOWNERS or git history"`.
Attach as owner_hint. State the source so confidence is clear; a bare username is less useful than "component: auth/; no CODEOWNERS entry; top committer jsmith (14/20 recent commits)". For non-true-positive findings, set owner_hint: null.
Checkpoint: Write tool → ./.triage-state/_chunk.tmp:
{"phase": 5, "context": {...}, "findings": [ {all findings with owner_hint} ]}Then Bash: python3 .claude/skills/triage/scripts/checkpoint.py save ./.triage-state 5 route --from ./.triage-state/_chunk.tmp
Order all findings by:
verdict: true_positive, then duplicate, then false_positive.severity HIGH > MEDIUM > LOW, then confidencedescending, then severity_alignment descending.
id../TRIAGE.jsonShape: `references/output-format.md` § TRIAGE.json — Read that file once at the start of Phase 6; it holds every template this phase writes.
Every input finding appears exactly once (duplicates reference their canonical via duplicate_of). Do not silently drop anything. Do not print this JSON to the terminal; write to file only.
./TRIAGE.mdReviewer-facing report. Build it incrementally. Do NOT emit the whole file in one Write. One chunk per finding; a stalled chunk loses that one section, not the file.
Step 1 — header. Write tool → ./TRIAGE.md (clobbers any prior file) containing only the title block, summary, and ## Act on these heading:
Template: references/output-format.md § TRIAGE.md header.
Step 2 — per finding. For each true_positive in severity order:
./.triage-state/_chunk.tmp containing ONE finding's section:Template: references/output-format.md § Per-finding section.
python3 .claude/skills/triage/scripts/checkpoint.py append ./TRIAGE.md --from ./.triage-state/_chunk.tmp
Repeat for each true_positive.
Step 3 — footer. Write tool → ./.triage-state/_chunk.tmp containing the Dropped table, then checkpoint.py append it the same way:
Template: references/output-format.md § Dropped table.
Checkpoint (final): Bash: python3 .claude/skills/triage/scripts/checkpoint.py done ./.triage-state 6 The next invocation's resume check sees status == "complete" and starts fresh.
Under ~12 lines:
Template: references/output-format.md § Terminal summary.
Emit the Next step line only when at least one finding survived as a true positive; with zero confirmed findings there is nothing to patch, so say that instead. /patch writes inert diffs to ./PATCHES/ — it never applies them.
Smoke test against fixtures/canary-findings.json, its expected verdicts, and the spot checks for real scanner output: references/testing.md. Read it when validating a change to this skill.
Why the pipeline is shaped this way (checkpoint files, dedupe-before-verify, the narrow Bash scope, CANNOT_VERIFY, the one-step threat boost, no network): references/design-notes.md. Read it before changing a rule, or when an operator asks why the skill does something.
Upstream attribution (Apache-2.0) and lineage: references/sources.md § Provenance.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.