Get Started
Reference

Agent CLI contracts

Stable JSON envelope, exit codes, and markdown packet format for thespider-agent findings, wait, and refine

Machine contract for thespider-agent findings, wait, and refine: the JSON envelope, exit codes, and markdown packet. Scripts and coding agents should pin this page, not scrape the human tables.

What is stable

Three contracts ship together and are versioned independently:

ContractVersion markerWhere
JSON envelopeschemaVersion: 1every --json document
Exit codesthis pagefindings *, wait, and refine
Markdown packetthespider_packet: 1the first line of the packet front matter

schemaVersion policy

Within a schemaVersion, fields may only be added. No field is removed, renamed, or changes type. A value that is known to be absent is null; keys are never omitted. Array fields are [], never null. Any other change bumps schemaVersion.

The envelope is an allow-list the CLI builds, not a pass-through of the server body. Field names match findings export (snake_case).

thespider_packet policy

Within thespider_packet: 1, headings may only be appended after the last one. Renaming, reordering, removing, or changing the nesting level of any heading requires bumping thespider_packet. Prose inside a section may change freely; the heading set and its order may not.

stdout purity

With --json, stdout carries exactly one JSON document terminated by a single newline, and nothing else. All human text, progress ticks, warnings, and errors go to stderr. A non-zero exit still emits one JSON document on stdout. Without --json, stdout carries only the command's primary output (the human table, or the markdown packet); diagnostics still go to stderr.

wait's per-poll progress line goes to stderr in both modes, as does findings copy's "copied" notice. clipboard's OSC 52 escape sequence is also written to stderr, so a consumer parsing stderr should expect it.

Commands

thespider-agent [--auth <PATH>] <COMMAND>

  findings                       # alias: finding
    list      [flags]
    show      <ID> [flags]
    triage    <ID> --status <STATUS> [flags]     # alias: set-status
    packet    <ID> [flags]
    copy      <ID> [flags]

  wait --campaign <ID> [flags]

  refine   [flags]                                  # see the refine section and /docs/guide/refine

findings list

FlagTypeDefaultNotes
--jsonboolfalseEmit the envelope; the stdout purity rule applies
--status <CSV>stringnoneThe eight statuses plus open, closed, actionable
--severity <CSV>stringnonecritical, high, medium, low, info, plus reserved none
--openboolfalseSugar for --status open; conflicts with --status and --actionable
--actionableboolfalseSugar for --status actionable; conflicts with --status and --open
--campaign <ID>integernoneFindings sighted in this campaign; also selects the campaign's profile
--branch <NAME>stringnone
--bucket <CODE>stringnone
--category <CODE>stringnone
--query <TEXT> / -qstringnone
--sort <MODE>stringprioritypriority, updated_desc, updated_asc, title
--profile <P>stringproject defaultsecurity, bugs, or all
--limit <N>integer100Above 500 is a usage error, never a silent clamp
--offset <N>integer0
--allboolfalseComplete filtered set via /findings/export; conflicts with --limit/--offset

findings show / packet / copy

<ID> positional, --json, --profile. packet and copy additionally take --redact.

findings triage

<ID> positional and --status <STATUS> (required), plus --reason, --note, --commit (repeatable), --decider, --json, --profile. Exactly one finding per invocation — callers loop.

wait

--campaign <ID> (required), --timeout <SECS> (default 1800; 0 means no timeout), --interval <SECS> (default 15; the real delay adds 0–20 % jitter), --fail-on <CSV> (default critical,high), --profile, --json.

--fail-on none and --fail-on off are different. none is the reserved severity token meaning "no severity was assigned", so --fail-on critical,high,none blocks on unclassified findings too. off — legal only as the entire value — disables the findings verdict, after which wait can only exit 0 or 4.

open vs actionable

Resolved on the server as "everything except these statuses," so an unknown legacy status still lands on the right side.

TokenMeaningUsed by
openNot fixed or false_positivedashboard count, findings list --open
actionableNot wont_fix, false_positive, accepted, or fixedwait exit 0/1, the PR gate's block set
closedfixed or false_positivefindings list --status closed
wait can exit 0 while findings list --open still returns rows. Those rows are accepted risks. Use actionable to decide whether to do work; use open to report state.
thespider-agent findings list --actionable --severity critical,high --json | jq '.pagination.total'  # work queue
thespider-agent findings list --open       --severity critical,high --json | jq '.pagination.total'  # dashboard number

