Skip to content
View as .md

Debugging

Start with the smallest probe that can separate the likely causes. Houses, threads, and streams usually go through the regular CLI or HTTP surface; arbe debug is reserved for checks that bypass permissions or environment resolution. Scripted proofs live in tests/README.md.

If local Vite rejects a package export that is already present in that package’s package.json, inspect the actual target before changing code. The director notice proof hit stale export resolution after a new core schema was added; a human restart of the shared dev server resolved it. Passing typechecks does not prove a running resolver has refreshed. Do not delete caches, alter dependencies, or restart a shared server without its owner’s approval.

Testing layers

Only unit tests run offline.

LayerRunNeeds
Unit / integrationbun run test (scope with --filter '@arbe/<pkg>')nothing
HTTP CRUD contractbun run tests/http-crud-proof.tsdev server + CLI auth
Env-bound dispatchbun run scripts/remote-dispatch.ts [<env>] [--local]environment + sandbox + house bot
LLM prompt suitehand an agent tests/README.mdrunning stack
Director scenariosbun tests/run-scenario.ts <n...> --house <id>run-scenarios skillCLI auth + a fresh test house
Browser / UIagent-browserdev server + browser profile
Task TUI layoutbun run apps/cli/src/task-tui/dev/frames.tsx 120,99,70,50 24nothing

frames.tsx renders the task TUI headlessly and prints each frame as text, so you can check responsive breakpoints, truncation and modal clipping without a real terminal — pass --keys=l,? to rotate the pane layout or open a modal first. Markdown bodies stay blank in those frames; the headless renderer does not paint them.

When website behavior is genuinely in doubt, prove it in the website rather than substituting a CLI result — but a browser pass is not owed to every visible change; see the skill’s “Visible change proof”. Browser profile setup and login recovery live only in the browser-testing skill.

First probes

Terminal window
arbe --local debug env # resolved URLs and LOCAL/REMOTE scope
arbe --local whoami # token validates against the resolved backend
arbe http /api/me # raw API; supports --jq, --status, -v, and stdin bodies
arbe env diagnose <env> # dispatch readiness: capability, keys, secrets, sandbox
arbe thread diagnose <id> # examine every director turn in the window; 2=any failed for good, 3=latest stalled
arbe thread trace <ref> # trigger, pickup, turn phases, spend, terminal outcome
arbe thread entries list <id> --follow
arbe thread entries list <id> # print a thread's messages (snapshot) — the reader
arbe thread entries read <id> # waits for a reply in flight, exits when the dispatch settles — not a reader
durable-stream read arbe-thread-<id> --offset -1 --live

arbe http, thread commands, and stream proxy reads use the signed-in user.

Testing as a bot identity

ARBE_CONFIG_DIR gives each tester its own CLI identity, so multiple agents (or one agent playing two roles) can act in the same house without sharing one token:

Terminal window
arbe agent key <bot> --name tester # mint a key for the bot
ARBE_CONFIG_DIR=~/.config/arbe-<bot> arbe login --token arbe_…
ARBE_CONFIG_DIR=~/.config/arbe-<bot> arbe house select <house-id>

Every prefixed command posts and reads as that bot. Note the permission asymmetry this exposes: a human member legitimately sees every house they belong to, a bot key sees only what the bot is a member of — so when probing cross-house access, check auth whoami first or you will attribute your own human reach to the bot. Raw durable-stream reads use DURABLE_STREAMS_SECRET from .env.local (bunx varlock reveal DURABLE_STREAMS_SECRET) and bypass the app’s permission checks.

Fast discriminators:

  • Connection refused or curl HTTP 000 on localhost → distinguish a stopped server from the agent harness blocking network access. If portless doctor is healthy or the URL works in the human’s browser, retry the same read-only request (or packages/skills/verify-arbe/doctor.sh) through the harness’s normal network approval mechanism before declaring the server blocked. A successful approved request identifies a sandbox restriction; do not restart the shared dev server.
  • 401 from whoami → run arbe login.
  • Local www with prod Supabase/Electric/streams → writes still hit production. arbe --local debug env warns about this split.
  • Installed CLI disagrees with the checkout → arbe --version shows its baked commit. Run from source (bun apps/cli/src/cli.ts …) or rebuild with arbe upgrade while proving a CLI fix.
  • A form submission returns the Cross-site … form submissions are forbidden body → CSRF, not authentication. apps/www/src/hooks.server.ts owns the guard; bearer requests are its deliberate exemption.

