Give every repo a memory. Each scan becomes a durable, versioned snapshot — so a repo's health, fit, and flags read as a trajectory, not a single anonymous number.
RepoLens's pitch is "evaluations compound" — but today saveRepooverwrites the latest analysis on every re-scan and keeps just a single prevFitLevel. The diffAnalyses() engine is fully built, yet structurally starved: it can only ever see two points because only one prior exists.
+
Scan Ledger persists the history those features were waiting for — the missing substrate that later unlocks drift/regret alerts and decision-replay.
+
+
+
+
02
What you'll see
+
Two read-only surfaces. Both appear only once a repo has ≥2 scans, and both stay static under reduced-motion.
+
+
+
Library card — health sparkline
+
+
+
+ facebook/react
+ Strong
+
+
ui-library · 220k★ · health 91
+
+
+ +19 · 6 scans · 60d
+
+
+
+
+
+
Verdict tab — "History" strip
+
+
History
+
6 scans · last 60 days
+
+ Health
+
+
+ 72 → 80 → 76 → 84 → 88 → 91
+
+
+
+ Fit
+ care → solid → solid → solid → strong ↑
+
+
+ Flags
+ −2 resolved · +0 new
+
+
+
+
+
Mockups rendered live — the real sparkline is an inline SVG string built the same way graph.js / diagram.js draw, no chart library.
+
+
+
03
Confirmed decisions
+
+
Snapshot trigger
Every scanIt's a scan ledger — honest, and the cap bounds it.
+
Sparkline metric
Health over timeFit shown as the line's tint + the progression row.
+
Retention
30 / repoRing buffer — drop the oldest.
+
Storage shape
One record / repoA snaps[] array — trivial trimming, one read.
+
+
+
+
04
Under the hood
+
A new IndexedDB store, one new pure module, thin store wrappers. Everything testable lands on the testable side of the codebase.
+
+
Data model — new snapshots store
+
// one record per repo, keyed by id (same convention as every store)
+{
+ id: <repoHash>, // hashRepoId(repoId) — the hash 'repos' already uses
+ repoId: string,
+ snaps: Snapshot[] // oldest → newest, max 30
+}
+
+Snapshot = {
+ ts: string, // ISO; equals the scan's saved_at
+ health: number | null,
+ fit: 'strong'|'solid'|'care'|'risky'|'unrated',
+ stars: number,
+ flags: string[], // red_flag titles at scan time
+ version: string | null // repo HEAD sha / pkg version, if known
+}
+
+
Data flow
+
+
1
A scan completes → saveRepo() writes the repos payload (unchanged), then calls appendScanSnapshot(payload).
+
2
Library loads → snapshots are batch-read once via a single idbGetAll('snapshots') into a Map (no per-card round-trips); each card draws its sparkline.
+
3
Verdict tab renders → the History strip is built from the same snapshotTrend(), through the existing html/esc escaping path.
+
+
+
New pure module — snapshots.jsfully unit-tested
+
+
toSnapshot(payload) → Snapshot
Trim a repo payload to a snapshot. Fit via deriveFit(payload)?.level — the same helper store.js already uses, so the ledger matches the app.
+
appendSnapshot(snaps, snap, cap = 30) → Snapshot[]
An inline-SVG points/path string. null if <2 usable points.
+
+
+
+
05
Migration, backup & edges
+
+
+
Additive migration
+
+
store/idb.js: DB_VERSION 3 → 4, add 'snapshots' to STORES. Existing data untouched — same as the v2 (collections) / v3 (decisions) upgrades.
+
Backfill: a repo scanned before this feature has no record — on first read, synthesize one snapshot from its stored payload, so existing users see a point right away.
+
+
+
+
Backup & safety
+
+
snapshots joins exportStores/importStores; bump BACKUP_VERSION; clamp to the 30-cap on import (fails safe).
+
Quota: 30 × ~200 B × N repos ≈ a few MB — negligible for IndexedDB.
+
Race:saveRepo is the only writer and scans are serialized — read-modify-write is safe.
+
+
+
+
+
+
06
Testing & footprint
+
+
+
Tests
+
+
snapshots.test.js — append/trim/immutability, trend deltas, fit direction, sparkline math (<2 → null).
+
Store (fake-indexeddb) — append + cap at 30, backfill-from-payload, export/import round-trip + clamp.
+
UI rendering stays untested (repo norm); its pure view-model builders are covered.
+
+
diff --git a/docs/superpowers/specs/2026-06-15-scan-ledger-design.md b/docs/superpowers/specs/2026-06-15-scan-ledger-design.md
new file mode 100644
index 0000000..1d4c046
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-15-scan-ledger-design.md
@@ -0,0 +1,137 @@
+# Scan Ledger — Design
+
+**Date:** 2026-06-15
+**Status:** Approved in brainstorm → ready for implementation plan
+**Scope (v1):** "Foundation + visible trajectory"
+
+## Goal
+
+RepoLens's stated direction is _"evaluations compound,"_ but `saveRepo` overwrites
+the latest analysis on every re-scan and keeps only a single `prevFitLevel`, so the
+existing `diffAnalyses()` engine can only ever compare two points. **Scan Ledger**
+persists a versioned history of every scan per repo and surfaces each repo's
+trajectory — turning the one-shot explainer into a longitudinal monitor, and laying
+the substrate that later unlocks drift/regret alerts and decision-replay.
+
+## Non-goals (v1)
+
+- Drift/regret alerts wired to real history (deferred — the ledger is their substrate).
+- Decision-replay, portfolio-level views (deferred).
+- Editing/annotating past snapshots.
+- No new network calls, no new permissions, no backend. 100% client-side.
+
+## Confirmed decisions
+
+- **Trigger:** record a snapshot on **every** successful scan (it's a _scan_ ledger).
+- **Sparkline metric:** **health** over time.
+- **Retention:** ring buffer, **30 snapshots/repo**, drop oldest.
+- **Storage shape:** one record per repo holding a `snaps` array.
+
+## Data model
+
+New IndexedDB object store `snapshots`, keyed by `id` (same convention as every
+existing store). **One record per repo:**
+
+```
+{
+ id: , // hashRepoId(repoId) — the same hash store.js uses for 'repos'
+ repoId: string,
+ snaps: Snapshot[] // oldest → newest, max 30
+}
+
+Snapshot = {
+ ts: string, // ISO timestamp; equals the scan's saved_at
+ health: number | null, // health score (0–100)
+ fit: 'strong' | 'solid' | 'care' | 'risky' | 'unrated',
+ stars: number,
+ flags: string[], // red_flag titles at scan time
+ version: string | null // repo HEAD sha / package version if known, else null
+}
+```
+
+Rationale for one-record-per-repo: trivial retention (trim the array), a single
+`idbGet` to read a repo's history, and far fewer rows than per-snapshot records.
+`saveRepo` is the only writer and scans are serialized through the AI queue, so the
+read-modify-write has no real race.
+
+## Migration
+
+`store/idb.js`: bump `DB_VERSION` 3 → 4 and add `'snapshots'` to `STORES`. The
+existing `onupgradeneeded` creates any missing store, so all existing data survives
+untouched — identical to the v1→v2 (`collections`) and v2→v3 (`decisions`) upgrades.
+
+**Backfill:** a repo scanned before this feature has no `snapshots` record. On first
+`listSnapshots`, if there's no record but the repo has a stored payload, synthesize a
+single snapshot from that payload and persist it — so existing users see one point
+immediately and the next scan adds the second.
+
+## Pure logic — new `snapshots.js` (no DOM / no chrome)
+
+- `toSnapshot(payload)` → `Snapshot` — extract the trimmed fields from a repo payload.
+ Fit comes from `deriveFit(payload)?.level` (the same helper `store.js` already uses
+ to compute `prevFitLevel`), so the ledger's fit matches the rest of the app.
+- `appendSnapshot(snaps, snap, cap = 30)` → `Snapshot[]` — immutable: append, then
+ keep only the last `cap`.
+- `snapshotTrend(snaps)` → `{ series, first, latest, healthDelta, fitDirection,
+ flagsResolved, flagsNew, daysSpan }`. Reuses `diffAnalyses()` for the first-vs-latest
+ fit/flag deltas by adapting each snapshot to the shape it expects (`ts` → `cachedAt`).
+ `series` is `[{ ts, health, fit, stars }]`.
+- `sparkline(series, { metric = 'health', width, height })` → an SVG points/path
+ **string** (string builder, like `graph.js` / `diagram.js` — no chart library).
+ Returns `null` if fewer than 2 usable points.
+
+`store.js` gains thin, non-pure wrappers that do the IndexedDB I/O and call the pure
+helpers:
+
+- `appendScanSnapshot(payload)` — invoked by `saveRepo` after the `repos` write: read
+ the repo's `snapshots` record, `appendSnapshot(toSnapshot(payload))`, `idbPut`.
+- `listSnapshots(repoId)` — `idbGet`, with the backfill above.
+
+## Data flow
+
+1. Scan completes → `saveRepo(analysis)` writes the `repos` payload **(unchanged)**,
+ then calls `appendScanSnapshot(payload)`.
+2. Library renders → snapshots are **batch-loaded once** via a single
+ `idbGetAll('snapshots')` into a `Map` (avoid N per-card round-trips); `card(r)`
+ pulls its series → `snapshotTrend` → `sparkline` → inline SVG.
+3. Verdict tab renders → builds the History strip from the same `snapshotTrend`.
+
+## UI surface (read-only)
+
+- **Library card** (`library.js card()`): a tiny inline-SVG **health** sparkline,
+ stroke tinted by the current fit token, with a `+Δ / N days` caption. Hidden if
+ `<2` points. Static under `prefers-reduced-motion`.
+- **Verdict tab** (`output-tab.js`): a compact **"History"** section — health
+ sparkline + the series numbers, fit progression (`care → solid → strong ↑`), and
+ flags resolved/new. Hidden if `<2` points. Rendered through the existing
+ `html` / `esc` escaping path.
+
+## Backup / export
+
+`store.js` `exportStores` / `importStores`: include `snapshots`. Bump
+`BACKUP_VERSION`. On import, clamp each repo's `snaps` to the 30-cap and validate
+shape, failing safe like the existing bounded import.
+
+## Testing
+
+- `tests/snapshots.test.js` (new): `toSnapshot`; `appendSnapshot` (append + trim +
+ immutability); `snapshotTrend` (deltas, empty/single input, fit direction);
+ `sparkline` (point math, `<2` → `null`).
+- Store tests (`fake-indexeddb`): `appendScanSnapshot` appends + caps at 30;
+ `listSnapshots` backfills from the payload when empty; export/import round-trips
+ snapshots and clamps on import.
+- UI rendering stays untested (repo norm); the pure view-model builders that feed it
+ are covered.
+
+## Files
+
+- **New:** `snapshots.js`, `tests/snapshots.test.js`
+- **Edit:** `store/idb.js` (migration), `store.js` (append/list/backup),
+ `library.js` (card sparkline + batched load), `output-tab.js` (History strip),
+ `backup.js` (version bump + import clamp)
+
+## Risks / mitigations
+
+- **Quota:** 30 × ~200 B × N repos ≈ a few MB worst case — negligible for IndexedDB.
+- **Read-modify-write race:** scans are serialized, single writer — acceptable.
+- **Library perf:** batch-load snapshots once into a `Map` at load, never per-card.
From cf123d822d5e21348839123bc9a0bd6eec25b70b Mon Sep 17 00:00:00 2001
From: ares <285551516+New1Direction@users.noreply.github.com>
Date: Mon, 15 Jun 2026 21:37:48 -0700
Subject: [PATCH 02/10] docs: Scan Ledger implementation plan
---
.../plans/2026-06-15-scan-ledger.md | 781 ++++++++++++++++++
1 file changed, 781 insertions(+)
create mode 100644 docs/superpowers/plans/2026-06-15-scan-ledger.md
diff --git a/docs/superpowers/plans/2026-06-15-scan-ledger.md b/docs/superpowers/plans/2026-06-15-scan-ledger.md
new file mode 100644
index 0000000..19ca60d
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-15-scan-ledger.md
@@ -0,0 +1,781 @@
+# Scan Ledger 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:** Persist a versioned, capped history of every repo scan and surface each repo's trajectory (a health sparkline on library cards + a "History" strip on the Verdict tab).
+
+**Architecture:** A new additive IndexedDB store (`snapshots`, one record per repo holding a capped `snaps[]` array). All logic lives in a new pure module `snapshots.js` (no DOM/chrome); `store.js` adds thin IndexedDB wrappers and seeds a snapshot on every `saveRepo`. The UI reads snapshots (batched for the library) and draws an inline-SVG sparkline. Snapshots round-trip through the existing backup envelope.
+
+**Tech Stack:** Vanilla ES modules (no build step), IndexedDB (promise-wrapped in `store/idb.js`), Vitest + `fake-indexeddb` for tests.
+
+**Spec:** `docs/superpowers/specs/2026-06-15-scan-ledger-design.md`
+
+---
+
+## File structure
+
+| File | Responsibility | Change |
+|------|----------------|--------|
+| `snapshots.js` | Pure ledger logic: `toSnapshot`, `appendSnapshot`, `snapshotTrend`, `sparkline` | **create** |
+| `tests/snapshots.test.js` | Unit tests for the above | **create** |
+| `diff-analysis.js` | Reuse its `FIT_ORDER` (export it) | modify (1 line) |
+| `store/idb.js` | Add the `snapshots` object store (v3→v4) | modify |
+| `store.js` | `appendScanSnapshot`, `listSnapshots`, `listAllSnapshots`; call from `saveRepo`; backup wiring | modify |
+| `backup.js` | Include `snapshots` in the envelope; bump `BACKUP_VERSION` | modify |
+| `library.js` | Batch-load snapshots in `init()`; draw the card sparkline | modify |
+| `output-tab.js` | `renderHistory(d)` — the Verdict "History" strip | modify |
+| `output-tab.html` | A `
` host for the strip | modify |
+
+Notes carried from the spec into this plan:
+- **Trigger:** every scan. **Metric:** health. **Cap:** 30/repo (ring buffer).
+- **Seeding (refines the spec's "backfill on read"):** seed the *prior* scan into the ledger at scan time, inside `appendScanSnapshot`, using the old payload `saveRepo` already reads. This is strictly better than read-time backfill — no write-on-read side effect, and an existing repo's first re-scan yields a real 2-point trend instead of one dead point. `listSnapshots` therefore stays a pure read.
+- **Trend computation (refines the spec's "reuse `diffAnalyses`"):** `snapshotTrend` computes deltas directly rather than feeding snapshots through `diffAnalyses`. Reason: snapshots store the already-*derived* `fit` and flag *titles* (no severity), so `diffAnalyses`' `_fitLevel` would re-derive fit from lossy data and get it wrong. Direct computation uses the stored fit and is simpler and correct. The one thing reused is `FIT_ORDER` (now exported from `diff-analysis.js`), so the constant isn't copied a fourth time.
+
+---
+
+### Task 1: Pure `snapshots.js` module + tests
+
+**Files:**
+- Create: `snapshots.js`
+- Create: `tests/snapshots.test.js`
+- Modify: `diff-analysis.js:4` (export `FIT_ORDER`)
+
+- [ ] **Step 1: Export `FIT_ORDER` from `diff-analysis.js`**
+
+Change line 4 from `const FIT_ORDER = ['strong', 'solid', 'care', 'risky'];` to:
+
+```js
+export const FIT_ORDER = ['strong', 'solid', 'care', 'risky'];
+```
+
+(Avoids a 4th copy of the constant; the rest of `diff-analysis.js` is unchanged.)
+
+- [ ] **Step 2: Write the failing tests** — `tests/snapshots.test.js`
+
+```js
+import { describe, it, expect } from 'vitest';
+import { toSnapshot, appendSnapshot, snapshotTrend, sparkline, SNAPSHOT_CAP } from '../snapshots.js';
+
+describe('toSnapshot', () => {
+ it('trims a payload to the snapshot shape, normalizing health and flags', () => {
+ const snap = toSnapshot(
+ { repoId: 'a/b', health: 88, stars: 1200, red_flags: [{ title: 'No tests' }, { title: '' }], saved_at: '2026-06-01T00:00:00.000Z' },
+ '2026-06-01T00:00:00.000Z'
+ );
+ expect(snap).toEqual({
+ ts: '2026-06-01T00:00:00.000Z',
+ health: 88,
+ fit: 'strong',
+ stars: 1200,
+ flags: ['No tests'],
+ version: null,
+ });
+ });
+ it('accepts health as a { score } object and defaults ts to saved_at', () => {
+ const snap = toSnapshot({ repoId: 'a/b', health: { score: 60 }, stars: 0, red_flags: [], saved_at: '2026-06-02T00:00:00.000Z' });
+ expect(snap.health).toBe(60);
+ expect(snap.ts).toBe('2026-06-02T00:00:00.000Z');
+ expect(snap.fit).toBe('care'); // 60, 0 flags
+ });
+ it('yields null health when absent', () => {
+ expect(toSnapshot({ repoId: 'a/b', red_flags: [] }, '2026-06-01T00:00:00.000Z').health).toBeNull();
+ });
+});
+
+describe('appendSnapshot', () => {
+ it('appends immutably and never mutates the input', () => {
+ const a = [{ ts: '1' }];
+ const out = appendSnapshot(a, { ts: '2' });
+ expect(out).toHaveLength(2);
+ expect(a).toHaveLength(1);
+ });
+ it('keeps only the most recent `cap`', () => {
+ const many = Array.from({ length: SNAPSHOT_CAP }, (_, i) => ({ ts: String(i) }));
+ const out = appendSnapshot(many, { ts: 'new' }, SNAPSHOT_CAP);
+ expect(out).toHaveLength(SNAPSHOT_CAP);
+ expect(out[0].ts).toBe('1');
+ expect(out[out.length - 1].ts).toBe('new');
+ });
+ it('handles a non-array prev', () => {
+ expect(appendSnapshot(undefined, { ts: 'x' })).toEqual([{ ts: 'x' }]);
+ });
+});
+
+describe('snapshotTrend', () => {
+ const snaps = [
+ { ts: '2026-06-01T00:00:00.000Z', health: 72, fit: 'care', stars: 100, flags: ['No tests', 'Stale'] },
+ { ts: '2026-06-11T00:00:00.000Z', health: 91, fit: 'strong', stars: 150, flags: ['No tests'] },
+ ];
+ it('returns null for <2 points', () => {
+ expect(snapshotTrend([])).toBeNull();
+ expect(snapshotTrend([snaps[0]])).toBeNull();
+ });
+ it('computes health delta, fit direction, flag diffs and day span', () => {
+ const t = snapshotTrend(snaps);
+ expect(t.count).toBe(2);
+ expect(t.healthDelta).toBe(19);
+ expect(t.fitFrom).toBe('care');
+ expect(t.fitTo).toBe('strong');
+ expect(t.fitDirection).toBe('up');
+ expect(t.flagsResolved).toEqual(['Stale']);
+ expect(t.flagsNew).toEqual([]);
+ expect(t.daysSpan).toBe(10);
+ expect(t.series).toHaveLength(2);
+ });
+});
+
+describe('sparkline', () => {
+ it('returns null for <2 plottable points', () => {
+ expect(sparkline([{ health: 5 }])).toBeNull();
+ expect(sparkline([{ health: null }, { health: null }])).toBeNull();
+ });
+ it('builds an svg polyline scaled to the box', () => {
+ const svg = sparkline([{ health: 0 }, { health: 50 }, { health: 100 }], { width: 100, height: 20 });
+ expect(svg).toContain('
` of `.lc-meta` is built — i.e. insert a new line in the returned template right after the `.lc-meta` closing `` (after line 155) and before the `${tags || boardDots ...}` line (156):
+
+```js
+ ${(() => {
+ const trend = snapshotTrend(snapsByRepo.get(r.repoId) || []);
+ if (!trend) return '';
+ const svg = sparkline(trend.series, { metric: 'health', width: 96, height: 22 });
+ if (!svg) return '';
+ const sign = trend.healthDelta > 0 ? '+' : '';
+ const delta = trend.healthDelta != null ? `${sign}${trend.healthDelta}` : '';
+ return `