/** * campaign-ledger — durable, machine-wide campaign ledger (v5.7 Fase 2, Block 3a THIN). * * The ledger sits ABOVE individual config-audit sessions: it tracks which repos are part * of a machine-wide audit campaign, each repo's lifecycle status (pending → audited → * planned → implemented), and a machine-wide roll-up by status + severity. It is resumable * across sessions because it persists to a single JSON file OUTSIDE the plugin dir * (`~/.claude/config-audit/campaign-ledger.json`, next to `sessions/` and `mcp-cache/`), * so it survives plugin uninstall/reinstall/upgrade. * * Design mirrors the knowledge-refresh precedent: the transformations are PURE and * deterministic (every "now" is injected as a YYYY-MM-DD string, never read from the clock * here), so they are fully unit-testable; a thin IO shell (load/save, explicit path) does * the only filesystem work. `validateLedger` is soft (returns a result, never throws) for * externally-loaded data; the transforms throw on programmer error (invalid status, unknown * path). `schemaVersion` is stamped from the start so a future Block 4 migration is cheap. * * THIN scope (Block 3a): ledger core + roll-up + persistence only — NOT execution, * orchestration, or a command surface (those are Blocks 3b/3c/4). Zero dependencies. * See docs/v5.7-optimization-lens-plan.md §Fase 2. */ import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { homedir } from 'node:os'; /** Ledger schema version — bump + add a migration (Block 4) on any breaking shape change. */ export const CAMPAIGN_SCHEMA_VERSION = 1; /** Per-repo lifecycle, in order. A repo advances through these as the campaign progresses. */ export const STATUSES = Object.freeze(['pending', 'audited', 'planned', 'implemented']); /** Severity buckets aggregated by the machine-wide roll-up. */ const SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low']); /** * Order-of-magnitude severity weights for the cross-repo backlog priority score. Each tier * dominates the next so a single higher-severity finding outranks many lower ones; exact * score collisions are still broken deterministically by the lexicographic + name tie-break * in `buildBacklog`. Exported so the score is documented, not a magic number. */ export const SEVERITY_WEIGHTS = Object.freeze({ critical: 1000, high: 100, medium: 10, low: 1 }); const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; /** Validate an injected `now` (required, YYYY-MM-DD). Throws — callers pass today's date. */ function requireNow(now) { if (typeof now !== 'string' || !DATE_RE.test(now)) { throw new TypeError('now must be a YYYY-MM-DD string'); } return now; } /** Canonicalize a repo path so the same repo never appears twice under different spellings. */ function normalizePath(path) { if (typeof path !== 'string' || path.trim() === '') { throw new TypeError('repo path is required'); } return resolve(path); } /** * Build an empty, versioned ledger stamped with `now`. * @param {{now: string}} opts * @returns {{schemaVersion:number, createdDate:string, updatedDate:string, repos:object[]}} */ export function createLedger({ now } = {}) { requireNow(now); return { schemaVersion: CAMPAIGN_SCHEMA_VERSION, createdDate: now, updatedDate: now, repos: [] }; } /** * Add a repo to the campaign (status `pending`). Idempotent on the normalized path — a repo * already present is left untouched (its progress is NOT reset). Returns a NEW ledger. * @param {object} ledger * @param {{path: string, name?: string}} repo * @param {{now: string}} opts */ export function addRepo(ledger, { path, name } = {}, { now } = {}) { requireNow(now); const resolved = normalizePath(path); if (ledger.repos.some((r) => r.path === resolved)) { return ledger; // idempotent: already tracked, preserve its status } const repo = { path: resolved, name: typeof name === 'string' && name.trim() !== '' ? name : resolved.split('/').pop(), status: 'pending', sessionId: null, findingsBySeverity: null, tokens: null, updatedDate: now, }; return { ...ledger, updatedDate: now, repos: [...ledger.repos, repo] }; } /** * Transition a tracked repo to a new status, optionally attaching the audit's * findings-by-severity and the producing sessionId. Returns a NEW ledger. * @param {object} ledger * @param {string} path * @param {string} status - one of STATUSES * @param {{now: string, findingsBySeverity?: object|null, sessionId?: string|null}} opts */ export function setRepoStatus(ledger, path, status, { now, findingsBySeverity, sessionId } = {}) { requireNow(now); if (!STATUSES.includes(status)) { throw new RangeError(`invalid status "${status}" — must be one of ${STATUSES.join(', ')}`); } const resolved = normalizePath(path); const idx = ledger.repos.findIndex((r) => r.path === resolved); if (idx === -1) { throw new Error(`repo "${resolved}" is not in the ledger — addRepo first`); } const updated = { ...ledger.repos[idx], status, updatedDate: now }; if (findingsBySeverity !== undefined) updated.findingsBySeverity = findingsBySeverity; if (sessionId !== undefined) updated.sessionId = sessionId; const repos = ledger.repos.slice(); repos[idx] = updated; return { ...ledger, updatedDate: now, repos }; } /** Load-pattern buckets carried by a token summary (manifest `summarizeByLoadPattern` shape). */ const LOAD_PATTERNS = Object.freeze(['always', 'onDemand', 'external', 'unknown']); /** * Set the machine-wide SHARED global always-loaded layer (global CLAUDE.md + agent listing + * global MCP + unscoped global rules + active plugins' always-loaded components). Stored ONCE * at the ledger root — never per repo — so the machine-wide roll-up counts it exactly once * (the structural guard against the historic double-count). The `summary` is the shape * manifest's `summarizeByLoadPattern` emits: `{always|onDemand|external|unknown: {tokens,count}}`. * Returns a NEW ledger. (B2b populates this from a live cross-repo sweep.) * @param {object} ledger * @param {object} summary * @param {{now: string}} opts */ export function setSharedGlobal(ledger, summary, { now } = {}) { requireNow(now); return { ...ledger, updatedDate: now, sharedGlobal: summary }; } /** * Attach a tracked repo's PER-REPO always-loaded token delta (its project-scoped contribution * beyond the shared global layer — project CLAUDE.md / rules / agents / MCP). Same `summary` * shape as `setSharedGlobal`. Mirrors `setRepoStatus`: throws if the repo is untracked. Returns * a NEW ledger. * @param {object} ledger * @param {string} path * @param {object} tokens * @param {{now: string}} opts */ export function setRepoTokens(ledger, path, tokens, { now } = {}) { requireNow(now); const resolved = normalizePath(path); const idx = ledger.repos.findIndex((r) => r.path === resolved); if (idx === -1) { throw new Error(`repo "${resolved}" is not in the ledger — addRepo first`); } const repos = ledger.repos.slice(); repos[idx] = { ...ledger.repos[idx], tokens, updatedDate: now }; return { ...ledger, updatedDate: now, repos }; } /** Tolerantly read the four load-pattern token numbers from a stored summary (or null/old data). */ function bucketTokens(summary) { const out = {}; for (const k of LOAD_PATTERNS) { const v = summary && summary[k]; out[k] = v && typeof v.tokens === 'number' ? v.tokens : 0; } return out; } /** * Machine-wide roll-up: repo counts by status, a severity total across every repo carrying * `findingsBySeverity`, AND a machine-wide always-loaded token bill. The token aggregate adds * the SHARED global layer (counted once, from the ledger root) to the SUM of per-repo deltas, * so `tokens.machineWide` is the honest "context spent every turn across the whole machine". * Pure derivation — never mutates; tolerant of old ledgers without token fields. * @param {object} ledger * @returns {{totalRepos:number, byStatus:object, bySeverity:object, reposWithFindings:number, tokens:object}} */ export function rollUp(ledger) { const byStatus = Object.fromEntries(STATUSES.map((s) => [s, 0])); const bySeverity = Object.fromEntries(SEVERITIES.map((s) => [s, 0])); let reposWithFindings = 0; const sharedGlobal = bucketTokens(ledger.sharedGlobal); const perRepoDelta = Object.fromEntries(LOAD_PATTERNS.map((k) => [k, 0])); const byRepo = []; let reposWithTokens = 0; for (const repo of ledger.repos) { if (byStatus[repo.status] !== undefined) byStatus[repo.status] += 1; const f = repo.findingsBySeverity; if (f && typeof f === 'object') { reposWithFindings += 1; for (const sev of SEVERITIES) { if (typeof f[sev] === 'number') bySeverity[sev] += f[sev]; } } if (repo.tokens && typeof repo.tokens === 'object') { reposWithTokens += 1; const b = bucketTokens(repo.tokens); for (const k of LOAD_PATTERNS) perRepoDelta[k] += b[k]; byRepo.push({ name: repo.name, path: repo.path, always: b.always, onDemand: b.onDemand, external: b.external }); } } // DESC by always-loaded cost ("most expensive repos"); deterministic name tie-break. byRepo.sort((x, y) => y.always - x.always || x.name.localeCompare(y.name)); const machineWide = Object.fromEntries( LOAD_PATTERNS.map((k) => [k, sharedGlobal[k] + perRepoDelta[k]]), ); return { totalRepos: ledger.repos.length, byStatus, bySeverity, reposWithFindings, tokens: { sharedGlobal, perRepoDelta, machineWide, reposWithTokens, byRepo }, }; } /** * Build the single, machine-wide PRIORITIZED backlog the user picks from. Pure derivation — * never mutates. The actionable unit is a REPO (the ledger tracks per-repo severity counts, * not individual findings — it tracks state, it does not re-run audits), so each backlog item * is one repo with outstanding work. * * Inclusion: a repo is in the backlog iff it is NOT yet `implemented` AND has at least one * outstanding finding (`totalFindings > 0`). `implemented` repos are done; `pending` and * zero-finding repos have nothing known to fix (they still surface in `rollUp.byStatus`). * * Order: DESC by `weightedScore` (SEVERITY_WEIGHTS), tie-broken lexicographically by * critical→high→medium→low count, then ascending by `name` — fully deterministic, and the * tie-break preserves "criticals always win" even when two repos share a weighted score. * * @param {object} ledger * @returns {Array<{path:string,name:string,status:string,sessionId:string|null,findingsBySeverity:object,totalFindings:number,weightedScore:number,rank:number}>} */ export function buildBacklog(ledger) { const items = []; for (const repo of ledger.repos) { if (repo.status === 'implemented') continue; const f = repo.findingsBySeverity; if (!f || typeof f !== 'object') continue; const findingsBySeverity = Object.fromEntries( SEVERITIES.map((s) => [s, typeof f[s] === 'number' ? f[s] : 0]), ); const totalFindings = SEVERITIES.reduce((sum, s) => sum + findingsBySeverity[s], 0); if (totalFindings === 0) continue; const weightedScore = SEVERITIES.reduce( (score, s) => score + findingsBySeverity[s] * SEVERITY_WEIGHTS[s], 0, ); items.push({ path: repo.path, name: repo.name, status: repo.status, sessionId: repo.sessionId ?? null, findingsBySeverity, totalFindings, weightedScore, }); } items.sort( (x, y) => y.weightedScore - x.weightedScore || y.findingsBySeverity.critical - x.findingsBySeverity.critical || y.findingsBySeverity.high - x.findingsBySeverity.high || y.findingsBySeverity.medium - x.findingsBySeverity.medium || y.findingsBySeverity.low - x.findingsBySeverity.low || x.name.localeCompare(y.name), ); return items.map((item, i) => ({ ...item, rank: i + 1 })); } /** * Validate a parsed ledger against the schema. Never throws — returns every problem at once * so the caller (and tests) can inspect them. Soft by design (loaded data may be corrupt). * @param {unknown} data * @returns {{valid:boolean, errors:string[]}} */ export function validateLedger(data) { const errors = []; if (!data || typeof data !== 'object' || Array.isArray(data)) { return { valid: false, errors: ['ledger must be an object'] }; } if (data.schemaVersion !== CAMPAIGN_SCHEMA_VERSION) { errors.push(`schemaVersion must be ${CAMPAIGN_SCHEMA_VERSION}`); } for (const field of ['createdDate', 'updatedDate']) { if (typeof data[field] !== 'string' || !DATE_RE.test(data[field])) { errors.push(`${field} must be YYYY-MM-DD`); } } if (!Array.isArray(data.repos)) { errors.push('repos must be an array'); return { valid: false, errors }; } const seen = new Set(); data.repos.forEach((r, i) => { const at = `repos[${i}]${r && typeof r === 'object' && r.path ? ` (${r.path})` : ''}`; if (!r || typeof r !== 'object' || Array.isArray(r)) { errors.push(`${at}: must be an object`); return; } if (typeof r.path !== 'string' || r.path.trim() === '') { errors.push(`${at}: path is required`); } else if (seen.has(r.path)) { errors.push(`${at}: duplicate path`); } else { seen.add(r.path); } if (!STATUSES.includes(r.status)) { errors.push(`${at}: status must be one of ${STATUSES.join(', ')}`); } }); return { valid: errors.length === 0, errors }; } // ── Persistence (thin IO shell) ──────────────────────────────────────────────── /** * Default on-disk location: next to `sessions/`, OUTSIDE the plugin dir, so the campaign * survives plugin uninstall/reinstall/upgrade. * @returns {string} */ export function defaultLedgerPath() { return join(homedir(), '.claude', 'config-audit', 'campaign-ledger.json'); } /** * Load + parse a ledger file. Returns `null` if the file does not exist (graceful first run); * other read/parse errors propagate so corruption is not silently swallowed. * @param {string} [path] * @returns {Promise} */ export async function loadLedger(path = defaultLedgerPath()) { let content; try { content = await readFile(path, 'utf8'); } catch (err) { if (err && err.code === 'ENOENT') return null; throw err; } return JSON.parse(content); } /** * Persist a ledger as human-readable JSON, creating parent directories as needed. * @param {string} path * @param {object} ledger * @returns {Promise<{path: string}>} */ export async function saveLedger(path = defaultLedgerPath(), ledger) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(ledger, null, 2)}\n`, 'utf8'); return { path }; }