Verify a deployment

Check each runtime changed by the commit:

Terminal window
curl -fsS -H 'Cache-Control: no-cache' https://arbe.0sk.ar/api/version
fly logs -a arbe-backstage --no-tail | tail

arbe thread director (bare) ends with a backstage: line naming every worker that heartbeated in the last three minutes, or a ⚠ backstage offline warning. Read it before blaming a slow bot: a thread behind with no lease held and no beating backstage is a dead runner. Local bun run dev starts no worker: a healthy local HTTP preview cannot rescue a stopped production backstage.

The backstage has no HTTP service, so flyctl has no health check and will call a machine that came up stopped “in a good state” and exit 0. CI therefore runs verify-deploy after every deploy (every machine must be started), and the deploy job is serialized with a concurrency group: two pushes minutes apart once ran two deploys on top of each other, the second replaced the machine and left it stopped, and CI was green for 90 minutes of silence (2026-09-03). fly status -a arbe-backstage shows the machine state; fly machine start <id> -a arbe-backstage is the remedy.

/api/version identifies www only. The backstage version appears in its startup logs. Dispatch turns from packages/core/dispatch/ execute inside the backstage, so a core dispatch change ships with the backstage, not with www. A cross-package change may require both deployments.

After deploy, post a fresh entry, then inspect arbe thread trace <thread> and backstage logs. Static assets can remain edge-cached briefly; retry with Cache-Control: no-cache and Pragma: no-cache before concluding the new asset did not ship.

Backstage image fails to build

The Deploy backstage step fails with Unknown lockfile version while www and docs build green: the image’s bun is older than whatever wrote bun.lock. Keep FROM oven/bun:<version>-slim at or ahead of the bun that rewrites the lockfile (a local bun install on a newer bun upgrades it silently). www and docs going green hides the fallout — bots, workflows and every dispatch turn run in the backstage, so read the last green Build with gh run list before believing a dispatch change is live.

The docs are served at https://arbe.0sk.ar/docs through www’s DOCS service binding, so they need both workers. Deploy docs before wwwscripts/deploy.ts already orders them that way — and verify with curl -sI https://arbe.0sk.ar/docs/system/architecture/ plus curl -s https://arbe.0sk.ar/llms.txt | head -1.

Docs site builds stale

astro build caches per-package in apps/docs/node_modules/.astro, and that cache survives a dist/ wipe. An edit to astro.config.mjs — a remark plugin, base, site — can therefore appear to do nothing while the build reports success. Symptom: some pages carry the new output and some carry the old. Clear both caches before concluding the config is wrong:

Terminal window
rm -rf apps/docs/.astro apps/docs/node_modules/.astro apps/docs/dist
bun run --filter '@arbe/www-docs' build

svelte-check reports a file that does not exist

svelte-check --tsgo --incremental transpiles the Svelte tree to disk under apps/www/.svelte-kit/.svelte-check/ and reuses it. Change apps/www/tsconfig.json — especially files/include — and the overlay tsconfig written next to that cache keeps the old shape, so the check fails with Error: File '.../.svelte-check/svelte/<name>.d.ts' not found for a path nothing references any more. The source is correct; the cache is not.

Terminal window
rm -rf apps/www/.svelte-kit
bun run --filter '@arbe/www' check

X is not defined only in the browser

@arbe/core is shared by the server, the CLI, and the browser, and nothing marks which modules are browser-safe. Buffer, process, and node:* imports type-check fine (globally typed) and pass bun run test (bun has them), so they only fail once a real browser loads the page. Before importing a core module from browser code, check it for Node-only APIs; use web-standard ones (TextEncoder, crypto.subtle, Uint8Array) or keep the code behind a lib/server or .server.ts boundary.

A redirect() in a page load 500s on a cold load

apps/www/src/routes/+layout.ts sets ssr = false, so every +page.ts/+layout.ts load runs in the browser. A redirect() thrown from a page load on the first render of a URL does not navigate: the Redirect reaches handleError instead, the analytics console logs client exception: {status: 307, location: …}, and the visitor gets the 500 page. Redirect from the component with goto(href, { replaceState: true }) in an $effect, or from hooks.server.ts, not from a page load.

