From cefa751990aa6736727ac99fb9f9c5de71b56810 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 23 Jun 2026 16:57:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(campaign):=20machine-wide=20always-loaded?= =?UTF-8?q?=20token=20roll-up=20=E2=80=94=20pure=20layer=20(v5.9=20B2a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scanners/lib/campaign-ledger.mjs | 88 ++++++++++++++++++++++++-- tests/lib/campaign-ledger.test.mjs | 94 ++++++++++++++++++++++++++++ tests/scanners/campaign-cli.test.mjs | 7 +++ 3 files changed, 185 insertions(+), 4 deletions(-) diff --git a/scanners/lib/campaign-ledger.mjs b/scanners/lib/campaign-ledger.mjs index 12a2dba..2104456 100644 --- a/scanners/lib/campaign-ledger.mjs +++ b/scanners/lib/campaign-ledger.mjs @@ -88,6 +88,7 @@ export function addRepo(ledger, { path, name } = {}, { now } = {}) { status: 'pending', sessionId: null, findingsBySeverity: null, + tokens: null, updatedDate: now, }; return { ...ledger, updatedDate: now, repos: [...ledger.repos, repo] }; @@ -119,17 +120,76 @@ export function setRepoStatus(ledger, path, status, { now, findingsBySeverity, s 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']); + /** - * Machine-wide roll-up: repo counts by status, plus a severity total aggregated across every - * repo that carries `findingsBySeverity`. Pure derivation — never mutates. + * 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 - * @returns {{totalRepos:number, byStatus:object, bySeverity:object, reposWithFindings:number}} + * @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; @@ -139,8 +199,28 @@ export function rollUp(ledger) { 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 }); + } } - return { totalRepos: ledger.repos.length, byStatus, bySeverity, reposWithFindings }; + + // 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 }, + }; } /** diff --git a/tests/lib/campaign-ledger.test.mjs b/tests/lib/campaign-ledger.test.mjs index 8bc8119..6d50355 100644 --- a/tests/lib/campaign-ledger.test.mjs +++ b/tests/lib/campaign-ledger.test.mjs @@ -10,6 +10,8 @@ import { createLedger, addRepo, setRepoStatus, + setSharedGlobal, + setRepoTokens, rollUp, buildBacklog, validateLedger, @@ -21,6 +23,17 @@ import { const NOW = '2026-06-22'; const LATER = '2026-06-23'; +/** + * Build a load-pattern summary in the exact shape manifest's `summarizeByLoadPattern` + * emits (and the shape B2b will store): one {tokens,count} bucket per load pattern. + */ +const tokSummary = (always, onDemand = 0, external = 0, unknown = 0) => ({ + always: { tokens: always, count: 1 }, + onDemand: { tokens: onDemand, count: 1 }, + external: { tokens: external, count: 1 }, + unknown: { tokens: unknown, count: 1 }, +}); + describe('constants', () => { it('schema version is 1', () => { assert.equal(CAMPAIGN_SCHEMA_VERSION, 1); @@ -57,6 +70,7 @@ describe('addRepo', () => { status: 'pending', sessionId: null, findingsBySeverity: null, + tokens: null, updatedDate: NOW, }); }); @@ -155,6 +169,86 @@ describe('rollUp', () => { }); }); +describe('machine-wide token roll-up (B2a)', () => { + it('addRepo seeds a null tokens field (mirrors findingsBySeverity)', () => { + const l = addRepo(createLedger({ now: NOW }), { path: '/r/a', name: 'a' }, { now: NOW }); + assert.equal(l.repos[0].tokens, null); + }); + + it('setSharedGlobal stores the shared layer at the ledger root, immutably + stamps', () => { + const l0 = createLedger({ now: NOW }); + const l1 = setSharedGlobal(l0, tokSummary(1000, 50, 7), { now: LATER }); + assert.deepEqual(l1.sharedGlobal, tokSummary(1000, 50, 7)); + assert.equal(l1.updatedDate, LATER); + // immutable: original untouched, no sharedGlobal leaked onto it + assert.equal(l0.sharedGlobal, undefined); + assert.equal(l0.updatedDate, NOW); + }); + + it('setRepoTokens attaches per-repo tokens, immutably + stamps', () => { + let l = addRepo(createLedger({ now: NOW }), { path: '/r/a', name: 'a' }, { now: NOW }); + const before = l; + l = setRepoTokens(l, '/r/a', tokSummary(120), { now: LATER }); + assert.deepEqual(l.repos[0].tokens, tokSummary(120)); + assert.equal(l.repos[0].updatedDate, LATER); + assert.equal(l.updatedDate, LATER); + assert.equal(before.repos[0].tokens, null); // input ledger not mutated + }); + + it('setRepoTokens throws when the repo is not tracked', () => { + const l = createLedger({ now: NOW }); + assert.throws(() => setRepoTokens(l, '/r/missing', tokSummary(1), { now: NOW }), /not in the ledger/); + }); + + it('rollUp returns an all-zero token aggregate for a fresh ledger', () => { + const r = rollUp(createLedger({ now: NOW })); + assert.deepEqual(r.tokens, { + sharedGlobal: { always: 0, onDemand: 0, external: 0, unknown: 0 }, + perRepoDelta: { always: 0, onDemand: 0, external: 0, unknown: 0 }, + machineWide: { always: 0, onDemand: 0, external: 0, unknown: 0 }, + reposWithTokens: 0, + byRepo: [], + }); + }); + + it('counts the shared-global layer ONCE, not per repo (double-count regression guard)', () => { + let l = createLedger({ now: NOW }); + l = setSharedGlobal(l, tokSummary(1000), { now: NOW }); // shared always-loaded = 1000 + for (const name of ['a', 'b', 'c']) { + l = addRepo(l, { path: `/r/${name}`, name }, { now: NOW }); + l = setRepoTokens(l, `/r/${name}`, tokSummary(100), { now: NOW }); // each per-repo delta = 100 + } + const t = rollUp(l).tokens; + assert.equal(t.sharedGlobal.always, 1000, 'shared layer stored once'); + assert.equal(t.perRepoDelta.always, 300, 'sum of three per-repo deltas'); + // THE invariant: shared layer counted ONCE (1000 + 300), NOT once per repo (3*1000 + 300) + assert.equal(t.machineWide.always, 1300); + assert.equal(t.reposWithTokens, 3); + }); + + it('ranks byRepo DESC by always-tokens with a deterministic name tie-break', () => { + let l = createLedger({ now: NOW }); + // deltas out of order + a tie at 100 to exercise the name tie-break + const spec = [['a', 100], ['z', 300], ['m', 100], ['q', 50]]; + for (const [name, always] of spec) { + l = addRepo(l, { path: `/r/${name}`, name }, { now: NOW }); + l = setRepoTokens(l, `/r/${name}`, tokSummary(always), { now: NOW }); + } + const byRepo = rollUp(l).tokens.byRepo; + assert.deepEqual(byRepo.map((r) => r.name), ['z', 'a', 'm', 'q']); + assert.deepEqual(byRepo[0], { name: 'z', path: '/r/z', always: 300, onDemand: 0, external: 0 }); + }); + + it('tolerates old ledgers: no sharedGlobal + repos without tokens → all zero', () => { + let l = createLedger({ now: NOW }); + l = addRepo(l, { path: '/r/a', name: 'a' }, { now: NOW }); // no tokens set + const t = rollUp(l).tokens; + assert.equal(t.machineWide.always, 0); + assert.equal(t.reposWithTokens, 0); + assert.deepEqual(t.byRepo, []); + }); +}); + describe('buildBacklog', () => { // Seed a ledger from a compact spec. A repo with `status` (and optional `findings`) is // transitioned; a bare repo stays `pending` with null findings. diff --git a/tests/scanners/campaign-cli.test.mjs b/tests/scanners/campaign-cli.test.mjs index 6d784db..457c558 100644 --- a/tests/scanners/campaign-cli.test.mjs +++ b/tests/scanners/campaign-cli.test.mjs @@ -57,6 +57,13 @@ const EMPTY_ROLLUP = { byStatus: { pending: 0, audited: 0, planned: 0, implemented: 0 }, bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, reposWithFindings: 0, + tokens: { + sharedGlobal: { always: 0, onDemand: 0, external: 0, unknown: 0 }, + perRepoDelta: { always: 0, onDemand: 0, external: 0, unknown: 0 }, + machineWide: { always: 0, onDemand: 0, external: 0, unknown: 0 }, + reposWithTokens: 0, + byRepo: [], + }, }; describe('campaign-cli — exit codes', () => {