Alias membership is defined server-side in exactly one place and the CLI forwards the token verbatim, so the two can never drift.

Exit codes

CodeNamewaitfindings list/show/packetfindings copyfindings triageMeaning
0OKSuccess. For wait: campaign terminal, failed == 0, no actionable finding matches --fail-on.
1FINDINGSFindings verdict only. Campaign terminal and at least one finding sighted in it is actionable and matches --fail-on.
2(reserved — never produced)Emitted only by clap's own argument parser and by the legacy gate / update --check. The one exit code that never writes a JSON document — see the error envelope.
3TIMEOUT--timeout elapsed before the campaign became terminal.
4DEGRADEDCampaign terminal with failed > 0 and no actionable --fail-on match. Nothing may be called clean.
5USAGECaller's input is wrong: client-side validation failure, or HTTP 400 / 404 / 409 / 413 / 422.
6OPERATIONALEnvironment failure: HTTP 401 / 402 / 403 / 429 / 5xx, exhausted retries, unparseable response, missing .thespider/config.toml, unresolvable org or project.
7INTERRUPTEDSIGINT/SIGTERM received while waiting.
8CLIPBOARDPacket rendered successfully but the clipboard write failed. The packet is on stdout (human mode) or in packet (JSON mode).

wait evaluates its verdict in this order:

interrupted        -> 7
timeout            -> 3
matches > 0        -> 1        (even when failed > 0; `degraded: true` is still set)
failed  > 0        -> 4
otherwise          -> 0

A findings verdict deliberately outranks degradation: an agent scripted on "fix the findings first" must not skip real criticals because one slice failed. Degradation always stays visible through degraded, campaign.failed, and a stderr warning.

wait exit 0 does not mean "no open findings" — it means "nothing actionable". A campaign whose only critical finding has been triaged accepted exits 0 while findings list --open --severity critical --json still reports it. Scripts that want the dashboard number must ask for it explicitly with --status open.

wait is not gate

Do not mix their exit codes in one script. gate 2 means "poll again." wait uses 3 for timeout and never emits 2 (clap already uses 2 for bad flags). gate 1 is any new finding; wait 1 respects --fail-on. A triage of accepted or wont_fix quiets both. See PR gate.

JSON envelope

Every --json document starts with the same envelope:

{
  "schemaVersion": 1,
  "command": "findings.list",
  "generatedAt": "2026-07-28T14:03:11Z",
  "agentVersion": "0.3.0",
  "ok": true,
  "exitCode": 0,
  "server": "https://thespider.xyz",
  "project": { "id": 42, "slug": "payments-api", "selector": "acme/payments-api" },
  "profile": "security",
  "profileSource": "project-default"
}

command is one of findings.list, findings.show, findings.triage, findings.packet, findings.copy, wait. profileSource is flag, campaign, project-config, project-default, or fallback. generatedAt is UTC RFC 3339 at second precision.

findings.list

{
  "filters": { "status": "open", "severity": "critical,high", "campaignId": null,
               "branch": null, "bucket": null, "category": null, "q": null, "sort": null,
               "since": null },
  "pagination": { "limit": 100, "offset": 0, "returned": 100, "total": 240,
                  "truncated": true, "mode": "page" },
  "summary": { "total": 240, "open": 180, "severe_open": 12, "needs_retest": 3 },
  "findings": [ { "id": 501, "": "" } ]
}

filters.status echoes the expanded token ("open" / "actionable"), never the flag name. filters.since echoes the --since selector exactly as sent (a campaign id as a string, or the ISO date/timestamp) — a set that was cut off at a point in time is meaningless without saying which. truncated is offset + returned < total — the CLI never silently paginates. In --all mode pagination.limit is null, mode is "all", and truncated is false.

Each finding record always carries every one of these keys:

FieldTypeNull?
idintegerno
fingerprintstringno
canonical_titlestringno
canonical_severitystringyes
statusstringno
bucket_code, bucket_namestringyes
discovered_in_campaign_idintegeryes
campaign_namestringyes
primary_filestringyes
reportsintegerno
models, passes, profiles_seenstringno ([])
first_seen_commit, last_seen_commit, fixed_at_commitstringyes
first_seen_branch, last_seen_branchstringyes
updated_atstringyes
web_urlstringno

How pagination.total and summary.* relate