While you are there: deleting a route file Vite is currently serving crashes bun run dev with an ENOENT for the deleted path. Write the replacement first, delete second, and expect to restart the dev server if you get the order wrong.

CLI output truncates at 65536 bytes

bun buffers stdio into its node-stream shim, and process.exit throws away whatever the 64KiB pipe buffer has not swallowed — so a piped CLI command stops at exactly 65536 bytes (arbe-7ef2, arbe-cbd4). Three things make this hard to see, all measured on bun 1.3.14:

  • console.log is not exempt. It writes straight to the fd in a bare script, but is buffered like everything else once anything has touched process.stdout — which the CLI does on every run. It also never calls the stream’s write method, so a process.stdout.write shim never sees it.
  • Bun.spawn({ stdout: 'pipe' }) does not reproduce it. It returns the full bytes even from a truncating binary; the first fix for arbe-cbd4 shipped green and broken on such a test. Reproduce and assert through a real shell pipe: sh -c 'bun cli … | cat | wc -c'.
  • writableLength / needDrain read 0 right after a 70KB write that returned false, so any “is something pending?” gate never fires.

The fix is installSyncStdio() (apps/cli/src/output.ts), called once at the CLI entry: it routes both the streams and the console methods through a blocking write(2). Two traps if you touch it — bun leaves piped stdio non-blocking, so a full pipe raises EAGAIN (retry, don’t let it throw), and blocking defers SIGINT until the reader drains.

Website performance

Slow page opening or thread switching → measurement recipe. Start with bun run apps/www/scripts/profile-routes.ts --help; the profiler is read-only.

Keep the browser, fixtures, and source quiet during each run. Save JSON, compare with apps/www/scripts/compare-performance.ts, and repeat both versions before claiming a gain. Local Vite measurements do not establish cold-start or production performance.

Baseline, botlab fixtures, and evidence — including a repeat that disproved an apparent speedup.

Thread creation

POST /api/threads answers with a Server-Timing header naming every remote wait it did — scope (membership gate), parent (what createThread waited on for the resolved parent; ≈scope because the gate is handed over in flight), stream (durable-stream create), then insert / config / participants. Read it from curl, the browser network panel, or the bench harness:

Terminal window
cd apps/www
bun run scripts/bench-thread-create.ts --house <house_id> --bot <agent_uuid>
bun run scripts/bench-thread-create.ts --house <id> --bot <uuid> --runs 7 --json
bun run scripts/bench-thread-create.ts --origin https://arbe.0sk.ar --house <id> --bot <uuid>

Use a disposable house — every run creates a real thread, and they are deleted afterwards unless --keep. Three shapes are measured: house (house parent, pregenerated id, seeded participant), dm (agent parent with an always-reply config seed), and onboarding (bot, membership, config-seeded interview thread, and greeting). Onboarding bots are deleted after their threads, so repeated benchmarks do not consume the house spawn cap.

What the numbers said in Aug 2026 (arbe-76f9): each Postgres round trip costs 45–75 ms, and durable-stream create costs 450–1000 ms — more than everything else combined, on localhost and in production alike (raw PUT to Electric measures the same, so it is the provider’s cost, not ours). Anything that only reorders our own Postgres trips is therefore worth ~100-200 ms at most; the stream is the floor while thread creation waits on it (arbe-5660).

Repair localhost TLS

Run portless doctor first. bun run dev:trust retries Portless’s local certificate-authority trust; it does not repair an unhealthy proxy or route. A foreign/shared bun run dev process is a blocker: ask its owner rather than stopping or restarting it.

Dispatch

The diagnostic pipeline is:

conversation entry → wake_thread on configured queue → claim thread lease
→ read past decided_offset → decision → selected turn or nobody
→ tagged outcome → advance offset → catch up again

The mechanism is documented in dispatch and thread director. Debug from the durable evidence:

Terminal window
arbe thread diagnose <thread>
arbe thread trace <thread>
arbe thread entries list <thread> --json
# lease renewals, catch-up passes, decisions, and turn internals are backstage logs:
fly logs -a arbe-backstage --no-tail | tail

Picks and silence over new conversation write signal.thread_director.decision, whether chosen by rules or a model; a bot attempt then writes a tagged signal.thread_director.outcome. Bookkeeping-only windows advance decided_offset without another decision. Current selected-turn skip reasons are:

