feat(campaign): machine-wide always-loaded token roll-up — pure layer (v5.9 B2a)
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>
This commit is contained in:
parent
2a3cb537f9
commit
cefa751990
3 changed files with 185 additions and 4 deletions
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue