# Live Benchmark Watcher Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build `scripts/live-benchmark.ts` — a `tsx`-runnable live terminal watcher that polls the CacheLane SQLite DB and redraws a stats panel showing cache hit ratios, savings, and per-turn details.

**Architecture:** Single standalone script in `scripts/`. On each tick it opens the DB twice (once via `openDatabase` for typed stats aggregates, once via raw `better-sqlite3` for the per-turn rows), formats a panel with ANSI escape codes, and writes it to stdout after clearing the terminal. No persistent DB connection — open/close per tick to avoid WAL contention with the hooks writing concurrently.

**Tech Stack:** `tsx` (runtime), `better-sqlite3` (raw per-turn query), `openDatabase` from `src/storage/index.ts` (typed stats), `node:util parseArgs` (CLI flags). No new npm deps.

---

## File Structure

| Action | Path | Responsibility |
|--------|------|---------------|
| Create | `scripts/live-benchmark.ts` | Entire watcher — args, DB reads, renderer, poll loop |

No other files modified. No tests file — this is a dev verification tool, not shipped code. Correctness is verified by running it and observing the output.

---

### Task 1: Scaffold — shebang, imports, arg parsing, identity resolution

**Files:**
- Create: `scripts/live-benchmark.ts`

- [ ] **Step 1: Create the file with scaffold**

```typescript
#!/usr/bin/env tsx
import { parseArgs } from "node:util";
import { homedir } from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
import {
  openDatabase,
  type CachelaneStats,
  type StatsScope,
} from "../src/storage/index.js";

// ── CLI flags ────────────────────────────────────────────────────────────────
const { values } = parseArgs({
  options: {
    interval: { type: "string", default: "3" },
    db:       { type: "string" },
    scope:    { type: "string", default: "session" },
  },
  strict: false,
});

const INTERVAL_MS = Math.max(1000, (Number(values.interval) || 3) * 1000);
const SCOPE: StatsScope =
  values.scope === "workspace" || values.scope === "all" ? values.scope : "session";
const DB_PATH =
  (values.db as string | undefined) ??
  path.join(
    process.env.CACHELANE_HOME ?? path.join(homedir(), ".cachelane"),
    "cachelane.db",
  );
const WORKSPACE_ID = process.env.CACHELANE_WORKSPACE_ID ?? "default";
const SESSION_ID   = process.env.CACHELANE_SESSION_ID   ?? "default";
```

- [ ] **Step 2: Verify tsx can parse the scaffold**

```bash
tsx scripts/live-benchmark.ts --interval 99999 2>&1 | head -5
```

Expected: the script starts without throwing (it will immediately try to draw a panel, showing either the DB-missing state or the empty-data state depending on whether CacheLane is installed). Press Ctrl+C after seeing output.

---

### Task 2: `readTick()` — DB query function

**Files:**
- Modify: `scripts/live-benchmark.ts`

- [ ] **Step 1: Define the `TurnRow` interface and `TickResult` union, then implement `readTick()`**

Append after the constants from Task 1:

```typescript
// ── Data types ───────────────────────────────────────────────────────────────
interface TurnRow {
  turn_number: number;
  input_tokens: number;
  cache_read_tokens: number;
  effective_cost_units: number;
  pruned_blocks_count: number;
  keepalive_pings_since_last_turn: number;
}

type TickResult =
  | { ok: true;  stats: CachelaneStats; recentTurns: TurnRow[] }
  | { ok: false; error: string };

// ── DB reads (open/close per tick to avoid WAL contention) ───────────────────
const RECENT_TURNS_SQL = `
  SELECT turn_number, input_tokens, cache_read_tokens,
         effective_cost_units, pruned_blocks_count,
         keepalive_pings_since_last_turn
  FROM   turns
  WHERE  workspace_id = ? AND session_id = ?
  ORDER  BY turn_number DESC
  LIMIT  10