Both are computed server-side over the same filtered set — the WHERE clause you asked for. pagination.total is summary.total. summary.open and summary.severe_open additionally apply the summary predicate status NOT IN ('fixed','false_positive'), which is exactly the open predicate. Consequences, all true by construction:

  • With --status open (or --open): pagination.total == summary.total == summary.open, and all three equal the number the web dashboard and campaign.open_findings show.
  • With --status actionable (or --actionable): pagination.total == summary.total == summary.open too, because actionable ⊂ open — but the number is smaller than the dashboard's, by exactly the count of accepted + wont_fix findings. That gap is the answer to "how many risks have we deliberately accepted?"
  • With --status closed: summary.open == 0.
  • With no --status: summary.total is everything and summary.open is its open subset.

In --all mode the export endpoint carries no summary block, so the CLI computes summary.open, summary.severe_open and summary.needs_retest from the exported records themselves. They are numbers, including 0 — a --all export is the complete filtered set, so the counts are knowable and a null there would wrongly read as "unqueried". summary.total is the export's own total. Two more --all caveats: reports, models, passes, and primary_file are derived from the export's nested reports[] (count; distinct sorted model_code; distinct sorted pass_code; lexicographically lowest cited path), and profiles_seen is derived from report provenance rather than from sightings.

findings.show

finding carries the list record plus merged_into_finding_id, fixed_at_branch, notes, and created_at. Then reports[], sightings[], history[], fixes[], and false_positives[].

The four rollups are scoped differently here than on findings list. On show, reports, models, passes, and profiles_seen are derived from the reports[] and sightings[] this response carries, and those are filtered to the resolved profile. On list the same-named columns count the whole finding regardless of profile. So for a finding sighted under both profiles, findings list --profile security may report "reports": 4, "models": ["anthropic/claude-opus-5","anthropic/claude-sonnet-5"] while findings show --profile security reports "reports": 2, "models": ["anthropic/claude-sonnet-5"] for the same id. Pass --profile all to make the two agree.

One element of reports[]:

{
  "id": 702, "ordinal": 1, "title": "", "severity": "high",
  "confidence": null, "cvss_v4_score": null, "cvss_v4_vector": null,
  "evidence": "", "exploit_scenario": null, "impact": "", "recommendation": "",
  "model_code": "anthropic/claude-sonnet-5", "model_name": "Claude Sonnet 5",
  "pass_code": "http", "pass_name": "HTTP surface",
  "bucket_code": "core", "profile": "security",
  "slice_id": 9, "slice_status": "completed",
  "categories": ["authz"],
  "cwes": ["CWE-89"],
  "files": [ { "path": "src/db.ts", "line": 42 } ],
  "reproduction_steps": null, "expected_behavior": null,
  "actual_behavior": null, "root_cause": null, "test_suggestion": null
}

categories, cwes, and files come from the server's structured arrays and are copied verbatim — see What the CLI never does. The five bug fields are emitted always (null under the security profile) so keys are never omitted.

The other four arrays are answers to four different questions. Each is [] when empty, never null, and every key below is always present.

sightings[] — where and when this finding has been seen. One row per campaign that sighted it.

{ "campaign_id": 81, "profile": "security", "branch": "main",
  "commit": "9f2c1ab", "first_seen_at": "2026-07-21T09:12:04Z", "last_seen_at": "2026-07-28T14:00:51Z" }

campaign_id is an integer; profile, branch, commit, first_seen_at, and last_seen_at are string|null. profiles_seen on the finding record is the distinct sorted profile set of this array.

history[] — the triage timeline, newest first. This is what the web UI's history panel shows, and what findings triage appends to.

{ "id": 3311, "from_status": "new", "to_status": "accepted",
  "decider": "thespider-agent@build-01", "actor_user_id": null,
  "notes": "risk accepted for the 2026-Q3 release", "created_at": "2026-07-28T14:00:51Z" }

id is an integer, actor_user_id is int|null (null for a CLI-driven change), and from_status, to_status, decider, notes, created_at are string|null. decider is the attribution string — history[0].decider is how you check who last moved a finding.

fixes[] — remediation records linked to this finding.

{ "id": 44, "status": "proposed", "description": "parameterized the query",
  "pr_url": "https://…/merge_requests/128", "ticket_url": null,
  "commits": ["9f2c1ab", "c4d5e6f"], "resolved_at": "2026-07-27T18:22:10Z" }

