[object Object]

← back to Desktop Dotbar

Jev classifies dotbar dot colors ($0 builtin default; paid TypeSafe flip gated off)

cf77e2f8b3f7970d0f37395c7028c9a6c181386f · 2026-09-23 11:56:07 -0700 · Steve Abrams

Re-decide each active terminal's displayed dot color through a Jev-style typed
choice at the dotbar's own getDots() seam. allcolordots.sh is untouched (no fleet
regression); Jev unavailable/capped/errors falls back to the heuristic color.
$0 builtin classifier by default; paid path unreachable unless DOTBAR_JEV_PAID=1
and provably under a $5/day cap (fail-closed). Per-terminal state-hash cache gates
reclassification to real transitions only. /api/jev exposes the counters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019excZ7L7VE14hbPqKQqH3i

Files touched

Diff

commit cf77e2f8b3f7970d0f37395c7028c9a6c181386f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 23 11:56:07 2026 -0700

    Jev classifies dotbar dot colors ($0 builtin default; paid TypeSafe flip gated off)
    
    Re-decide each active terminal's displayed dot color through a Jev-style typed
    choice at the dotbar's own getDots() seam. allcolordots.sh is untouched (no fleet
    regression); Jev unavailable/capped/errors falls back to the heuristic color.
    $0 builtin classifier by default; paid path unreachable unless DOTBAR_JEV_PAID=1
    and provably under a $5/day cap (fail-closed). Per-terminal state-hash cache gates
    reclassification to real transitions only. /api/jev exposes the counters.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_019excZ7L7VE14hbPqKQqH3i
---
 jev-dots.js           | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json          |   3 +-
 server.js             |  11 +-
 test/jev-dots.test.js | 117 +++++++++++++++++++++
 4 files changed, 411 insertions(+), 2 deletions(-)