`;

function readTick(): TickResult {
  let cacheDb;
  try {
    cacheDb = openDatabase(DB_PATH);
  } catch {
    return {
      ok: false,
      error: `Database not found: ${DB_PATH}\nRun: node dist/cli/index.js install`,
    };
  }

  try {
    const stats = cacheDb.getStats({
      scope:        SCOPE,
      workspace_id: SCOPE === "all" ? undefined : WORKSPACE_ID,
      session_id:   SCOPE === "session" ? SESSION_ID : undefined,
    });

    const raw = new Database(DB_PATH, { readonly: true });
    const recentTurns = raw
      .prepare<[string, string]>(RECENT_TURNS_SQL)
      .all(WORKSPACE_ID, SESSION_ID) as TurnRow[];
    raw.close();

    return { ok: true, stats, recentTurns };
  } finally {
    cacheDb.close();
  }
}
```

- [ ] **Step 2: Smoke-check that tsx can load the file so far**

```bash
tsx scripts/live-benchmark.ts --interval 99999 2>&1 | head -5
```

Expected: the script starts (may show DB-missing state or empty state). Press Ctrl+C after seeing output — no import errors or syntax errors.

---

### Task 3: `renderPanel()` — ANSI display formatter

**Files:**
- Modify: `scripts/live-benchmark.ts`

- [ ] **Step 1: Add number-formatting helpers**

Append after `readTick`:

```typescript
// ── Formatting helpers ───────────────────────────────────────────────────────
const SEP  = "═".repeat(62);
const LINE = "─".repeat(62);

function fmt(n: number): string {
  return Math.round(n).toLocaleString("en-US");
}

function pct(ratio: number): string {
  return `${(ratio * 100).toFixed(1)}%`;
}

function pad(s: string | number, width: number, right = false): string {
  const str = String(s);
  return right
    ? str.padStart(width)
    : str.padEnd(width);
}
```

- [ ] **Step 2: Implement `renderPanel()`**

Append after the helpers:

```typescript
function renderPanel(result: TickResult): string {
  const now = new Date().toLocaleTimeString("en-US", { hour12: false });
  const intervalSec = INTERVAL_MS / 1000;

  const header = [
    SEP,
    `  CacheLane Live Benchmark${pad(`[${intervalSec}s poll]`, 36, true)}`,
    `  session: ${SESSION_ID}   workspace: ${WORKSPACE_ID}`,
    SEP,
  ].join("\n");

  const footer = `${LINE}\n  Updated: ${now}   DB: ${DB_PATH}\n  Ctrl+C to exit\n${SEP}`;

  if (!result.ok) {
    return `${header}\n\n  ${result.error.replace(/\n/g, "\n  ")}\n\n${footer}`;
  }

  const { stats, recentTurns } = result;

  if (stats.turns === 0) {
    return (
      `${header}\n\n` +
      `  No session data yet — waiting for CacheLane to process a turn...\n\n` +
      footer
    );
  }

  const tokensSaved = Math.round(stats.baseline_cost_units - stats.effective_cost_units);

  const totals = [
    `  TOTALS`,
    `  ${pad("Turns processed",      22)}: ${fmt(stats.turns)}`,
    `  ${pad("Cache hit ratio",      22)}: ${pct(stats.cache_hit_ratio)}`,
    `  ${pad("Savings ratio",        22)}: ${pct(stats.savings_ratio)}`,
    `  ${pad("Cost units saved",     22)}: ${fmt(tokensSaved)}`,
    `  ${pad("Baseline cost units",  22)}: ${fmt(stats.baseline_cost_units)}`,
    `  ${pad("Effective cost units", 22)}: ${fmt(stats.effective_cost_units)}`,
    `  ${pad("Pruned blocks",        22)}: ${fmt(stats.pruner_counts.pruned_blocks)}`,
    `  ${pad("Keepalive pings",      22)}: ${fmt(stats.keepalive_counts.pings)}`,
  ].join("\n");

  const tableHeader = [
    SEP,
    `  LAST ${recentTurns.length} TURNS`,
    `  ${pad("Turn", 6)}${pad("Input", 9, true)}  ${pad("CacheRead", 10, true)}  ${pad("Pruned", 7, true)}  ${pad("KAPings", 8, true)}`,
    `  ${LINE.slice(0, 44)}`,
  ].join("\n");

  const tableRows = recentTurns
    .map(
      (t) =>
        `  ${pad(t.turn_number, 6, true)}` +
        `${pad(fmt(t.input_tokens), 9, true)}  ` +
        `${pad(fmt(t.cache_read_tokens), 10, true)}  ` +
        `${pad(t.pruned_blocks_count, 7, true)}  ` +
        `${pad(t.keepalive_pings_since_last_turn, 8, true)}`,
    )
    .join("\n");

  return [header, totals, tableHeader, tableRows, footer].join("\n");
}
```

