SLOPSHOPPER

jev-blindspot-band

A one-glance summary of jev-blindspot in the band above the prompt: the blind spot count, the deeper question, and the top findings for the prompt you just…

newbandpromptnetworktimer
★ 17v0.1.0MITupdated 2026-10-07jsk4581/jev-blindspot/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jev-blindspot-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ◌ blindspot checking… http://127.0.0.1:7461/ http://127.0.0.1:7461/ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◌ blindspot checking… http://127.0.0.1:7461/ http://127.0.0.1:7461/ ⟨Claude Code's own drawing⟩
README

jev-blindspot

npm Node Claude Code Codex CLI License: MIT

Good results start with good requests.

jev-blindspot is a prompt reviewer for Claude Code and Codex CLI that runs alongside your session and shows its reviews in a local web UI.

Each time you submit a prompt, Jev decides whether a review is needed. If so, a lightweight model provides quick feedback on what your request may have overlooked, including missing context, unstated assumptions, or the underlying problem. The review helps you decide what may need clarification.

It does not block your prompt, edit it, or add anything to the agent's context. Your prompt goes through as written, and you decide whether to act on the review.

<img src="https://raw.githubusercontent.com/jsk4581/jev-blindspot/main/docs/panel.png" alt="The jev-blindspot web UI in a browser tab">

Now supports Claude Code Mods, so you can view it directly in the CLI. <img src="https://raw.githubusercontent.com/jsk4581/jev-blindspot/main/docs/band.png" alt="The jev-blindspot band above the Claude Code prompt">

Contents

Quick start

Node 20 or newer, a TypeSafe API key, and Claude Code or Codex CLI already logged in.

npm install -g jev-blindspot
mkdir -p ~/.config/jev-blindspot
echo 'TYPESAFE_API_KEY=your-key' > ~/.config/jev-blindspot/env && chmod 600 ~/.config/jev-blindspot/env
jev-blindspot install-hook      # hooks into every agent it finds

For Codex, also trust the new hook once: jev-blindspot install-hook codex --trust, or /hooks inside Codex. Details in Install.

Then type /blindspot in Claude Code, or /prompts:blindspot in Codex. The web UI's URL comes back in the session at once, with no model turn spent. Open it in a browser tab next to the session and keep working as usual.

From then on, every prompt you submit becomes a card in the web UI: quiet when there is nothing to consider, otherwise the blind spots of that request, each with a sentence you can paste into the next prompt. The card also shows what the check cost: the model, the time, the tokens in and out, and for Claude the dollar amount and the number of turns. Nothing changes in the session itself.

Why

To get good work out of an AI you have to ask for it well, and asking well means knowing the work. The skill that matters most here is knowing what you do not know. A request written without that knowledge is not wrong; it is silent about the questions it never knew were there, and the answer will be silent about them too. Those gaps are invisible from the inside. You cannot list what you did not think of.

Prompt linting tries to fix this at the input, and pays for it in exactly the places that matter: it intercepts what you typed, makes you wait, and spends tokens on every prompt whether or not there was anything to find.

jev-blindspot moves the check out of the way. A single classifier call first decides whether the prompt is worth a second look at all. Only then does a light model go looking, in the project directory, for what the request did not consider. That happens in the background while the agent is already working, and the result appears in a separate tab, where you can fold it into the next request. Nothing is inserted into the session.

jev-blindspot reviews the request only. It does not inspect or predict what the assistant does with it. It is a thinking aid: it keeps showing you the questions you did not know to ask, in work you know well and in work you do not.

How it works

Two stages run for every prompt. The first is a single classifier call on every prompt; the second is a model run that happens only when the first stage returns a positive decision.

Gate: TypeSafe jev, one request. jev answers a fixed set of typed questions about a state object and returns probabilities instead of text. The gate sends seven questions in one call. Two of them decide:

  • worth_checking: does this request have a blind spot? Is there something