ReasonMeaning
filteredthe selected turn produced nothing more specific
budget_exceededthe house spend gate refused the selected turn
empty_replythe model completed without usable final text

arbe thread director <ref> distinguishes caught-up, behind, and live-lease states. arbe thread trace <ref> shows persisted decisions, outcomes, dispatch signals, and historical signal types. Reader copy for selected-turn failures and skips lives in reader-copy.ts.

Presence cues

Replay the durable signals that drive queued/thinking/working state:

Terminal window
arbe thread entries list <thread> --json | \
(cd apps/www && bun run scripts/replay-presence.ts)

The pre-signal “Deciding who replies…” and its stall message are client state, owned by composer-cues.ts. A stall reports client.error because no terminal or progress signal arrived after a durable post.

Dispatch latency

Start with arbe thread trace <thread> --house <house>. Each completed bot reports total trigger-to-completion time separately from its own turn; the outer runner is bookkeeping, not another reply. An explicit trigger entry id wins over later messages, so a human posting during generation cannot make the reply appear faster. Older inferred anchors say estimated; missing referenced entries stay unknown. Handoff is part of pickup, not an additional delay to add.

New director decisions record the effective human-burst pause and the measured decision duration. One participant bot defaults to no pause; explicit scope settings win, and multi-bot threads retain the app default. This pause is not measured sleeping time: worker pickup and reads can overlap that interval. New turns report every model call’s first nonempty text/thinking/tool delta and first text delta, measured from that call’s start; these are provider-stream observations, not the moment text appeared in the browser. No delta means not observed, while old turns say the timing was not recorded.

Use the trace’s PostHog link for the same turn’s usage analytics. System-prompt and tool-schema sizes are characters, not token estimates; message/tool counts explain why a clean conversation still has a large input. PostHog receives these counts and timings, never the exact prompt, answer, or request body. Historical exact context cannot be reconstructed from analytics. Check existing telemetry before proposing another capture store.

The busy bar follows durable prepare/generate/tools/save transitions, with an indeterminate active stage. The initial choose stage includes the human-burst wait; it does not claim the director has started a model call. Progress writes are included in Arbe processing, outside measured provider and tool time.

arbe thread trace <ref> pairs each persisted signal.thread_director.decision with dispatch start/completion and its tagged outcome. It reports pickup, handoff, reply-turn phases, tokens, and cost when those fields exist. Phase fields do not sum to the full duration; use fresh production samples rather than old medians when investigating latency. New modelMs measures provider-call wall time and toolsMs measures tool execution; historical llmMs includes both plus transcript writes. The completed-line Arbe remainder excludes both, but begins at dispatch start: pickup and handoff still contain earlier Arbe work and intentional debounce. Compare no-tool turns with the same model and record the debounce setting; do not treat a shorter configured pause as faster processing.

Before judging a scenario’s silence or speaker selection, verify each bot’s effective thread mode. Creating a thread with one bot seeds an always override even if that bot is ambient at house scope. The scenario runner must set and read back its declared modes on every new thread before posting; scenario 19 otherwise answers “Perfect” correctly for its actual configuration while failing the ambient expectation.

For a workflow instruction followed by no eligible bots, inspect the recorded author kind before changing prompts or adding mentions. Workflow instructions are authored by the house System agent and the assigned bot has an always override; resolving System as unknown prevents that bot from owing a reply.

Prompt caching defaults

Bot replies build their context in packages/core/dispatch/dispatch.ts, then tool-loop.ts calls pi-ai’s stream/streamSimple. They request usage, pass the thread id as sessionId, request cacheRetention: 'long', and send OpenRouter’s explicit x-session-id header. The header pins a conversation even before its first cache hit and supplies Z.AI’s upstream session affinity; prompt_cache_key alone is OpenRouter’s fallback routing key. Neither option guarantees a hit or forces an unsupported provider to cache.