id is an integer, commits is a string[] ([] when none), and status, description, pr_url, ticket_url, resolved_at are string|null.

false_positives[] — dismissals, each with the commit it was recorded against.

{ "id": 12, "reason": "test fixture, not reachable in production",
  "decider": "alice@acme.example", "applies_until_commit": "9f2c1ab",
  "created_at": "2026-07-24T11:03:00Z" }

id is an integer; reason, decider, applies_until_commit, and created_at are string|null. applies_until_commit is the commit the dismissal was recorded through — the web UI renders it as "Applies through <commit>".

A merged finding is reported, never silently redirected: merged_into_finding_id names the canonical id, and findings triage refuses to write against a tombstone.

findings.triage

{ "change": { "finding_id": 501, "from_status": "new", "to_status": "triaged",
              "decider": "thespider-agent@build-01", "actor_user_id": null,
              "reason": null, "notes": null, "commits": [] } }

decider is never null: it defaults to thespider-agent@<host> (or thespider-agent when no hostname is available) and is overridable with --decider. It is what the web UI history timeline attributes the change to.

findings.packet and findings.copy

packet (the full markdown, as a string) plus a finding object with id, fingerprint, canonical_severity, status, merged_into_finding_id, and web_url. findings.copy adds copied (bool) and bytes (int). packet is present even when copied is false, so a JSON consumer always has the payload.

copied means a clipboard method accepted the document, which is not always a verifiable fact. A local clipboard tool (pbcopy / wl-copy / xclip / xsel) exits non-zero on failure, so its success is checked. The fallback — an OSC 52 escape sequence written to the terminal — has no reply of any kind, so all the CLI ever learns is that the bytes were written; the terminal may have ignored them. When OSC 52 is the only method that claimed the copy, human mode also prints the packet to stdout and says so on stderr, so you are never left with neither a clipboard nor a document. copied stays a plain bool in schemaVersion: 1; --json consumers should read packet, which is always there.

wait

{
  "campaign": { "id": 81, "status": "completed", "profile": "security",
                "slice_count": 12, "pending": 0, "running": 0, "completed": 11,
                "failed": 1, "retryable": 0 },
  "degraded": true,
  "reason": "terminal",
  "waited_seconds": 312,
  "polls": 21,
  "fail_on": ["critical", "high"],
  "verdict": {
    "status_filter": "actionable",
    "matched": 3,
    "truncated": false,
    "findings": [
      { "id": 501, "canonical_severity": "critical", "canonical_title": "SQL injection in the payments reader", "status": "new", "primary_file": "src/db.ts" },
      { "id": 517, "canonical_severity": "high", "canonical_title": "Missing authz check on /admin/export", "status": "new", "primary_file": "src/routes/admin.ts" },
      { "id": 522, "canonical_severity": "high", "canonical_title": "Reflected XSS in the search page", "status": "needs_retest", "primary_file": "web/app/search.vue" }
    ]
  },
  "summary": { "total": 3, "open": 3, "severe_open": 3, "needs_retest": 0 }
}

campaign.retryable is the subset of campaign.failed that a claim will still be offered, and it is part of what "terminal" means: wait returns only when pending, running and retryable are all zero, which is the same "still outstanding" set the server uses for campaign completion, the CI gate and the daemon's claimable poll — so wait can never report a campaign finished while workers are still claiming into it. A failed slice whose attempts are spent counts in failed but not in retryable: it is terminal and degraded (exit 4), never clean. A server older than this field omits it and it reads 0.

reason is terminal, timeout, or interrupted. verdict.status_filter is "actionable" whenever the verdict query ran — the one predicate schemaVersion: 1 uses — and null when it did not, so the envelope states which predicate produced matched rather than leaving a consumer to assume it is the dashboard's open count. fail_on is [] when the verdict was disabled with --fail-on off.

Branch on reason before trusting any counter. When the loop stops before the campaign is terminal — a timeout or an interrupt, possibly before the very first poll returned — nothing was counted, so every campaign.* field except id is null, and verdict.* and summary.* are null as described below. degraded stays a plain false on that path: it means "nothing is known to have failed", not "nothing failed". A null means unqueried; a 0 means counted and genuinely zero.

verdict.findings carries up to 20 projected rows in the full findings.list record shape documented above — each element in the example is abbreviated, but the real document carries every key of that table. verdict.matched is the complete count of matching findings, and verdict.truncated is true exactly when matched exceeds the 20-row cap, i.e. when verdict.findings does not list everything matched counted. The array is never null; it is [] when nothing matched.