anyone who knows this kind of work would have considered, that the request shows no sign of, and that would change the result? Things the author clearly left out on purpose do not count.

  • risk: a five-level rubric from read-only to irreversible (delete, force

push, real sends, production data). High risk lowers the worth_checking bar.

Four more are the knowledge-gap taxonomy from Towards Detecting Prompt Knowledge Gaps for Improved LLM-guided Issue Resolution (arXiv:2501.11709): missing context, missing specification, unclear instruction, several requests bundled. The last, deeper_problem, asks whether the request reads as a means rather than the end: a step toward a larger goal, a workaround for a symptom, or a question that depends on a more fundamental one. These five never decide anything. They appear as chips on the card as soon as the gate returns and go to the second stage as hints.

Only questions with a closed answer set go to jev. Which kinds of knowledge the request calls for, and what exactly it did not consider, are open questions and go to the second stage.

Brain: the agent's own headless mode. When the gate passes, the daemon runs a model on the login you already have, in the project directory, with read-only file access. The run matches the agent the prompt was typed into:

prompt typed intobrain runproject instructions
Claude Codeclaude -p --model sonnet --effort low with Read, Grep and GlobCLAUDE.md loads as in a normal session
Codex CLIcodex exec -m gpt-5.6-luna with low reasoning effort, a read-only sandbox and hooks disabledAGENTS.md loads as usual

The model reads what the request touches, and the effort level is the only brake. It decides for itself which kinds of knowledge the request calls for, then returns up to five items: the consideration that is absent, why it matters for this request, and a sentence you can paste into the next prompt. When the request stands on a more fundamental request, problem or question the author has not asked, the brain also names that one, at most one per turn, and the card shows it above the blind spots as the deeper question. Most turns have none. Latency depends on your plan, region and how much the model decides to read; the web UI shows the analyzing state as soon as the gate passes.

Quiet turns stay visible. Acknowledgements, follow-ups and small mechanical edits produce a one-line quiet card with the gate's probabilities, so you can see why the card stayed quiet and adjust the threshold when a decision looks wrong.

What you see

Each prompt becomes a card in the web UI:

stateshown
checkingthe prompt and a progress bar (gate in flight)
analyzingthe gap flags and risk level from the gate, while the brain works
donethe perspectives the brain applied and its items, sorted by severity, each with a copy button, plus the model, time and tokens it took
quietone grey line: the reason and the probabilities
gate unavailablethe gate call failed (timeout, key, network); no analysis was run
stoppedthe daemon shut down while this analysis was running

Every finished card ends with a meta line: the agent, the gate time, the brain time, the model, the input tokens summed over all of the brain's turns (with how many were read from the prompt cache and how many were written to it), the largest single request (the context the model saw at once), the tokens it wrote, and for the Claude brain the number of turns and the cost in dollars. The session list on the left sums the brain tokens per session, so you can see what the reviews have spent on each project.

Items are written in the language of the prompt, whatever it is. The web UI's own labels follow the browser language and can be pinned in settings (English, Korean, Japanese, Chinese, Spanish, French, German, Portuguese, Russian, Italian). Sessions are listed on the left by project directory; the web UI switches to the session that last received a prompt.

The gear in the top bar opens settings: the web UI language, which brain runs, the Claude and Codex models, the effort level of each, and the jev model. Saving writes ~/.config/jev-blindspot/env and applies to the running daemon at once. The Codex model list comes from what Codex has cached for your account.

Install

Requirements: Linux or macOS, Node 20 or newer, a TypeSafe API key, and the agent you use logged in: Claude Code 2.1.278 or newer with a subscription, or Codex CLI 0.144 or newer with a ChatGPT login. Each agent's prompts are analyzed by that agent's own headless mode, so you need only the one you type into.

npm install -g jev-blindspot

mkdir -p ~/.config/jev-blindspot
cat > ~/.config/jev-blindspot/env <<'EOT'
TYPESAFE_API_KEY=your-key
EOT
chmod 600 ~/.config/jev-blindspot/env

jev-blindspot install-hook   # registers the UserPromptSubmit hook with every agent found
jev-blindspot status

install-hook looks for ~/.claude and ~/.codex and registers with each; pass claude or codex to pick one. It backs up the file it edits and is a no-op when the hook is already present.

From source instead: git clone, npm install, npm run build, then use node bin/jev-blindspot.mjs in place of jev-blindspot.

Claude Code

The hook goes into hooks.UserPromptSubmit in ~/.claude/settings.json and runs from the next prompt on. install-hook also writes ~/.claude/commands/blindspot.md, so /blindspot prints the web UI's URL: the hook answers it itself and blocks the prompt, so no model turn is spent. /blindspot status adds daemon counters. Prompts show a claude tag in the web UI and their brain run is claude -p on your subscription.

jev-blindspot also works as a Claude Code mod. With Claude Code 2.1.287 or newer, the jev-blindspot-band mod draws a summary of the last prompt in the band above the prompt box, so you can see the result without leaving the terminal:

The jev-blindspot band above the Claude Code prompt, listing three blind spots

It shows "checking" as soon as you send a prompt, then the blind spot count, the deeper question when there is one, and every finding, with a link to the web UI for the full card. The mod only reads results from the local daemon: the hook above still sends the prompt, and the mod changes nothing Claude reads. Install it from this repository:

claude plugin marketplace add jsk4581/jev-blindspot
claude plugin install jev-blindspot-band@jev-blindspot --scope user

It loads from the next session. Remove it with claude plugin uninstall jev-blindspot-band@jev-blindspot --scope user.

Codex CLI

The hook goes into ~/.codex/hooks.json, and install-hook writes ~/.codex/prompts/blindspot.md, so /prompts:blindspot does the same as /blindspot above. Codex runs a hook you added only after you have trusted its definition, so after jev-blindspot install-hook codex either open Codex and trust jev-blindspot under /hooks, or run jev-blindspot install-hook codex --trust, which writes the same [hooks.state] entry to ~/.codex/config.toml that /hooks would (backup taken first). Prompts show a codex tag in the web UI and their brain run is codex exec on your ChatGPT login.

The hook starts the daemon on the first prompt. To open the web UI, type /blindspot in Claude Code or /prompts:blindspot in Codex: the URL is printed into the session by the hook itself, without a model turn, so it is the fastest way to get there from where you already are. /blindspot status adds daemon counters. Outside a session, jev-blindspot open prints the same URL, and the web UI is at http://127.0.0.1:7461/ by default. The daemon exits after 30 idle minutes when no web UI tab is open and comes back with the next prompt.

Use

commandwhat it does
jev-blindspot statusdaemon, gate key, brain, hook and config state
jev-blindspot openprint the web UI's URL
jev-blindspot start / stopmanage the daemon by hand
jev-blindspot gate "<prompt>" [--cwd dir]run the gate only and print every probability
jev-blindspot fixtures [file]run a fixture file through the gate; exit code is the number of mismatches
jev-blindspot smoke [prompt]post one event and wait for the card to settle
`jev-blindspot usage [--days N\--all] [--json]`what the brain spent: prompts, runs, tokens, context size, cost at API prices
`jev-blindspot install-hook [claudecodexall] [--trust]`register the hook and the /blindspot command; --trust also records Codex hook trust

JEV_FAKE=1 replaces the gate and the brain with stubs, for checking the plumbing before adding a key.

Turning it off

  • One project: create a file named .jev-blindspot-off in the project root or

any parent directory. Do this first in repositories you are not allowed to send text out of.

  • One shell: JEV_BLINDSPOT_DISABLE=1.
  • Everywhere: remove the jev-blindspot entry from hooks.UserPromptSubmit in

~/.claude/settings.json and, for Codex, in ~/.codex/hooks.json (the trust entry in config.toml is then inert and can be deleted). The command files ~/.claude/commands/blindspot.md and ~/.codex/prompts/blindspot.md can go too.

What leaves your machine

Per prompt, the gate request to TypeSafe contains:

{
  "prompt": "the prompt you just submitted (cut at 6000 characters)",
  "history": [
    { "user": "an earlier prompt from the same session, cut at 500 characters",
      "assistant": "the text of the reply it got, last 1500 characters" }
  ],
  "project": {
    "dir_name": "my-app", "languages": ["typescript"], "frameworks": ["react"],
    "has_tests": true, "has_ci": true, "git_branch": "main", "is_git_repo": true
  }
}

history holds the two exchanges before the current prompt, read from the agent's transcript: the prompt text and the assistant's reply text. Tool calls, tool output, and thinking are not in it. No file contents or diffs are in it either, unless a reply quoted them. Directory and branch names are; check them before enabling this in a work repository.

When the gate passes, the brain runs claude -p (Claude Code prompts) or codex exec (Codex prompts) on your own login with the prompt, the same two exchanges, the gate's flags, and the project directory. The model reads files in that directory as it sees fit (read-only: Read, Grep and Glob for Claude; a read-only sandbox for Codex), so whatever it opens is sent along, and so are your project instructions. That traffic goes to Anthropic under your Claude account, or to OpenAI under your ChatGPT account, the same as any session of that agent.

Locally, each session's prompts and results are appended to ~/.local/share/jev-blindspot/sessions/<session>.jsonl and deleted after 30 days. ~/.local/share/jev-blindspot/usage.jsonl keeps one line per gate decision and brain run (time, agent, model, token counts, cost; no prompt text) and is not deleted, so jev-blindspot usage can add up any period. Logs in ~/.local/state/jev-blindspot/ do not contain prompt text.

The daemon listens on 127.0.0.1 only unless JEV_BIND_EXTRA adds an address. On any added address, anyone who can reach the port can open the web UI; set JEV_TOKEN to require one. The routes that accept prompts and stop the daemon take loopback connections only; the settings route accepts any allowed host, since anyone who can open the web UI already sees every prompt and the settings only choose models.

Configuration

Everything lives in ~/.config/jev-blindspot/env, one KEY=VALUE per line; environment variables take precedence. The settings dialog in the web UI covers the models and effort levels. The keys you are most likely to touch by hand:

  • TYPESAFE_API_KEY: the gate key, required.
  • JEV_WORTH_MIN (default 0.65): the worth_checking probability from which

a prompt is analyzed. Run jev-blindspot gate "<prompt>" on a few of your own prompts and move it until quiet and analyze match what you would want.

  • JEV_BIND_EXTRA and JEV_TOKEN: reach the web UI from another machine, and

require a token when you do.

The full list, with defaults, is in docs/configuration.md.

What it costs

The gate decides which prompts reach the brain; in our own use about half of them do. The gate runs on TypeSafe and does not count against your Claude or ChatGPT plan. Each brain run reads the files it needs over a few requests, and every request resends the context, so input is summed over the run.

Claude Code

One claude -p call on Sonnet 5.5, effort low, thinking off.

per brain run (median / p90 / max)clean Claude Code configheavy config (my own)
fixed context (first request)5.1k14.3k
largest request7.2k / 11.9k / 14.0k15.9k / 17.5k / 21.7k
input summed over the run23.6k / 37.6k / 65.0k44.7k / 62.0k / 89.8k
written to the prompt cache2.0k11.7k
output1.2k / 2.2k / 2.7k1.2k / 2.1k / 2.8k
cost at API list prices$0.026 / $0.053 / $0.072$0.068 / $0.081 / $0.105
share of the 5-hour limit on Max 5xabout 0.05%about 0.14%

The heavy config loads many skills, a long CLAUDE.md and memory into every session. The brain loads them too, and since they are rewritten to the prompt cache on every run, they account for most of the difference. On Max 5x, about $0.50 of brain use at API prices moved the 5-hour meter by 1%; the weekly meter did not move over 48 runs.

Codex CLI

One codex exec call on gpt-5.6-luna, reasoning low. Measurement in progress.