Defaults by upstream provider (OpenRouter reference, checked 2026-09-13):

  • Z.AI (including GLM-5.3-Flash) and DeepSeek (including V4 Flash): automatic, no cache_control needed. The existing read-only evidence in arbe thread trace tplxswluvwvu --house xkwkqzyovpwm includes GLM reads of 4,608 tokens and DeepSeek reads of 7,424, interspersed with misses. Zero writes does not mean caching is disabled.
  • OpenAI, xAI, Moonshot: automatic; OpenAI requires at least 1,024 prompt tokens. OpenAI GPT-5.6+ also offers explicit breakpoints, but Arbe leaves its automatic caching enabled. Groq’s automatic caching is model-specific (Kimi K2).
  • Anthropic: requires opt-in. pi-ai already marks the system prompt, last tool, and conversation tail. Arbe’s long requests a one-hour TTL where supported, rather than the default five minutes; writes cost more than ordinary input. Minimum cacheable length depends on the Claude model.
  • Alibaba: explicit five-minute markers on the supported unsuffixed model ids listed in dispatch/reply.ts; pi-ai marks the system, last tool and conversation tail. Snapshot endpoints are deliberately excluded. The policy applies to both bundled and dynamically resolved models, without changing routing.
  • Gemini 2.5+: implicit caching, with model-specific minimum sizes; Arbe does not request its optional explicit cache storage or pay its storage charge. Other models/endpoints have no blanket caching guarantee.

The reusable prompt starts with identity, platform and authored instructions; changeable thread title/tags/status and per-turn directives follow them. No current clock or trace id is injected ahead of that prefix. Tool schemas have a stable order within a turn; changing enabled tools or reaching the forced final answer without tools changes the provider’s prefix. Sliding the conversation window also invalidates some cached history. Retention, eviction and routing can still cause misses.

Trace and usage ledger use pi-ai’s reported cached_tokens and cache_write_tokens, not estimates; ordinary input excludes both. A reported zero is not proof that the provider cannot cache. Do not infer a cache hit from cacheRetention or prompt size. After deploying backstage, send two nearby replies with the same bot and tools in a fresh test-house thread, then inspect arbe thread trace <thread> --house <test-house>: look for positive cache read, including later calls within a tool turn. Never post into the read-only evidence house above. The offline wire regression is dispatch/prompt-caching.test.ts; it verifies headers, Alibaba markers and usage mapping through the installed pi-ai adapter, not a simulated cache hit.

A turn is stalled or missing its terminal

thread diagnose returns the trigger entry and trace id. Correlate them with the durable task and its attempts:

Terminal window
# run from packages/
bunx supabase db query --linked "select task_id, state, attempts, max_attempts, enqueue_at from absurd.t_dispatch where task_name='wake' and params->>'threadId'='<thread>' order by enqueue_at desc" -o table
bunx supabase db query --linked "select run_id, attempt, state, claimed_by, claim_expires_at, failure_reason from absurd.r_dispatch where task_id='<task>' order by attempt" -o table
# The trace's cheap record lives in three real surfaces: `arbe thread trace <thread>`
# for per-turn LLM timings, `(cd apps/www && wrangler tail arbe)` for request-level
# logs from the www Worker, and the `usage_events` ledger (joined by trace_id) for
# spend. There is no `posthog-cli`; query the ledger directly:
bunx supabase db query --linked "select seam, key_source, model_ref, input_tokens, output_tokens, cost_usd, created_at from usage_events where trace_id='<trace>' order by created_at" -o table

Interpret the failure shape before changing a timeout:

  • Turn cap → the core runner aborts the selected turn and writes signal.dispatch.failed; the director records a failure outcome.
  • Paid decision failure → the wake retries when retryable; when the budget is spent, or the model names a bot that isn’t a candidate, the decision fails visibly (a failure outcome with the reason), never a silent nobody.
  • Lease loss → the stale pass stops before another append; another wake or the behind scan catches the thread up.
  • Lease contention → the second wake returns quietly; the pass holding the lease keeps reading until the thread is caught up, and the janitor is the backstop.
  • [thread-director.scan] stranded thread <id> → the five-minute janitor had to wake a thread, which means a wake was lost or a pass ended early. In a healthy system this line never appears; find out why that thread fell behind (arbe thread trace <id> shows the gap as a slow pickup marked ).
  • [thread-director.pass] with repeated settlement:stay → inspect the tagged outcomes for that decision; three transient failures or one permanent failure should let the offset advance.
  • Backstage process loss → the task claim and thread lease expire, then a fresh claim reconstructs settlement from the stream.

Turn caps live in packages/core/dispatch/reply-turn.ts; lease intervals live at the top of apps/backstage/src/thread-director-wake.ts. Redelivery reuses persisted decisions and counts tagged outcomes from the stream.