The verdict query only runs on a terminal campaign, and unobtained numbers are null, not 0. When reason is timeout or interrupted, or when --fail-on off disabled the verdict, no verdict query was ever issued — so verdict.status_filter, verdict.matched, verdict.truncated and every field of summary are null, and verdict.findings is []. A zero in those fields always means "counted, and it was zero". Branch on reason (or the exit code) before you read them.

refine

command is refine. Flag semantics live on Refine. Same exit-code numbers as wait, different predicates:

CodeNamerefine meaning
0OKThe selected backlog (actionable ∩ filters) is empty.
1FINDINGSWork remains after --max-iterations. With --list: work remains now.
3TIMEOUTA retest wait exceeded --retest-timeout.
4DEGRADEDAny retest campaign terminated with failed > 0 or without a conclusion.
5USAGEBad input — including 409 commit_mismatch (both SHAs named) and unbound/unenabled models.
6OPERATIONALEnvironment failure.
7INTERRUPTEDSIGINT/SIGTERM; the worktree is kept and resume hints are printed.

Precedence when several conditions apply: 7 > 3 > 4 > 1 > 0.

The payload extends the shared envelope fields with:

FieldTypeMeaning
iterationsUsedintegerIterations actually run (0 under --list).
findingsSelectedintegerFindings selected by the filters for this run.
fixedNowintegerFindings a completed retest concluded fixed during the run.
stillOpenintegerSelected findings still actionable at exit.
campaignIdsintegerEvery retest campaign created ([] when none).
worktreestring|nullThe worktree path used, or null (--list, --in-place).
branchstring|nullThe original checkout branch recorded on campaigns — never the worktree branch.
degradedbooleanAny terminal retest campaign failed or reached no conclusion.
elapsedMsintegerWall-clock duration of the run.

Error envelope

Emitted on every non-zero terminating path the command itself reaches — including transport and auth failure (an unreadable or malformed auth store is an operational envelope with exit 6, not a bare stderr line).

Exit 2 is the one exception. Argument parsing happens in clap, before the command body runs: an unknown flag, a missing value, or a value your --severity / --status / --fail-on list rejects makes clap print usage text to stderr and exit 2 with zero bytes on stdout. There is no JSON document to parse. Branch on 2 before you read stdout:

thespider-agent findings list --severity bogus --json > out.json; rc=$?
case "$rc" in
  0) jq '.pagination.total' out.json ;;
  2) echo "bad arguments (usage text is on stderr; out.json is empty)" >&2; exit 2 ;;
  *) jq -r '.error.message' out.json >&2; exit "$rc" ;;
esac
{
  "schemaVersion": 1,
  "command": "wait",
  "generatedAt": "2026-07-28T14:03:11Z",
  "agentVersion": "0.3.0",
  "ok": false,
  "exitCode": 6,
  "error": { "kind": "operational", "code": "invalid_token",
             "httpStatus": 401, "message": "HTTP 401: invalid_token" }
}

error.kind is usage or operational. error.code is the server's error code, or one of the CLI-originated codes auth_store_unreadable, missing_project_config, invalid_limit, reason_required_for_false_positive, commit_requires_fixed_status, merged_finding_not_triageable, export_too_large, interrupted, timeout, or null.

auth_store_unreadable is the code behind the auth-failure guarantee above: an auth.toml that is missing, unreadable, or malformed produces this envelope with exitCode: 6, rather than the bare exit 1 an unhandled error would give.

project, profile, and the payload keys are omitted on the error envelope when they could not be resolved. This is the single documented exception to "keys are never omitted": the error envelope guarantees only schemaVersion, command, generatedAt, agentVersion, ok, exitCode, and error.

Worked examples

# The AC1 one-liner: open high-severity findings as JSON, no web UI.
thespider-agent findings list --open --severity critical,high --json | jq '.pagination.total'

# The agent work queue, with ids.
thespider-agent findings list --actionable --severity critical,high --json | jq -r '.findings[].id'

# Did we see everything, or is the page truncated?
thespider-agent findings list --json | jq '.pagination | {returned, total, truncated}'