Measuring your own

The figures above were measured in October 2026 with scripts/bench-brain.mjs: 24 prompts against this repository, run twice. Plan limits are set by the vendors and change, and the meters only report whole percents. Run the script on your own setup, and use jev-blindspot usage to see what the brain has spent over time; set JEV_PLAN_USD_PER_PERCENT to turn that into a share of your plan.

Known limits

  • The Claude brain needs Claude Code 2.1.278 or newer for --json-schema,

--effort and --permission-prompts none. It runs with your settings, hooks and CLAUDE.md the way a session does; the jev-blindspot hook recognises the brain's own run and does not fire inside it. --bare is not used because it reads only ANTHROPIC_API_KEY, which subscription logins do not have.

  • The Codex brain needs codex exec with --output-schema, --ephemeral and

--disable hooks (Codex CLI 0.144 was used). The model list differs per account; if gpt-5.6-luna is not available to you, set JEV_BRAIN_CODEX_MODEL to a light model you have.

  • One gate call per prompt, one brain run per analyzed prompt. Runs of one

session go in order. Two runs go at once across all sessions by default; more wait in line and none are dropped. Raise JEV_BRAIN_CONCURRENCY if you work in several sessions at the same time.

  • Codex support was built against Codex CLI 0.144 (hook payload prompt,

session_id, turn_id, transcript_path; rollout user_message and agent_message lines for history). The trust-hash recipe follows Codex's source and can change with a Codex release; /hooks inside Codex is always the fallback.

Development

npm test                              # unit tests: thresholds, transcript, parser, repo scan
node bin/jev-blindspot.mjs fixtures     # gate fixtures against the real jev
node scripts/brain-try.mjs "prompt" [--codex]   # one brain run against this repository
node scripts/gate-tune.mjs            # compare question wordings over the fixtures

Layout: bin/ (the dependency-free hook and the CLI launcher), src/cli/ (commands, Codex trust recipe), src/daemon/ (HTTP, SSE, store, pipeline), src/gate/ (jev state, questions, thresholds), src/brain/ (prompt, schema, claude -p and codex exec runners), src/context/ (repository scan, transcript tail), web/ (the web UI, no build step; labels in i18n.js).

jev-blindspot is a community project and is not affiliated with TypeSafe.

License

MIT