- [ ] **Step 3: Verify tsx can parse the renderer additions**

```bash
tsx scripts/live-benchmark.ts --interval 99999 2>&1 | head -5
```

Expected: script starts without syntax errors. Press Ctrl+C after output appears.

---

### Task 4: Poll loop, SIGINT handler, and `main()`

**Files:**
- Modify: `scripts/live-benchmark.ts`

- [ ] **Step 1: Add `startWatcher()` and wire `main()`**

Append after `renderPanel`:

```typescript
// ── Watcher ──────────────────────────────────────────────────────────────────
function startWatcher(): void {
  // Hide cursor for clean redraws
  process.stdout.write("\x1b[?25l");

  function tick(): void {
    const result = readTick();
    process.stdout.write("\x1b[2J\x1b[H"); // clear screen + move to top-left
    process.stdout.write(renderPanel(result) + "\n");
  }

  tick(); // draw immediately on start, don't wait for first interval
  const timer = setInterval(tick, INTERVAL_MS);

  function exit(): void {
    clearInterval(timer);
    process.stdout.write("\x1b[?25h\n"); // restore cursor
    process.stdout.write("Exiting.\n");
    process.exit(0);
  }

  process.on("SIGINT", exit);
  process.on("SIGTERM", exit);
}

// ── Entry point ──────────────────────────────────────────────────────────────
startWatcher();
```

- [ ] **Step 2: Verify the complete script runs end-to-end**

```bash
tsx scripts/live-benchmark.ts --interval 99999 2>&1 | head -15
```

Expected: full panel renders (DB-missing state since hooks aren't installed yet). Press Ctrl+C — cursor should be restored (no stuck hidden cursor).

---

### Task 5: End-to-end smoke test with a real session

This task verifies the full CacheLane pipeline fires. Follow these steps in order.

- [ ] **Step 1: Build the CacheLane binary**

```bash
npm run build 2>&1 | tail -5
```

Expected: `dist/cli/index.js` exists afterwards:
```bash
ls dist/cli/index.js
```

- [ ] **Step 2: Install CacheLane hooks into Claude Code**

```bash
node dist/cli/index.js install
```

Expected output (JSON with `changed: true`):
```json
{"changed":true,"mcp":true,"hooks":true}
```

- [ ] **Step 3: Run `doctor` to confirm hooks are registered**

```bash
node dist/cli/index.js doctor --json
```

Expected: all checks `ok: true`, especially `mcp` and `hooks`.

- [ ] **Step 4: Start the watcher in a separate terminal**

```bash
tsx scripts/live-benchmark.ts
```

Expected: panel renders immediately showing "No session data yet — waiting..."

- [ ] **Step 5: Start a NEW Claude Code session**

The hooks must be active from session start. Open a new Claude Code window/tab. Send a few messages to generate turns.

- [ ] **Step 6: Observe the watcher updating**

After each Claude Code message, the watcher should redraw within `--interval` seconds showing:
- `Turns processed` incrementing
- `Cache hit ratio` > 0% after turn 2+ (prefix warms on turn 1, reads on turn 2+)
- `Savings ratio` > 0% once cache hits start

- [ ] **Step 7: Confirm with `stats` CLI directly**

In another terminal:
```bash
node dist/cli/index.js stats --json
```

Expected: same numbers as the watcher panel (validates that `cachelane stats` is what real users run).

- [ ] **Step 8: Cleanup after verification**

```bash
node dist/cli/index.js uninstall --purge
```

Expected: `{"changed":true}` and `~/.cachelane/` directory is gone.
