diff --git a/backup.js b/backup.js
index 912b992..fe2990c 100644
--- a/backup.js
+++ b/backup.js
@@ -8,12 +8,16 @@
// scan cache — round-trips through one human-readable JSON file.
export const BACKUP_FORMAT = 'repolens-backup';
-export const BACKUP_VERSION = 1;
+export const BACKUP_VERSION = 2;
// Upper bounds on how much a single import may write, so a hostile or corrupt
// file can't pin the IndexedDB write lock or blow the storage quota. Anything
// past these is dropped with a surfaced warning (never silently).
-export const MAX_ROWS = { repos: 5000, nodes: 20000, edges: 50000, cache: 5000, collections: 2000, decisions: 5000 };
+export const MAX_ROWS = { repos: 5000, nodes: 20000, edges: 50000, cache: 5000, collections: 2000, decisions: 5000, snapshots: 5000 };
+
+// Per-repo snapshot ring-buffer cap (mirrors SNAPSHOT_CAP in snapshots.js); each
+// imported snapshots row is trimmed to its most recent SNAP_CAP entries.
+const SNAP_CAP = 30;
const arr = (x) => (Array.isArray(x) ? x : []);
const rowHasRepo = (r) => !!(r && r.id != null && r.payload && r.payload.repoId);
@@ -22,10 +26,11 @@ const edgeOk = (e) => !!(e && e.id != null && e.source != null && e.target != nu
const cacheOk = (c) => !!(c && c.repoId && c.platform);
const collectionOk = (c) => !!(c && c.id != null && c.payload && typeof c.payload.name === 'string');
const decisionOk = (d) => !!(d && d.id != null && d.payload && d.payload.repoId && d.payload.decision);
+const snapshotOk = (r) => !!(r && r.id != null && r.repoId && Array.isArray(r.snaps));
/** Empty normalized shape — the safe fallback when a file can't be parsed. */
function emptyValue() {
- return { repos: [], nodes: [], edges: [], cache: [], collections: [], decisions: [] };
+ return { repos: [], nodes: [], edges: [], cache: [], collections: [], decisions: [], snapshots: [] };
}
/**
@@ -34,19 +39,14 @@ function emptyValue() {
* @param {{ repos?: object[], nodes?: object[], edges?: object[], cache?: object[], exportedAt?: string }} [parts]
* @returns {object}
*/
-export function buildBackup({ repos, nodes, edges, cache, collections, decisions, exportedAt } = {}) {
- const r = arr(repos), n = arr(nodes), e = arr(edges), c = arr(cache), col = arr(collections), dec = arr(decisions);
+export function buildBackup({ repos, nodes, edges, cache, collections, decisions, snapshots, exportedAt } = {}) {
+ const r = arr(repos), n = arr(nodes), e = arr(edges), c = arr(cache), col = arr(collections), dec = arr(decisions), snap = arr(snapshots);
return {
format: BACKUP_FORMAT,
version: BACKUP_VERSION,
exportedAt: exportedAt || new Date().toISOString(),
- counts: { repos: r.length, nodes: n.length, edges: e.length, cache: c.length, collections: col.length, decisions: dec.length },
- repos: r,
- nodes: n,
- edges: e,
- cache: c,
- collections: col,
- decisions: dec,
+ counts: { repos: r.length, nodes: n.length, edges: e.length, cache: c.length, collections: col.length, decisions: dec.length, snapshots: snap.length },
+ repos: r, nodes: n, edges: e, cache: c, collections: col, decisions: dec, snapshots: snap,
};
}
@@ -87,6 +87,7 @@ export function validateBackup(obj) {
cache: clamp('cache', arr(obj.cache).filter(cacheOk)),
collections: clamp('collections', arr(obj.collections).filter(collectionOk)),
decisions: clamp('decisions', arr(obj.decisions).filter(decisionOk)),
+ snapshots: clamp('snapshots', arr(obj.snapshots).filter(snapshotOk).map((r) => ({ ...r, snaps: arr(r.snaps).slice(-SNAP_CAP) }))),
};
return { ok: errors.length === 0, errors, warnings, value };
}
@@ -99,7 +100,7 @@ export function validateBackup(obj) {
*/
export function summarizeBackup(obj) {
const { value } = validateBackup(obj);
- return { repos: value.repos.length, nodes: value.nodes.length, edges: value.edges.length, cache: value.cache.length, collections: value.collections.length, decisions: value.decisions.length };
+ return { repos: value.repos.length, nodes: value.nodes.length, edges: value.edges.length, cache: value.cache.length, collections: value.collections.length, decisions: value.decisions.length, snapshots: value.snapshots.length };
}
/**
diff --git a/diff-analysis.js b/diff-analysis.js
index 2e39932..a048f20 100644
--- a/diff-analysis.js
+++ b/diff-analysis.js
@@ -1,7 +1,7 @@
// Pure helpers for "Diff Since I Last Looked" — compares two cached analysis snapshots.
// No DOM, no chrome APIs, unit-testable.
-const FIT_ORDER = ['strong', 'solid', 'care', 'risky'];
+export const FIT_ORDER = ['strong', 'solid', 'care', 'risky'];
const FIT_RANK = Object.fromEntries(FIT_ORDER.map((f, i) => [f, i]));
export function daysSince(isoTs) {
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..4e4dbaa
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-15-scan-ledger.md
@@ -0,0 +1,794 @@
+# 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 (health, flag titles, fit via deriveFit)', () => {
+ // Real red_flags carry a `severity`; deriveFit counts non-'ok' flags as warnings,
+ // so health 88 with 2 warning flags derives to 'care' — and the ledger's fit must
+ // match what the app shows (deriveFit on the same payload), so toSnapshot passes the
+ // real red_flags through unfiltered.
+ const snap = toSnapshot(
+ { repoId: 'a/b', health: 88, stars: 1200, red_flags: [{ title: 'No tests', severity: 'warn' }, { title: '', severity: 'warn' }], 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: 'care',
+ stars: 1200,
+ flags: ['No tests'], // empty title dropped; titles kept regardless of severity
+ version: null,
+ });
+ });
+ it('derives a strong fit for a healthy, flag-free repo', () => {
+ const snap = toSnapshot({ repoId: 'a/b', health: 90, stars: 0, red_flags: [] }, '2026-06-01T00:00:00.000Z');
+ expect(snap.fit).toBe('strong');
+ });
+ 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 `
`;
+ })()}
+```
+
+- [ ] **Step 5: Add the sparkline styles** — append to `library.html` inside its `
+
+
+
+
+
+
Design Blueprint · for review
+
Scan Ledger
+
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.
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.