Extend the campaign ledger with a machine-wide always-loaded token bill, the
pure data-model + aggregation half of B2. Deliberately does NOT do live I/O:
the cross-repo readActiveConfig sweep that populates real numbers is B2b.
Design — shared layer counted ONCE, structurally:
- Ledger root gets an optional `sharedGlobal` summary (the always-loaded layer
paid in every repo: global CLAUDE.md + agent listing + global MCP + unscoped
global rules + active plugins' always components), stored ONCE via the new
setSharedGlobal().
- Each repo entry gets an optional `tokens` summary (its PER-REPO delta only),
set via the new setRepoTokens(); addRepo now seeds `tokens: null` (mirrors
findingsBySeverity).
- rollUp() stays PURE and gains a `tokens` aggregate: machineWide = sharedGlobal
+ Σ(per-repo deltas), so the shared layer is counted exactly once by
construction — the structural guard against the historic double-count bug.
Plus a `byRepo` table ranked DESC by always-loaded cost ("most expensive
repos") with a deterministic name tie-break.
Summary shape mirrors manifest's summarizeByLoadPattern exactly
({always|onDemand|external|unknown: {tokens,count}}) so B2b wires in trivially.
Keeps rollUp pure (no filesystem I/O) → byte-stable, respects the THIN campaign
motor invariant. Chosen over the plan's literal "run readActiveConfig inside
rollUp" which would break both (operator-approved deviation).
- 8 new ledger tests incl. the counted-once regression guard + byRepo ranking.
- Updated addRepo shape test (+tokens:null) and campaign-cli EMPTY_ROLLUP.
- No frozen snapshot affected (campaign is v5.7, absent from v5.0.0 baselines).
Suite green: 1189 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
369 lines
15 KiB
JavaScript
369 lines
15 KiB
JavaScript
/**
|
|
* 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<object|null>}
|
|
*/
|
|
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 };
|
|
}
|