diff --git a/jev-dots.js b/jev-dots.js
new file mode 100644
index 0000000..0c67a36
--- /dev/null
+++ b/jev-dots.js
@@ -0,0 +1,282 @@
+'use strict';
+// jev-dots — re-classify each terminal's DOT COLOR through Jev (TypeSafe System One:
+// unstructured per-terminal state in, a typed choice over the dot-color labels out).
+//
+// Scoped to the DOTBAR's OWN read path only: server.js getDots() already runs
+// `allcolordots --json` and gets per-terminal {tty,pid,label,color,variant,parked}.
+// This module re-decides the displayed COLOR at that seam, so the shared fleet
+// allcolordots.sh is never touched. If Jev is unavailable/capped/errors, each row
+// falls back to allcolordots' original heuristic color — never blank, never a throw.
+//
+// $0 BY DEFAULT: the built-in deterministic classifier runs locally with no network
+// and no spend. The PAID TypeSafe backend is gated OFF behind DOTBAR_JEV_PAID=1 AND a
+// hard daily spend cap; flipping it on is a separate Steve-approved step.
+//
+// Load-bearing cost control = the per-terminal STATE-HASH CACHE: a terminal is only
+// (re)classified when its state hash changes, so "every terminal every 2.5s refresh"
+// collapses to a few classifications/day (real transitions only) with no loss of
+// "jev decides every color."
+
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const https = require('https');
+const { execFile } = require('child_process');
+
+// ---- config constants (trivially changeable) --------------------------------
+const LABELS = ['lightblue', 'orange', 'purple', 'yellow', 'green', 'pink', 'none'];
+// Semantic priority (needs-Steve first) — matches allcolordots / server.js ORDER.
+// A higher-priority signal in the label text outweighs a lower-priority one.
+const PRIORITY = { lightblue: 6, orange: 5, purple: 4, yellow: 3, green: 2, pink: 1, none: 0 };
+
+const CONFIG = {
+  // PAID PATH DEFAULT-OFF. Only DOTBAR_JEV_PAID=1 arms it; anything else = $0 builtin.
+  paidEnabled: process.env.DOTBAR_JEV_PAID === '1',
+  // Hard daily spend cap (USD). Steve may set DOTBAR_JEV_CAP_USD=2; default $5.
+  capUsd: Number(process.env.DOTBAR_JEV_CAP_USD || '5'),
+  provider: process.env.DOTBAR_JEV_PROVIDER || 'typesafe',
+  timeoutMs: Number(process.env.DOTBAR_JEV_TIMEOUT_MS || '800'),
+  ledgerPath: path.join(os.homedir(), '.claude', 'cost-ledger.jsonl'),
+  costLogJs: path.join(os.homedir(), '.claude', 'skills', 'cost-tracker', 'scripts', 'log.js'),
+  costApiKey: 'typesafe_jev',
+  costApp: 'desktop-dotbar',
+};
+
+// ---- observability counters (verification reads these) ----------------------
+const stats = {
+  classifications: 0, // rows that actually ran the classifier (cache miss)
+  cacheHits: 0,       // rows served from the state-hash cache (no reclassify)
+  cacheMisses: 0,
+  builtinCalls: 0,    // classifier decided via the $0 local path
+  paidCalls: 0,       // classifier decided via the paid TypeSafe API
+  paidFellBack: 0,    // paid attempted but errored -> builtin
+  capBlocked: 0,      // paid armed but daily cap reached -> builtin
+  paidEnabled: CONFIG.paidEnabled,
+  capUsd: CONFIG.capUsd,
+  lastSpendUsd: 0,
+  updated: 0,
+};
+
+// tty -> { hash, color, source, confidence }
+const cache = new Map();
+
+// ---- state extraction: what we feed Jev per terminal ------------------------
+function cleanLabel(label) {
+  if (!label) return '';
+  return String(label).replace(/^[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}️\s]+/u, '').trim();
+}
+// The meaningful per-terminal state (comparable to what the heuristic sees, plus
+// the label text the heuristic ignores): the heuristic's own color as a prior,
+// the cleaned dot title, and whether the session is stopped.
+function extractState(row) {
+  return {
+    heuristicColor: LABELS.includes(row.color) ? row.color : 'none',
+    label: cleanLabel(row.label),
+    variant: row.variant || '',
+    stopped: row.variant === 'stopped',
+  };
+}
+function stateHash(s) {
+  return crypto.createHash('sha1')
+    .update(`${s.heuristicColor}\u0000${s.label}\u0000${s.variant}`)
+    .digest('hex');
+}
+
+// ---- $0 built-in classifier -------------------------------------------------
+// Deterministic typed choice over the dot-color labels. Designed as a strict
+// REFINEMENT of the heuristic: the heuristic's own color is a strong prior, and
+// label-text keywords can only pull the decision toward a DIFFERENT, correctly
+// prioritized label when the text genuinely says so. With no keyword signal it
+// keeps the heuristic color, so it is at least as good as the heuristic by
+// construction and cannot randomly regress.
+const KEYWORDS = [
+  // color, weight, regex (tested against lowercased label)
+  ['lightblue', 4.0, /needs?\s+steve|waiting on steve|blocked on steve|need (?:you|steve)|your input|awaiting steve/],
+  ['orange', 3.6, /\bpaste|!\s*(?:ssh|bash|sudo|psql|open)|run this|in your console|console step|\bpastes?\b/],
+  ['purple', 3.6, /gated|pending[- ]approval|awaiting approval|memo drafted|approve\/reject|approval queue/],
+  ['yellow', 3.4, /needs? direction|clarify|which approach|decision fork|askuser|1 question|\bquestions?\b/],
+  ['pink', 3.0, /\bparked\b|nothing left|handed off|\bdone\b|complete(?:d)?\b|finished\b/],
+  ['green', 3.0, /working|monitoring|running|building|in progress|started \d|next \d|deploy|scanning|sweeping/],
+];
+function classifyBuiltin(state) {
+  const scores = Object.fromEntries(LABELS.map((c) => [c, 0]));
+  // Prior: trust the heuristic color. 'none' is a weak prior (the session painted
+  // nothing), so label text is allowed to name a real color over it.
+  scores[state.heuristicColor] += state.heuristicColor === 'none' ? 0.6 : 3.0;
+  const text = state.label.toLowerCase();
+  for (const [color, weight, re] of KEYWORDS) {
+    if (re.test(text)) scores[color] += weight;
+  }
+  // A stopped session with no needs-Steve / working signal reads as parked, not working.
+  if (state.stopped) scores.pink += 1.0;
+  // Pick the argmax; break ties toward the higher semantic priority (needs-Steve first).
+  let best = 'none', bestScore = -1;
+  for (const c of LABELS) {
+    const sc = scores[c];
+    if (sc > bestScore || (sc === bestScore && PRIORITY[c] > PRIORITY[best])) {
+      best = c; bestScore = sc;
+    }
+  }
+  const sum = LABELS.reduce((a, c) => a + scores[c], 0) || 1;
+  const distribution = Object.fromEntries(LABELS.map((c) => [c, +(scores[c] / sum).toFixed(3)]));
+  return { color: best, confidence: +(bestScore / sum).toFixed(3), source: 'builtin', distribution };
+}
+
+// ---- daily spend cap (fail-closed) ------------------------------------------
+// Sum today's desktop-dotbar rows in the cost ledger. On ANY read failure this
+// returns Infinity so the cap is treated as reached -> paid path is skipped -> no spend.
+function spendTodayUsd(readLedger) {
+  try {
+    const raw = readLedger();
+    if (!raw) return 0;
+    const today = new Date().toISOString().slice(0, 10);
+    let sum = 0;
+    for (const line of raw.split('\n')) {
+      if (!line.trim()) continue;
+      let e; try { e = JSON.parse(line); } catch { continue; }
+      if (e && e.app === CONFIG.costApp && typeof e.cost_usd === 'number'
+          && String(e.ts || '').slice(0, 10) === today) {
+        sum += e.cost_usd;
+      }
+    }
+    return sum;
+  } catch {
+    return Infinity; // cannot confirm under cap -> never spend
+  }
+}
+
+function logPaidCall(logSpend) {
+  // Log every paid call to the cost ledger via cost-tracker's log.js. Best-effort:
+  // a logging failure must never crash a refresh (but is counted).
+  try { logSpend(); } catch { /* counted by caller */ }
+}
+function defaultLogSpend() {
+  execFile(process.execPath, [CONFIG.costLogJs, '--api', CONFIG.costApiKey,
+    '--units', '1:call', '--app', CONFIG.costApp, '--note', 'dotbar color classification'],
+    { timeout: 4000 }, () => {});
+}
+
+// ---- paid TypeSafe backend (UNREACHABLE unless DOTBAR_JEV_PAID=1) ------------
+// Reuses jev-model-router's typed-choice wire shape (choice question + per-answer
+// confidence), adding only the new state->dot-color schema. Reads the key lazily
+// and ONLY when armed, so with the flag off nothing is ever read or sent.
+function readTypesafeKey() {
+  if (process.env.TYPESAFE_API_KEY) return process.env.TYPESAFE_API_KEY;
+  try {
+    const env = fs.readFileSync(path.join(os.homedir(), 'Projects', 'secrets-manager', '.env'), 'utf8');
+    const m = /^TYPESAFE_API_KEY=(.+)$/m.exec(env);
+    return m ? m[1].trim() : '';
+  } catch { return ''; }
+}
+const DOT_CRITERIA = {
+  lightblue: 'The session has stopped and needs Steve: any stop that requires his input.',
+  orange: 'A paste is waiting: a shell command / console step Steve must run himself.',
+  purple: 'Gated: a memo is drafted to pending-approval awaiting Steve\'s approve/reject.',
+  yellow: 'Needs direction: a question or decision fork is waiting on Steve\'s answer.',
+  green: 'Working: a process, loop, or agent is actively executing or monitoring.',
+  pink: 'Parked: work handed off or complete, nothing left to progress.',
+  none: 'No dot / no discernible state.',
+};
+function typeSafeClassify(state, deps) {
+  const key = (deps && deps.key) || readTypesafeKey();
+  if (!key) return Promise.reject(new Error('no TYPESAFE_API_KEY'));
+  const body = JSON.stringify({
+    model: 'jev-latest',
+    state: { heuristicColor: state.heuristicColor, label: state.label, stopped: state.stopped },
+    questions: {
+      dot: {
+        type: 'choice',
+        instructions: 'Which dot color best classifies this terminal\'s current state?',
+        criteria: DOT_CRITERIA,
+      },
+    },
+  });
+  return new Promise((resolve, reject) => {
+    const req = https.request('https://api.typesafe.ai/v1/systemone', {
+      method: 'POST',
+      headers: { 'content-type': 'application/json', authorization: `Bearer ${key}`,
+        'content-length': Buffer.byteLength(body) },
+      timeout: CONFIG.timeoutMs,
+    }, (res) => {
+      let out = '';
+      res.on('data', (c) => (out += c));
+      res.on('end', () => {
+        if (res.statusCode < 200 || res.statusCode >= 300) return reject(new Error(`http ${res.statusCode}`));
+        try {
+          const ans = JSON.parse(out).answers && JSON.parse(out).answers.dot;
+          if (ans && LABELS.includes(ans.choice)) {
+            resolve({ color: ans.choice, confidence: typeof ans.confidence === 'number' ? ans.confidence : null, source: 'typesafe' });
+          } else reject(new Error('bad answer shape'));
+        } catch (e) { reject(e); }
+      });
+    });
+    req.on('error', reject);
+    req.on('timeout', () => { req.destroy(new Error('timeout')); });
+    req.end(body);
+  });
+}
+
+// ---- the seam server.js calls ----------------------------------------------
+// rows: allcolordots --json rows (already filtered to active/non-parked by getDots).
+// opts: dependency-injection seam for tests (paidEnabled, capUsd, readLedger,
+//       paidTransport, logSpend). Production uses the real env/files.
+// Returns Map<tty, {color, source, confidence}>. Never throws.
+async function classifyDots(rows, opts = {}) {
+  const paidEnabled = opts.paidEnabled !== undefined ? opts.paidEnabled : CONFIG.paidEnabled;
+  const capUsd = opts.capUsd !== undefined ? opts.capUsd : CONFIG.capUsd;
+  const readLedger = opts.readLedger || (() => { try { return fs.readFileSync(CONFIG.ledgerPath, 'utf8'); } catch { return ''; } });
+  const paidTransport = opts.paidTransport || typeSafeClassify;
+  const logSpend = opts.logSpend || defaultLogSpend;
+
+  const seen = new Set();
+  const misses = [];
+  for (const row of rows || []) {
+    if (!row || !row.tty) continue;
+    seen.add(row.tty);
+    const state = extractState(row);
+    const hash = stateHash(state);
+    const cached = cache.get(row.tty);
+    if (cached && cached.hash === hash) { stats.cacheHits++; continue; }
+    stats.cacheMisses++;
+    misses.push({ tty: row.tty, state, hash });
+  }
+
+  // Compute today's spend ONCE per refresh so a burst of misses can't each blow past the cap.
+  let spend = 0;
+  if (paidEnabled) { spend = spendTodayUsd(readLedger); stats.lastSpendUsd = Number.isFinite(spend) ? spend : stats.lastSpendUsd; }
+
+  for (const m of misses) {
+    stats.classifications++;
+    let result = null;
+    // Paid path is structurally UNREACHABLE unless armed AND provably under the cap.
+    if (paidEnabled && spend < capUsd) {
+      try {
+        result = await paidTransport(m.state, {});
+        stats.paidCalls++;
+        logPaidCall(logSpend);
+        spend += 0.001; // reserve the just-spent call so the in-loop cap holds before the ledger flushes
+      } catch {
+        stats.paidFellBack++;
+        result = classifyBuiltin(m.state); stats.builtinCalls++;
+      }
+    } else {
+      if (paidEnabled) stats.capBlocked++; // armed but cap reached -> fall back, no spend
+      result = classifyBuiltin(m.state); stats.builtinCalls++;
+    }
+    cache.set(m.tty, { hash: m.hash, color: result.color, source: result.source, confidence: result.confidence });
+  }
+
+  // Prune cache entries for ttys that vanished (keep it bounded).
+  for (const tty of cache.keys()) if (!seen.has(tty)) cache.delete(tty);
+
+  stats.updated = Date.now();
+  const out = new Map();
+  for (const tty of seen) { const c = cache.get(tty); if (c) out.set(tty, { color: c.color, source: c.source, confidence: c.confidence }); }
+  return out;
+}
+
+function getStats() { return { ...stats, cacheSize: cache.size }; }
+function _resetForTest() { cache.clear(); for (const k of Object.keys(stats)) if (typeof stats[k] === 'number') stats[k] = 0; }
+
+module.exports = { classifyDots, classifyBuiltin, extractState, stateHash, spendTodayUsd, getStats, LABELS, _resetForTest };
diff --git a/package.json b/package.json
index e4273c9..d4dfd64 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,8 @@
   "private": true,
   "main": "electron-main.js",
   "scripts": {
-    "start": "electron ."
+    "start": "electron .",
+    "test": "node --test test/*.test.js"
   },
   "dependencies": {
     "electron": "^44.4.0"
diff --git a/server.js b/server.js
index 7faf854..a7f15af 100755
--- a/server.js
+++ b/server.js
@@ -8,6 +8,7 @@ const http = require('http');
 const { execFile, spawn } = require('child_process');
 const fs = require('fs');
 const path = require('path');
+const jevDots = require('./jev-dots');  // re-classifies each dot COLOR via Jev ($0 builtin; paid flip gated)
 
 const ALLCOLORDOTS = `${process.env.HOME}/.claude/skills/allcolordots/allcolordots.sh`;
 const ROUTER = `${process.env.HOME}/.claude/skills/dot-screen-router/router.sh`;
@@ -59,10 +60,17 @@ async function getDots() {
   // A durably-parked live tab peels OUT of the active colour groups into the PARKED
   // section, so the active dots stay uncluttered (allcolordots --json carries `parked`).
   rows = rows.filter(r => r && r.live && !r.parked);
+  // Re-decide each ACTIVE dot's COLOR through Jev (System One typed choice). Scoped to
+  // the dotbar's own read path — allcolordots.sh is untouched. Jev unavailable/capped/
+  // errors -> we keep allcolordots' original heuristic color (never blank, never throw).
+  let jevMap = new Map();
+  try { jevMap = await jevDots.classifyDots(rows); } catch { jevMap = new Map(); }
   const groups = {};
   for (const key of ORDER) groups[key] = [];
   for (const r of rows) {
-    const color = META[r.color] ? r.color : 'none';
+    const jv = jevMap.get(r.tty);
+    const jevColor = jv && META[jv.color] ? jv.color : null;
+    const color = jevColor || (META[r.color] ? r.color : 'none');
     groups[color].push({
       tty: r.tty,
       pid: r.pid,
@@ -265,6 +273,7 @@ const server = http.createServer(async (req, res) => {
     const url = new URL(req.url, 'http://x');
     if (url.pathname === '/health') return send(res, 200, { ok: true, port: server.address() && server.address().port });
     if (url.pathname === '/api/dots') { if (!snapshot.updated) await refresh(); refresh(); return send(res, 200, snapshot); }
+    if (url.pathname === '/api/jev') { return send(res, 200, jevDots.getStats()); }
     if (url.pathname === '/api/arrange' && req.method === 'POST') {
       return send(res, 200, await arrange());
     }
diff --git a/test/jev-dots.test.js b/test/jev-dots.test.js
new file mode 100644
index 0000000..5a26b62
--- /dev/null
+++ b/test/jev-dots.test.js
@@ -0,0 +1,117 @@
+'use strict';
+// Verification for jev-dots: classification (builtin $0), cache-gate, spend cap
+// (fail-closed), fallback, and a negative fault-injection test. Zero deps.
+const test = require('node:test');
+const assert = require('node:assert');
+const jev = require('../jev-dots');
+
+const row = (tty, color, label, variant) => ({ tty, color, label, variant: variant || '', live: true, parked: false });
+const todayLine = (usd) => JSON.stringify({ app: 'desktop-dotbar', cost_usd: usd, ts: new Date().toISOString() });
+
+test('builtin classifies synthetic states to sane colors', () => {
+  const cases = [
+    [row('t1', 'green', 'TK-1 · 2 pastes waiting'), 'orange'],   // label overrides heuristic
+    [row('t2', 'none', 'parked — handed off', 'stopped'), 'pink'],
+    [row('t3', 'green', 'monitoring · next 14:00'), 'green'],
+    [row('t4', 'purple', 'gated · 1 memo in pending-approval'), 'purple'],
+    [row('t5', 'none', 'needs Steve to approve the login'), 'lightblue'],
+    [row('t6', 'yellow', '1 question — which approach'), 'yellow'],
+    [row('t7', 'green', 'building the importer'), 'green'],
+  ];
+  for (const [r, expect] of cases) {
+    const res = jev.classifyBuiltin(jev.extractState(r));
+    assert.equal(res.color, expect, `${r.label} -> ${res.color}, expected ${expect}`);
+    assert.equal(res.source, 'builtin');
+    assert.ok(res.confidence > 0 && res.confidence <= 1);
+  }
+});
+
+test('cache-gate: unchanged state does NOT reclassify on later refreshes', async () => {
+  jev._resetForTest();
+  const rows = [row('a', 'green', 'working'), row('b', 'purple', 'gated · 1'), row('c', 'none', 'parked', 'stopped')];
+  await jev.classifyDots(rows);
+  assert.equal(jev.getStats().classifications, 3, 'first pass classifies all 3');
+  await jev.classifyDots(rows);
+  await jev.classifyDots(rows);
+  const s = jev.getStats();
+  assert.equal(s.classifications, 3, 'no new classifications when state unchanged');
+  assert.equal(s.cacheHits, 6, 'both later passes were pure cache hits');
+});
+
+test('negative/fault-injection: a REAL state change reclassifies (stale hash cannot hide it)', async () => {
+  jev._resetForTest();
+  const before = [row('a', 'green', 'working')];
+  await jev.classifyDots(before);
+  assert.equal(jev.getStats().classifications, 1);
+  const after = [row('a', 'orange', 'TK-9 · 1 paste')];   // same tty, changed state
+  const map = await jev.classifyDots(after);
+  assert.equal(jev.getStats().classifications, 2, 'changed state forces a reclassify');
+  assert.equal(map.get('a').color, 'orange', 'color follows the new state, not the stale cache');
+});
+
+test('paid OFF by default: no paid calls, everything via $0 builtin', async () => {
+  jev._resetForTest();
+  const map = await jev.classifyDots([row('a', 'green', 'working')]); // no opts, env unset
+  const s = jev.getStats();
+  assert.equal(s.paidCalls, 0);
+  assert.equal(s.builtinCalls, 1);
+  assert.equal(map.get('a').source, 'builtin');
+});
+
+test('cap fail-closed: cap reached -> paid transport is NEVER called, falls back to builtin', async () => {
+  jev._resetForTest();
+  let paidAttempts = 0;
+  const spyTransport = async () => { paidAttempts++; return { color: 'green', source: 'typesafe', confidence: 0.9 }; };
+  const map = await jev.classifyDots([row('a', 'purple', 'gated · 1'), row('b', 'green', 'working')], {
+    paidEnabled: true, capUsd: 5, readLedger: () => todayLine(5.0), paidTransport: spyTransport,
+  });
+  const s = jev.getStats();
+  assert.equal(paidAttempts, 0, 'no paid attempt once cap is reached');
+  assert.ok(s.capBlocked >= 2, 'cap-block counted for each miss');
+  assert.equal(s.paidCalls, 0);
+  assert.ok(map.get('a').color, 'still shows a color (builtin fallback)');
+});
+
+test('ledger-read failure is fail-closed (Infinity spend -> no paid)', async () => {
+  jev._resetForTest();
+  let paidAttempts = 0;
+  await jev.classifyDots([row('a', 'green', 'working')], {
+    paidEnabled: true, capUsd: 5, readLedger: () => { throw new Error('ledger unreadable'); },
+    paidTransport: async () => { paidAttempts++; return { color: 'green', source: 'typesafe' }; },
+  });
+  assert.equal(paidAttempts, 0, 'unreadable ledger must not enable spend');
+  assert.ok(jev.getStats().capBlocked >= 1);
+});
+
+test('paid fallback: transport error -> builtin color, never blank, never throws', async () => {
+  jev._resetForTest();
+  const map = await jev.classifyDots([row('a', 'purple', 'gated · 1 memo')], {
+    paidEnabled: true, capUsd: 5, readLedger: () => '', // $0 spent -> under cap -> paid attempted
+    paidTransport: async () => { throw new Error('boom'); },
+  });
+  const s = jev.getStats();
+  assert.equal(s.paidFellBack, 1);
+  assert.equal(map.get('a').color, 'purple', 'fell back to a real builtin color');
+  assert.equal(map.get('a').source, 'builtin');
+});
+
+test('paid used when armed + under cap + transport ok, and logs the spend', async () => {
+  jev._resetForTest();
+  let logged = 0;
+  const map = await jev.classifyDots([row('a', 'yellow', 'needs direction')], {
+    paidEnabled: true, capUsd: 5, readLedger: () => '',
+    paidTransport: async () => ({ color: 'green', source: 'typesafe', confidence: 0.88 }),
+    logSpend: () => { logged++; },
+  });
+  const s = jev.getStats();
+  assert.equal(s.paidCalls, 1);
+  assert.equal(logged, 1, 'every paid call is logged to the cost ledger');
+  assert.equal(map.get('a').color, 'green');
+  assert.equal(map.get('a').source, 'typesafe');
+});
+
+test('classifyDots never throws on junk rows', async () => {
+  jev._resetForTest();
+  const map = await jev.classifyDots([null, {}, row('a', 'green', 'x'), { tty: '', color: 'green' }]);
+  assert.ok(map.get('a'));
+});

← 26f22db dotbar: ticket states as vertical chips with right-edge labe  ·  back to Desktop Dotbar  ·  dotbar: mark orphaned sessions (live claude, no iTerm2 tab) d56df0f →