# Block on a campaign, then branch on the documented exit code.
thespider-agent wait --campaign 81 --json > wait.json; rc=$?
case "$rc" in
  0) echo "clean" ;;
  1) jq -r '.verdict.findings[].canonical_title' wait.json ;;
  2) echo "bad arguments; usage text is on stderr and wait.json is empty" >&2; exit 2 ;;
  3) echo "timed out; still running" ;;
  4) echo "degraded: $(jq '.campaign.failed' wait.json) slice(s) failed" ;;
  *) jq -r '.error.message' wait.json >&2; exit "$rc" ;;
esac

Markdown packet

findings packet <ID> writes a version-marked markdown document to stdout; findings copy <ID> puts the same document on the system clipboard. It is rendered client-side from the finding detail endpoint — there is no packet endpoint — and the renderer version is pinned to the binary version.

Front matter

---
thespider_packet: 1
finding_id: 501
fingerprint: "sha256:…"
project: "acme/payments-api"
project_id: 42
profile: "security"
status: "new"
severity: "high"
merged_into_finding_id: null
redacted: false
generated_by: "thespider-agent/0.3.0"
generated_at: "2026-07-28T14:03:11Z"
server: "https://thespider.xyz"
web_url: "https://thespider.xyz/projects/42/findings?finding=501"
---

Keys are fixed, always present, in this order. Values are emitted as JSON scalars so model-authored text cannot break the block.

Ordered heading list

Every heading is emitted unconditionally and in this exact order. A section with no content renders _none_, so a consumer can rely on the heading set rather than on presence.

#HeadingContents
1# {canonical_title}the finding title
2## Identityfinding id, fingerprint, project, profile, bucket, status, severity, first/last seen commit and branch, report count, web_url
3## Merge status_not merged_, or the canonical id to triage instead
4## Locationprimary file, then a deduplicated (path, line)-sorted list of every citation
5## Reportsone row per report in r.id order
6## Evidenceper-report blocks, r.id ascending
7## Exploit scenariosame
8## Impactsame
9## Recommendationsame
10## Bug detailsthe five bug fields; _none_ under the security profile
11## Sightingscampaign, profile, branch, commit, first/last seen
12## Triage historyfrom → to, decider, actor, notes, timestamp

Reports appear in ascending report id — the order the server returns — and are never re-sorted client-side. A file with line: null renders as a bare path; a file with a line renders path:line, so a real line 0 is never confused with "no line".

Model-authored text is treated as hostile. Inside a free-text block, three kinds of line are backslash-escaped, because each of them can change the heading set a markdown parser sees:

  • a level-1 or level-2 ATX heading (# …, ## …) — it would forge a section boundary. Level 3 and below are left intact, so a model's ### Details still renders as a subheading;
  • a fenced-code opener (three or more backticks or tildes) — the packet emits no fences of its own, so a single unbalanced one would leave a fence open for the rest of the document and every later heading would parse as literal code text;
  • a setext underline (a line of only = or only -) — it would promote the line above it to an H1/H2 that is not in the list, with no # anywhere.

File citations — including the primary file: line — are rendered inside a code span wide enough to survive a backtick in the path; a path containing a newline is emitted as a JSON string literal, so one citation always stays on one line.

Table cells and the H1 title get one more treatment: the packet is written straight to your terminal, so every control character in them is rendered as its \u{…} escape rather than passed through. A finding title carrying \x1b]52;c;… or \r\x1b[2K cannot retitle your window, silently overwrite your clipboard, or erase and fabricate a line of output.

Redaction

--redact is off by default. The evidence for the highest-severity finding class — a hardcoded credential — is the secret, so masking by default would destroy the packet's purpose, and the data already lives on the server. With --redact the assembled document is passed through the same secret-masking rules run --redact uses and the front matter records redacted: true. The packet is never truncated.

What the CLI never does

  • No local findings cache. Every call is a fresh server read.
  • No writes into .thespider/config.toml.
  • One finding per triage call. Loop if you have many.
  • open / closed / actionable are sent verbatim. Membership is server-side.
  • categories, cwes, and files come from structured arrays, not string splitting.
One degraded case: against a server older than this release the three structured arrays do not exist, and the CLI reports [] for categories, cwes, and files rather than failing. Upgrade the server to get report provenance in findings show and the packet.

Next steps

  • The agent — every command and flag.
  • Triage — what each status means for the dashboard and the gate.
  • PR gate — why gate and wait are different tools.
  • Findings export — the server-side bulk contract.
Copyright © 2026