zirvDocs

DocsSupervision (zirv ctx)

Harness proxy

README.md, lines 651–1000View source

The harness proxy is an opt-in intake decision made once, before a session launches: from the request text alone it decides intent, complexity, risk, execution mode (Direct/Bounded/Orchestrated), a workflow to start (or none), the orchestrator harness+model, and the worker tier, then hands that decision to zirv chat. It implements part of the execution-profile seam tracked in issue #537; the deterministic classifier, team compiler, workflow definitions and gates it sits in front of are unchanged.

Decider chain. decide() computes the deterministic baseline first. With decider = "typesafe" and a credential present, one metadata-only Jev call (coarse counts, classes and flags; never request text, repository names or paths) may advise clarification and its category; with no Jev credential the helper-model chokepoint runs, then the baseline. The baseline measures no diff at intake, so it floors complexity by the request's own size: 120+ words or 3+ enumerated items is at least bounded, 300+ words or 8+ items at least substantial (never architectural) -- without it every multi-part spec landed on the cheap seat. The eleven-question classification schema and live battery below describe the helper's merge behavior. The historical Jev path and current helper model answer eleven questions — intent, complexity, risk, workflow, needs_clarification, and six additive domain tags (security, data, docs_only, devops, architecture, frontend) — never execution, seat tier or worker tier directly: a live battery found those unreliable, and any many-option seat/ tier question never cleared the confidence floor. A field whose model answer is not decisive keeps the baseline value instead, with a recorded reason, except complexity/risk when it is the margin alone that fell short: these resolve to the higher of the two most probable levels, and that level is taken only when it is above the baseline. This keeps a near-tie deterministic whichever level wins, and avoids the 2026-09-21 case, when an exhaustive multi-system investigation landed on a single haiku seat because a bounded/substantial near-tie (confidence 0.57, margin 0.14) fell to the text-only baseline. A confidence below min_confidence still keeps the baseline: that is the model having no opinion rather than a tie between two candidates, and escalating it over-sized the live battery's bump-timeout and ambiguous cases from trivial/direct/cheap into bounded work on a standard seat. The recorded reason names what actually happened — resolved upward to <label> or kept baseline. A model answer is decisive when BOTH its confidence is at or above min_confidence AND its margin (the gap between its top and runner-up probability; for a yes/no question, distance from the maximally uncertain 0.5, doubled) is at or above min_margin. A completed 2026-09-18 measurement (497 live calls across the intake battery) found flipped intent/workflow/architecture answers topped out at margin 0.14, while their own stable answers sat at 0.17 or higher — confidence alone missed this, since a flipped answer's confidence was not noticeably lower than a stable one's. The gate is a determinism tool, not an accuracy tool: one stable complexity answer for perf-investigation sits at margin 0.18-0.24 and is simply wrong, and one genuinely ambiguous prompt still flipped at margin 0.54 — the same request body giving the same answer twice is the property this floor buys, not correctness. The shared Jev client backs that further with its own decision cache: an identical request body (hashed with SHA-256) served twice within [jev] cache_ttl_secs (default a week, 0 disables it) returns the exact same stored answer instead of asking Jev again, at zero additional cost. complexity and risk are always merged with max(model, baseline) — a monotonic floor, so a model decision can raise them but never lower them — and validation is recomputed from the merged complexity and risk, so a raise always propagates. execution, seat tier, worker tier and seat role then derive entirely from that merged complexity: Trivial → direct/standard/single seat, Bounded → bounded/standard/single seat, Substantial or Architectural → orchestrated/orchestrator seat — floored upward when risk reaches High (at least Bounded) or the request names a security surface, and a Direct execution always clears any chosen workflow back to none. execution alone never routes to the cheap seat tier any more (cheap-seat overhead fix, wrapper-overhead benchmark 2026-09-24): a live 85-run replay found Direct sending 16/28 runs to haiku, which then took 2-5x the turns of a sonnet run on the same code task for +133% wall time and no cost saving, so Direct now maps to the same Standard seat tier Bounded already used; worker_tier is unaffected. An orchestrated seat runs the frontier tier only for Architectural complexity or High-or-worse risk; a Substantial one stays on the standard tier. The baseline's own deterministic pick of a workflow to start (selection::select_definition) fires only at Substantial or Architectural complexity — the same 2026-09-24 benchmark found intake starting a workflow for every Bounded-complexity headless run even though headless agents never read it, adding ~11s/run; an explicit "start a <name> workflow" request still starts one at any complexity, exactly as before. When a workflow starts, the proxy layer names its id so the seat can run zirv workflow status and follow the current step. The winning decision is then validated against the live roster (an unready harness falls back to the baseline harness, its model re-derived for the decision's own seat tier; an unknown workflow id falls back to the baseline) before it is applied. The committed tests/fixtures/proxy/jev-battery.json documents the expected ruling (execution, complexity, workflow, seat tier) per request class, verified against the real API; TYPESAFE_API_KEY=... cargo nextest run --run-ignored only jev_live_battery replays it against Jev directly (it is #[ignore]d by default, like every other live provider contract), running each case TWICE and asserting the two merged decisions are identical to each other as well as to the recorded ruling — a flip between the two runs is reported as an instability, distinct from an outright mismatch.

Domain tags. A substring keyword match (the deterministic classifier's own security-domain detection) misses phrasing that never uses one of its fixed keywords — "rotate the shared token" names none of security/auth/ permission/credential/secret — so a model decider is also asked directly, one yes/no question per tag. A true (>= 0.5) answer that also clears the margin floor (see "Decider chain" above; a yes/no question has no separate confidence to check, so only its margin gates it) adds that tag to decision.domains; tags only ever accumulate, and a confident security tag applies the same risk/execution floor the keyword trigger already does (risk at least High, execution at least Bounded). Shown in zirv ctx proxy's human/--json output, the proxy: announce line, and the [zirv proxy] prompt layer as domains: security, data — omitted everywhere when empty.

Clarification. needs_clarification always keeps the model's raw yes/no reading, but a consumer only acts on it when it is ALSO decisive at min_margin (a confident-looking but thin-margin "ambiguous" reading must not interrupt a launch on its own). When the winning decision's needs_clarification is at or above 0.5 and decisive, the intake view (see "The intake plan card" below) shows one reworded question in a box (Which part should change? Name a file, a module or a screen. for the target category, and similarly for behavior/constraint/generic) — ⏎ answers it, Tab skips and plans anyway. An empty or skipped answer leaves the decision as is; a non-empty one is appended to the request (separated by a blank line) and decide() runs exactly once more — never a second round, however ambiguous the new decision still looks. zirv ctx proxy itself never prompts; it keeps printing needs_clarification as a plain field, same as every other value. A launch that never got the chance to ask (a dashboard pane, a resumed session) still carries a clarify: ask the user one precise question before acting line in its [zirv proxy] prompt layer when the same threshold-and-decisive condition holds. The Typesafe metadata intake asks only when it can also name what is missing: without a decisive target/behavior/constraint category (confidence at least 0.7), the decision is marked not decisive and nothing asks.

When it takes over. Bare zirv and zirv chat open the proxy's intake view first only when [proxy] enabled = true, the configured decider has a usable model (typesafe needs a non-empty model and the environment variable named by credential_env set; helper needs a resolvable default adapter; deterministic never takes over), and the terminal can actually render it (an interactive stdin, VT output enabled, and stderr itself a terminal — the region is inline ratatui, drawn on the plain screen before the harness starts). Otherwise the launch proceeds exactly as it does today — the full orchestrator harness, keeping whatever task text was already typed — after one zirv ▸ advisory line carrying the reason, for example proxy: enabled but TYPESAFE_API_KEY is unset; starting the orchestrator harness or proxy: this terminal cannot render the intake view; starting the orchestrator harness.

The intake plan card#

When the proxy is active, zirv chat/bare zirv opens a short inline view — on the normal screen, before the harness's own full-screen start, so it survives that switch (issue #701) — instead of the harness starting immediately:

  1. A boxed prompt (⏎ plan and start · shift+⏎ new line · esc start without a plan) reads the task description; Alt+⏎/Ctrl+J also insert a newline. Long lines soft-wrap inside the box, which grows with the content (capped at the inline region's own height, scrolling to keep the cursor's own line visible once content exceeds that). While the box is empty the hint instead reads ⏎/esc start the full orchestrator · shift+⏎ new line — Enter on nothing typed starts the harness the same way Esc always has.

  2. ✻ Sizing the task… Ns · esc to start without a plan — a spinner while decide() runs on a worker thread; Esc or Ctrl+C abandons it (the thread's result is then just discarded) and starts the harness without a plan, keeping whatever was typed.

  3. The clarify box above, only when the decision asks.

  4. A plan card in plain words — no seat tiers, decider names, domain scores or confidence numbers, ever; zirv ctx proxy --json still has those:

    text
    ╭─ Plan ───────────────────────────────────────────────────────────────╮
    │ A bounded bug fix.                                                   │
    │                                                                       │
    │   Model     Sonnet 5, working alone                                  │
    │   Workflow  bugfix · 7 steps, starting at reproduce                  │
    │   Why       one area, low risk                                       │
    │                                                                       │
    │ ❯ 1. Start                                                           │
    │   2. Start with a different model                                    │
    │   3. Start without a workflow                                        │
    │   4. Edit the task                                                   │
    ╰──────────────────────────────────────────────────────────────────────╯
      ↑↓ choose · ⏎ confirm · esc start without a plan

    An orchestrated decision shows Lead/Helpers instead of Model. ↑↓/digits 1-4 choose, ⏎ confirms — every plan waits for Enter, no countdown. Choice 2 opens a short list of the models every enabled-and- ready harness's own tier ladder offers (cheap through frontier, minus the already-planned seat) — the harness can change too, not only the model; picking one replaces the whole seat. Omitted (and the rest renumbered) when there is nothing else to offer. Choice 3 starts no workflow; choice 4 returns to the prompt with the typed text kept. Esc or Ctrl+C at any point — the prompt, the clarify box, the plan card, the model list — starts the harness without a plan.

  5. The decided workflow starts only once choice 1 or 2 is confirmed (choice 3 starts none) — not, as before, ahead of the operator ever seeing a plan.

  6. When the configured decider failed or timed out, a yellow ⚠ line above the card names it in plain words (Typesafe did not answer within 10s. The plan below uses local rules.), with zirv ctx proxy --json for details.

  7. Confirming clears the region and leaves exactly one line in scrollback, e.g.:

    text
    ✻ zirv planned in 3s · Sonnet 5 alone · bugfix workflow w-3f2a

What zirv chat does with it. The decided harness and --model replace the resolved adapter and cfg.chat.model before launch (a picked model from choice 2 replaces orchestrator.model the same way); the decision's workflow starts with the request text as the first prompt when the repo has no workflow already active and the plan was confirmed with choice 1 or 2.

Single seat vs. orchestrator seat. A Direct or Bounded decision launches the harness as a single seat (PromptRole::Single): it gets none of the orchestrator's own conventions — no HARNESS_PROMPT, no derived harness roster, no "delegate everything" coaching — and never reads the operator's orchestrator system-prompt.md; editing repository files directly is allowed (only an orchestrator seat is technically blocked from that), and the seat gets its own optional ~/.zirv/system-prompt.single.md (SINGLE_PROMPT_FILE) layer instead, if one exists. An Orchestrated decision keeps today's orchestrator seat and every one of its existing conventions unchanged.

zirv ctx proxy [--json] [--headless] [REQUEST] decides and prints without launching anything. REQUEST is read from stdin when omitted and stdin is not a tty; human output is the announce line plus one line per field with its source and confidence, then reasons and fallbacks; --json prints the full decision. zirv ctx chat --proxy / --no-proxy overrides cfg.proxy.enabled for one launch; --resume and --simple always skip the proxy.

Operator overrides. zirv ctx chat and zirv ctx proxy accept --execution direct|bounded|orchestrated, --tier cheap|standard|deep|frontier and --validation review,test,security (or none) to override the derived profile for that one launch. They are flags only, never config keys, so a checkout cannot set them. Raising is always honoured; lowering stops at the hard floor (a high-risk or security request cannot go below bounded, cannot drop the review or security gate, and cannot lower the tier below its derived value), and each refusal is named in the decision's reasons. A headless launch still ends on a single seat. The applied override is recorded as operator_override on the proxy-decisions.jsonl row.

Single-seat validation. A single seat has no reviewer or tester seat, so when its profile asks for review, test or security validation the [zirv proxy] layer carries one validate: line telling it to run the targeted tests, re-read its own diff and flag security assumptions before reporting done. This is advice only; no hook blocks completion. Bounded bug fixes and small features stay single-seat; workflow.auto_start = detect can still start a workflow for them.

Per-seat profile store. The final decision (with its override and started workflow) is also written to <state>/proxy-profile/<seat short id>.json. A rollover or handover successor keeps the seat's short id, so the successor's handoff prompt re-renders the same [zirv proxy] layer from that file (unless its base prompt already carries one). A missing, corrupt or old-format file is skipped.

zirv ctx proxy stats [--json] is read-only. It groups the stored profiles by intent/complexity and reports, in tokens only (no dollars), the median seat input, output and cache tokens from the seat transcript, the tokens of zirv agent delegations whose parent is that seat, and the verified-pass rate of the last attributable build/test run in the seat transcript. A class with no verification record prints quality as unavailable.

Headless single seat. A headless launch works unattended — nobody is watching the seat, so it must never be told it is an orchestrator: zirv's own rule for a worker is "runs unattended and must not delegate further". Pass --headless, or leave it unset and let zirv ctx proxy fall back to reading ZIRV_CTX_HEADLESS=1 (adapters::HEADLESS_ENV, the same marker zirv itself sets on a headless launch's own process environment) from the calling process's environment — either is enough. Either one forces execution/ seat_role to their single-seat equivalents (Orchestrated/Orchestrator downgrades to Bounded/Single; a decision already Direct/Bounded is a no-op) for BOTH the deterministic decider and a Jev/helper-assisted decision alike — applied last, after everything else has run. complexity, risk, seat_tier, worker_tier, and workflow are left exactly as the rest of the pipeline decided; only which seat executes the request changes. The rendered [zirv proxy] prompt layer then carries the same "You are the single seat for this request: do the work here yourself; do not delegate." line an interactive single-seat decision already gets. An interactive launch (zirv chat, a dashboard pane) never passes this and is unaffected. An external harness that launches zirv headlessly (a benchmark, CI, or any caller outside a session) should always pass --headless.

Disabled by default:

toml
# ~/.zirv/ctx.toml
[proxy]
enabled = false              # ZIRV_CTX_PROXY_ENABLED
decider = "typesafe"         # typesafe | helper | deterministic; ZIRV_CTX_PROXY_DECIDER
min_confidence = 0.5         # ZIRV_CTX_PROXY_MIN_CONFIDENCE
min_margin = 0.2             # ZIRV_CTX_PROXY_MIN_MARGIN -- see "Decider chain" above
request_max_bytes = 16384    # ZIRV_CTX_PROXY_REQUEST_MAX_BYTES
validation_gate = false      # ZIRV_CTX_PROXY_VALIDATION_GATE

[proxy.typesafe]
base_url = "https://api.typesafe.ai/v1"   # ZIRV_CTX_PROXY_TYPESAFE_BASE_URL
credential_env = "TYPESAFE_API_KEY"       # ZIRV_CTX_PROXY_TYPESAFE_CREDENTIAL_ENV
model = "jev-1.13.0"                      # ZIRV_CTX_PROXY_TYPESAFE_MODEL -- pinned; see below
timeout_secs = 10                         # ZIRV_CTX_PROXY_TYPESAFE_TIMEOUT_SECS

Validation gate. validation_gate = true makes the Stop hook block (instead of only advising) a seat whose stored proxy profile requires tests (validation.independent_test, the condition behind the single seat's validate: line) while code changed and verification evidence is stale. It reuses the [verify_on_stop] nudge, so it needs verify_on_stop.enabled and stops blocking after max_nudges; it never blocks a Stop that is already a continuation. Operator-only: a repo layer that sets it is a hard error.

Pinned model. model defaults to a specific Jev release (jev-1.13.0, what jev-latest itself resolves to today) rather than the jev-latest moving alias: the pin is for reproducibility across FUTURE alias moves, not because today's alias is wrong -- an alias that can change underneath a deployed config would confound Jev's own answer-to-answer instability (see "Decider chain" above) with an actual model upgrade, making either one impossible to diagnose from the outside. Set model = "jev-latest" explicitly to opt back into automatic upgrades, or to a newer pinned version once you've verified it against tests/fixtures/proxy/jev-battery.json.

Privacy. The state sent to a model decider carries the request text (truncated to request_max_bytes) plus the repository name and the registered workflow ids/descriptions — never file contents, secrets, or any live-measured repository fact (uncommitted/branch changes, the active workflow, primary file extensions all used to ride along here too; stripping them changed no answer's accuracy in the 2026-09-18 replay, and it means the request body now depends only on the request text, the workflow registry and the policy, never on anything that can drift between two calls for the same request). The harness/model catalogue (names, readiness, headroom, prices) used to ride along as well; it was dropped for the same reason (no question ever read it, and TypeSafe's own guidance is that irrelevant state degrades answer accuracy) — the harness roster is still policed against the live roster directly when the decision is applied, never through this state. The TypeSafe credential is read only from the environment variable named by credential_env.

Price. TypeSafe Jev is priced through the catalogue's typesafe vendor at $0.042 per MTok input, output free — see Model catalogue — and costs at most one bounded call per launch.

The native runtime applies the same decision behind its existing gate: the decided seat role (PromptRole::Single/Orchestrator) always applies, and the decided model applies too when it names a configured, policy-allowed native [route] directly, or — the common case, since the proxy's harness-CLI-style aliases and an operator's own route names are independent vocabularies — resolves to the same catalogue model as one of them (an operator who named a route cheap with model = "sonnet" still gets it picked for a decided sonnet; several matching routes prefer the role's own default route, else the first by route id). No match at all leaves the pane on its role's own default route rather than failing the launch. This feature does not change the coming-soon gate itself.

The HTTP call to Jev is a shared client, not proxy-specific code; other advisory sites it may back (memory ranking, the supervisor judge, dispatch tiering, review triage, gate reclassification) are listed under their own [jev] key, each still spending through this same [proxy.typesafe] connection.