Spend is joined by the turn’s trace id. usage_events is the ledger; PostHog is the second sink. See observability and analytics.

Env-bound dispatch

Separate “did dispatch run?” from “could the sandbox answer?”:

Terminal window
arbe env diagnose <env>
arbe thread diagnose <thread>
arbe sandbox view <id> # live daytona state
arbe x -s <sandbox> -- bash -lc 'tail ~/arbe-pi-runner.log; tail ~/pi-run.log'

run_command results appear as pi.tool_result entries. For a complete proof, bun run scripts/remote-dispatch.ts [<env>] [--local] plants a sandbox-only nonce and requires the bot to read it back. See the manual runbook and Daytona.

Detached work runs on a child thread. Start diagnosis there; snapshots from thread entries list are safer for agents than --follow, which intentionally never returns. A child with no terminal should be checked against the runner logs above.

If a thread keeps reaching an old sandbox after rebinding environments, follow stale thread sandbox pointers.

Backstage

arbe wf runs heads its listing with the specific condition and its remedy — read it before reaching for a restart, because only two of the four cases are the backstage’s fault:

Header lineWhat it meansRemedy
runner offlineno heartbeat in three minutesrestart the backstage
beating but has not claimed N runsrunnable work sitting unclaimed — wedgedrestart the backstage
parked with no await anchorno wf_run_threads row, so nothing can emit the wake eventcancel and respawn; see arbe-4478
waiting on a person / a bot turnparked on ctx.awaitEvent; a human gate waits as long as it takes, a bot turn past an hour is lostopen the run thread
<task> has retried N timesuncapped or pathological retry ladderfix the task — a restart only makes it retry sooner

A high retried N× on a finished row is the same signal after the fact.

Terminal window
fly logs -a arbe-backstage --no-tail
bun run --filter '@arbe/backstage' deploy

Use the package deploy script. Bare fly deploy from apps/backstage has the wrong Docker build context. A post-deploy Cannot find module './x.js' crash means the partial image missed a transitive import; bun run check includes check:backstage-image, which identifies imports outside the Docker COPY set or missing production dependencies.

Director model experiments are process-wide backstage variants. Set both values explicitly, deploy/restart through the package script, then post a fresh botlab entry and read the durable proof:

Terminal window
fly secrets set -a arbe-backstage \
ARBE_DIRECTOR_MODEL=openai/gpt-5.6-terra \
ARBE_DIRECTOR_THINKING_LEVEL=high
bun run --filter '@arbe/backstage' deploy
fly logs -a arbe-backstage --no-tail | tail # directorModel=… directorThinking=…
arbe thread trace <fresh-thread> # model=… thinking=…

ARBE_DIRECTOR_MODEL accepts either openai/gpt-5.6-terra or openrouter/openai/gpt-5.6-terra form and normalizes both to the canonical startup/trace ref. The default off sends no reasoning parameter, leaving reasoning behavior to the provider default. Unset both secrets to return to the code-owned defaults (openai/gpt-5.6-terra, off): fly secrets unset -a arbe-backstage ARBE_DIRECTOR_MODEL ARBE_DIRECTOR_THINKING_LEVEL, then deploy/restart and verify the same two surfaces. Blank values also act as unset. Startup fails before polling on malformed nonblank values, unknown models, or unsupported model/thinking combinations. Only paid decision witnesses show model=… thinking=…; model-free decisions say no llm time and omit the configured variant.

Production backstage on Fly consumes all background work, including work submitted through localhost. bun run dev runs only www/API and docs; apps/www/tmp/dev.log contains local HTTP logs. Read worker activity with fly logs -a arbe-backstage and durable decisions/outcomes with arbe thread trace <thread> --house <house>. Trace records the executing backstage SHA, including failures; unstamped older records say unknown. A later deployment does not change this attribution.

Check local HTTP/auth readiness separately from production worker heartbeat. Before proving changed background code, deploy it from main, then submit fresh work in a test house. Test houses isolate records, not deployment effects. An invalid queued payload must fail before execution; the worker log identifies its kind/id, validation details, running SHA, and deploy-first hint. A schema-valid payload can still depend on changed semantics, so a passing shape check does not replace coordinated deployment.

Every failed command, surprising flag, and source-only discovery is debugging-surface feedback. Fix the command, proof, skill, or canonical doc so the next investigation starts with the discriminator.