Agent CLI contracts
thespider-agent exposes findings and campaign completion to agents, shell scripts, and CI without
the web UI. This page is the machine contract: what is stable, how it changes, and what every field
and exit code means.
What is stable
Three contracts ship together and are versioned independently:
| Contract | Version marker | Where |
|---|---|---|
| JSON envelope | schemaVersion: 1 | every --json document |
| Exit codes | this page | findings * and wait |
| Markdown packet | thespider_packet: 1 | the 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 isnull; keys are never omitted. Array fields are[], nevernull. Any other change bumpsschemaVersion.
The envelope is CLI-owned: record fields are an explicit allow-list built by the agent, not a passthrough of the server body, so an ordinary server addition can never become an accidental breaking change for a skill. Field names deliberately match the server and findings export vocabulary verbatim (snake_case) — there is no renaming layer to drift.
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 bumpingthespider_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]
findings list
| Flag | Type | Default | Notes |
|---|---|---|---|
--json | bool | false | Emit the envelope; the stdout purity rule applies |
--status <CSV> | string | none | The eight statuses plus open, closed, actionable |
--severity <CSV> | string | none | critical, high, medium, low, info, plus reserved none |
--open | bool | false | Sugar for --status open; conflicts with --status and --actionable |
--actionable | bool | false | Sugar for --status actionable; conflicts with --status and --open |
--campaign <ID> | integer | none | Findings sighted in this campaign; also selects the campaign's profile |
--branch <NAME> | string | none | |
--bucket <CODE> | string | none | |
--category <CODE> | string | none | |
--query <TEXT> / -q | string | none | |
--sort <MODE> | string | priority | priority, updated_desc, updated_asc, title |
--profile <P> | string | project default | security, bugs, or all |
--limit <N> | integer | 100 | Above 500 is a usage error, never a silent clamp |
--offset <N> | integer | 0 | |
--all | bool | false | Complete 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.Status predicates: open vs actionable
Each token is resolved server-side as a complement — "every status except these" — not as a fixed list, so a status outside the standard vocabulary (a legacy workspace import can carry one) still lands on the correct side of every token.
| Token | Question it answers | Definition | Used by |
|---|---|---|---|
open | "What is not yet resolved?" | every status except fixed, false_positive (of the eight: new, triaged, accepted, wont_fix, needs_retest, still_vulnerable) | findings list --status open / --open; matches summary.open, the web dashboard, and campaign.open_findings by construction |
actionable | "What still needs a human decision?" | every status except wont_fix, false_positive, accepted, fixed (of the eight: new, triaged, needs_retest, still_vulnerable) | wait's exit-0/1 verdict; findings list --status actionable / --actionable; the exact complement of the set the PR gate suppresses |
closed | "What is resolved?" | exactly fixed, false_positive | findings list --status closed; the exact complement of open |
wait can report clean while findings list --status open returns rows. That is correct.
It means: nothing actionable; N accepted risks remain open.accepted and wont_fix are
deliberate risk decisions — they keep a finding open (it is still counted on the dashboard) but
remove it from the actionable set, which is what lets an agent loop converge after a triage
decision instead of failing forever.Rule of thumb: 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
| Code | Name | wait | findings list/show/packet | findings copy | findings triage | Meaning |
|---|---|---|---|---|---|---|
0 | OK | ✔ | ✔ | ✔ | ✔ | Success. For wait: campaign terminal, failed == 0, no actionable finding matches --fail-on. |
1 | FINDINGS | ✔ | — | — | — | Findings 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. |
3 | TIMEOUT | ✔ | — | — | — | --timeout elapsed before the campaign became terminal. |
4 | DEGRADED | ✔ | — | — | — | Campaign terminal with failed > 0 and no actionable --fail-on match. Nothing may be called clean. |
5 | USAGE | ✔ | ✔ | ✔ | ✔ | Caller's input is wrong: client-side validation failure, or HTTP 400 / 404 / 409 / 413 / 422. |
6 | OPERATIONAL | ✔ | ✔ | ✔ | ✔ | Environment failure: HTTP 401 / 402 / 403 / 429 / 5xx, exhausted retries, unparseable response, missing .thespider/config.toml, unresolvable org or project. |
7 | INTERRUPTED | ✔ | — | — | — | SIGINT/SIGTERM received while waiting. |
8 | CLIPBOARD | — | — | ✔ | — | Packet 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
The two commands publish disjoint exit tables and must not be mixed in one script.
gate2means "ask again later; CI should retry with backoff." Awaitreturning2would mean the opposite — "I waited and it never terminated" — sowaituses3 = TIMEOUT.gate's2is a catch-all that also swallows unrecognized status strings, so it is not a clean pending signal and cannot be inherited.gate1is severity-blind;wait1is severity-scoped by--fail-on(defaultcritical,high). Same number, different predicate.
There is exactly one deliberate alignment: wait's status predicate (actionable) is the exact
complement of the set the PR gate suppresses, so a triage decision that quiets the gate also quiets
wait. They still disagree about severity scoping and about every code except 0 and 1.
2 is reserved because clap exits 2 on its own argument-parsing failures: if wait used 2 for
a timeout, then wait --campain 5 (a typo) would be indistinguishable from a real one.
JSON envelope
Every --json document starts with the same envelope:
{
"schemaVersion": 1,
"command": "findings.list",
"generatedAt": "2026-07-28T14:03:11Z",
"agentVersion": "0.2.3",
"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 },
"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.
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:
| Field | Type | Null? |
|---|---|---|
id | integer | no |
fingerprint | string | no |
canonical_title | string | no |
canonical_severity | string | yes |
status | string | no |
bucket_code, bucket_name | string | yes |
discovered_in_campaign_id | integer | yes |
campaign_name | string | yes |
primary_file | string | yes |
reports | integer | no |
models, passes, profiles_seen | string | no ([]) |
first_seen_commit, last_seen_commit, fixed_at_commit | string | yes |
first_seen_branch, last_seen_branch | string | yes |
updated_at | string | yes |
web_url | string | no |
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 andcampaign.open_findingsshow. - With
--status actionable(or--actionable):pagination.total == summary.total == summary.opentoo, becauseactionable ⊂ open— but the number is smaller than the dashboard's, by exactly the count ofaccepted+wont_fixfindings. That gap is the answer to "how many risks have we deliberately accepted?" - With
--status closed:summary.open == 0. - With no
--status:summary.totalis everything andsummary.openis its open subset.
In --all mode the export endpoint carries no summary block, so summary.open,
summary.severe_open, and summary.needs_retest are null while 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[].
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": ["opus","sonnet"] while
findings show --profile security reports "reports": 2, "models": ["sonnet"] 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": "sonnet", "model_name": "Sonnet",
"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 },
"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 }
}
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.
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.
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.2.3",
"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.2.3"
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.
| # | Heading | Contents |
|---|---|---|
| 1 | # {canonical_title} | the finding title |
| 2 | ## Identity | finding 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 | ## Location | primary file, then a deduplicated (path, line)-sorted list of every citation |
| 5 | ## Reports | one row per report in r.id order |
| 6 | ## Evidence | per-report blocks, r.id ascending |
| 7 | ## Exploit scenario | same |
| 8 | ## Impact | same |
| 9 | ## Recommendation | same |
| 10 | ## Bug details | the five bug fields; _none_ under the security profile |
| 11 | ## Sightings | campaign, profile, branch, commit, first/last seen |
| 12 | ## Triage history | from → 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### Detailsstill 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 database or cache. Every command performs a fresh server read.
- No file writes outside a user-requested output path. Nothing findings-related is written into
.thespider/config.toml. - One finding per
triagecall. There is no batch triage endpoint; callers loop. - No client-side expansion of
open/closed/actionable. The token is sent verbatim and the membership lives server-side in exactly one place. - No delimiter parsing.
categories,cwes, andfilescome from the server's structured arrays, so a path containing a newline, a path whose last segment is literally:42, and a category code containing", "all round-trip byte-faithfully.
[] 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
gateandwaitare different tools. - Findings export — the server-side bulk contract.