Source 1 files
hooks/blindspot-band.mjs 216 lines
1// jev-blindspot band: a summary of the last prompt's blind spots, above the prompt.
2//
3// The jev-blindspot hook already sends every prompt to the local daemon; this mod
4// only reads the result back and draws it. It never changes a prompt and adds
5// nothing to what Claude reads.
6//
7// prompt.submit: note that a prompt went out, so the band shows "checking" at
8//   once and the poll below runs until that prompt's turn settles.
9// session.start: read the port and panel address, show the session's last turn,
10//   and start the poll.
11// ui.render (AbovePrompt): one header line, then the deeper question and every
12//   finding, each cut to one row. Other mods' band content is kept below ours.
13//
14// The host reads on(...) and $.noun.method(...) from source, so they are spelled
15// literally, and helpers that take $ are top-level functions.
16
17const POLL_MS = 1500;
18// Stop polling a prompt whose turn never settles (daemon down, brain stuck).
19const ACTIVE_LIMIT_MS = 180_000;
20// The hook stamps the turn when it runs, which may be a little before this mod
21// sees the prompt; a turn this much older than the submit still counts as it.
22const SUBMIT_SLACK_MS = 5000;
23const SETTLED = new Set(["quiet", "gate_unavailable", "done", "error", "cancelled"]);
24const GAP_LABEL = {
25  missing_context: "context",
26  missing_specification: "spec",
27  unclear_instruction: "unclear",
28  multiple_context: "several asks",
29  deeper_problem: "deeper",
30};
31const SEVERITY = {
32  "likely-costly": { mark: "!", color: "red" },
33  "worth-asking": { mark: "?", color: "yellow" },
34  note: { mark: "·", color: undefined },
35};
36
37let disabled = false;
38let base = "http://127.0.0.1:7461";
39let panelUrl = "http://127.0.0.1:7461/";
40let sessionId = null;
41// What the band shows: null (nothing), or { turn } / { pending: true }.
42let view = null;
43let submittedAt = 0;
44let polling = false;
45let unreachableSince = 0;
46
47export function register(on) {
48  on("session.start", async ($, e, next) => {
49    const result = await next(e);
50    try {
51      if ((await $.env.get("JEV_BLINDSPOT_CHILD")) === "1" || (await $.env.get("JEV_BLINDSPOT_DISABLE")) === "1") {
52        disabled = true;
53        return result;
54      }
55      await readConfig($);
56      sessionId = await $.session.id();
57      const turn = await latestTurn($);
58      if (turn) view = { turn };
59      $.ui.invalidate("ui.render");
60      $.clock.every(POLL_MS, () => poll($));
61    } catch {
62      // the band stays empty
63    }
64    return result;
65  });
66
67  on("prompt.submit", async ($, e, next) => {
68    // Only prompts a person sent: not claude -p runs, task notifications or other sessions.
69    const kind = e.origin?.kind;
70    if (!disabled && (kind === "composer" || kind === "bridge") && !isCommand(e.text)) {
71      submittedAt = await $.clock.now();
72      unreachableSince = 0;
73      view = { pending: true };
74      $.ui.invalidate("ui.render");
75    }
76    return next(e);
77  });
78
79  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
80    if (disabled || view === null || e.props?.hasSurvey || e.props?.view?.agentId) {
81      return next(e);
82    }
83    const { Box, Text, Link } = $.ui.resolve(e);
84    const ours = band(Box, Text, Link);
85    const theirs = await next(e);
86    return Box({ flexDirection: "column", children: theirs ? [ours, theirs] : [ours] });
87  });
88}
89
90async function poll($) {
91  if (disabled || submittedAt === 0 || polling) return;
92  const now = await $.clock.now();
93  if (now - submittedAt > ACTIVE_LIMIT_MS) {
94    submittedAt = 0;
95    if (view?.pending) view = null;
96    $.ui.invalidate("ui.render");
97    return;
98  }
99  polling = true;
100  try {
101    const turn = await latestTurn($);
102    if (turn === undefined) {
103      // daemon not answering; the hook starts it, so give it a few seconds
104      if (unreachableSince === 0) unreachableSince = now;
105      if (now - unreachableSince > 6000) view = { unreachable: true };
106    } else if (turn && turn.ts >= submittedAt - SUBMIT_SLACK_MS) {
107      unreachableSince = 0;
108      view = { turn };
109      if (SETTLED.has(turn.state)) submittedAt = 0;
110    }
111    $.ui.invalidate("ui.render");
112  } finally {
113    polling = false;
114  }
115}
116
117/** The session's newest turn, null when it has none, undefined when the daemon is down. */
118async function latestTurn($) {
119  if (!sessionId) return null;
120  try {
121    const res = await $.http.fetch(`${base}/api/sessions/${encodeURIComponent(sessionId)}?limit=1`);
122    if (!res.ok) return undefined;
123    const turns = JSON.parse(res.text);
124    return Array.isArray(turns) && turns.length ? turns[turns.length - 1] : null;
125  } catch {
126    return undefined;
127  }
128}
129
130/** Port and panel address, from the environment or ~/.config/jev-blindspot/env, as the hook reads them. */
131async function readConfig($) {
132  const file = {};
133  try {
134    const home = await $.env.get("HOME");
135    const cfgHome = (await $.env.get("XDG_CONFIG_HOME")) || `${home}/.config`;
136    const text = await $.fs.read(`${cfgHome}/jev-blindspot/env`);
137    for (const raw of text.split(/\r?\n/)) {
138      const line = raw.trim();
139      const eq = line.indexOf("=");
140      if (!line || line.startsWith("#") || eq <= 0) continue;
141      file[line.slice(0, eq).trim()] = line.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
142    }
143  } catch {
144    // no config file
145  }
146  const port = Number((await $.env.get("JEV_PORT")) || file.JEV_PORT) || 7461;
147  const extra = ((await $.env.get("JEV_BIND_EXTRA")) ?? file.JEV_BIND_EXTRA ?? "").split(",").map((s) => s.trim()).filter(Boolean);
148  base = `http://127.0.0.1:${port}`;
149  panelUrl = `http://${extra[0] ?? "127.0.0.1"}:${port}/`;
150}
151
152function isCommand(text) {
153  return /^\s*\//.test(String(text ?? ""));
154}
155
156function band(Box, Text, Link) {
157  const pad = (rows) => Box({ key: "band", flexDirection: "column", paddingX: 1, children: rows });
158  const head = (mark, color, words, extra = []) =>
159    Box({
160      key: "head",
161      flexDirection: "row",
162      children: [
163        Text({ key: "mark", color, bold: true, children: `${mark} blindspot  ` }),
164        Text({ key: "words", color, children: words }),
165        ...extra,
166        Text({ key: "sep", dimColor: true, children: "   " }),
167        Link({ key: "link", href: panelUrl, label: panelUrl }),
168      ],
169    });
170
171  if (view.pending) return pad([head("◌", "cyan", "checking…")]);
172  if (view.unreachable) return pad([head("·", undefined, "daemon not reachable")]);
173
174  const t = view.turn;
175  const risk = t.gate ? Text({ key: "risk", dimColor: true, children: `  risk ${t.gate.risk.toFixed(1)}` }) : null;
176  const flagged = (t.gate_decision?.flagged_gaps ?? []).map((k) => GAP_LABEL[k] ?? k);
177  const chips = flagged.length ? Text({ key: "gaps", dimColor: true, children: `  · ${flagged.join(", ")}` }) : null;
178  const extras = [risk, chips].filter(Boolean);
179
180  switch (t.state) {
181    case "pending":
182      return pad([head("◌", "cyan", "checking…")]);
183    case "analyzing":
184      return pad([head("◌", "cyan", "looking for blind spots…", extras)]);
185    case "quiet":
186      return pad([head("·", undefined, t.gate_decision?.reason === "duplicate" ? "same prompt, skipped" : "nothing to check", extras)]);
187    case "gate_unavailable":
188      return pad([head("·", "yellow", `gate unavailable${t.gate_failure ? ` (${t.gate_failure})` : ""}`)]);
189    case "error":
190      return pad([head("×", "red", `${t.error?.stage ?? "analysis"} failed`)]);
191    case "cancelled":
192      return pad([head("·", undefined, "cancelled")]);
193  }
194
195  // done
196  const items = t.result?.items ?? [];
197  const deeper = t.result?.deeper?.[0];
198  if (items.length === 0 && !deeper) return pad([head("✓", "green", "no blind spots", extras)]);
199  const count = `${items.length} blind spot${items.length === 1 ? "" : "s"}`;
200  const rows = [head("◉", "magenta", count, extras)];
201  if (deeper) {
202    rows.push(
203      Text({ key: "deeper", wrap: "truncate-end", children: [Text({ key: "dl", color: "magenta", children: "  ↳ deeper  " }), Text({ key: "dt", children: deeper.title })] }),
204    );
205  }
206  // Every finding is shown (the brain returns at most five); a band taller than
207  // the terminal allows scrolls.
208  for (const [i, it] of items.entries()) {
209    const s = SEVERITY[it.severity] ?? SEVERITY.note;
210    rows.push(
211      Text({ key: `i${i}`, wrap: "truncate-end", children: [Text({ key: "m", color: s.color, bold: true, children: `  ${s.mark} ` }), Text({ key: "t", children: it.title }), Text({ key: "d", dimColor: true, children: `  ${it.domain}` })] }),
212    );
213  }
214  return pad(rows);
215}
216