feat(manifest): ownership split for machine-wide token roll-up (v5.9 B2b-1) [skip-docs]

Pure classifier splitManifestByOwnership(sources) → {shared, delta}, the
FS-free core that B2b's live cross-repo sweep will feed into the campaign
ledger's setSharedGlobal/setRepoTokens (B2a).

classifyOwnership maps each source string to its layer:
  shared : user | managed | plugin:* | ~/.claude.json:projects (global MCP)
  delta  : project | local | .mcp.json | @import | unrecognized

Anything not positively global falls to delta, so a source is never silently
folded into the once-counted shared layer (a wrong fold HIDES machine-wide
cost; a wrong delta is at worst visibly attributed to a repo). Both layers
carry the canonical summarizeByLoadPattern shape so the ledger setters consume
them verbatim.

TDD: 6 unit tests first (RED → GREEN). buildManifest/CLI output unchanged →
manifest snapshot byte-identical. Suite 1195 green (1189 + 6).

[skip-docs]: internal pure export only, no user-facing surface yet. User-facing
docs (commands/campaign.md + CLAUDE.md manifest/campaign rows) land in B2b-3
when refresh-tokens + the rendered token-bill ship.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 17:10:03 +02:00
commit 872b8ac281
2 changed files with 126 additions and 1 deletions

View file

@ -168,6 +168,56 @@ export function summarizeByLoadPattern(sources) {
return summary;
}
/**
* Source strings (the `source` field buildManifest stamps) that belong to the
* SHARED GLOBAL layer config paid once per machine and identical in every
* repo: the global ~/.claude CLAUDE.md, managed enterprise policy, and the
* global MCP slice of ~/.claude.json. Installed plugins are also shared but are
* matched by the `plugin:` prefix below, not by this set.
*/
const SHARED_GLOBAL_SOURCES = Object.freeze(new Set(['user', 'managed', '~/.claude.json:projects']));
/**
* Classify one manifest source as part of the once-counted shared global layer
* or a per-repo delta (v5.9 B2b). Anything not positively identified as global
* (project / local / .mcp.json / @import / unrecognized) falls to `delta`, so a
* source is never silently folded into the shared layer a wrong fold would
* HIDE machine-wide cost, whereas a wrong delta is at worst attributed visibly
* to a repo.
* @param {string} source
* @returns {'shared'|'delta'}
*/
export function classifyOwnership(source) {
if (typeof source === 'string') {
if (source.startsWith('plugin:')) return 'shared'; // installed plugins are machine-global
if (SHARED_GLOBAL_SOURCES.has(source)) return 'shared';
}
return 'delta';
}
/**
* Partition manifest sources by ownership for the machine-wide token roll-up,
* returning two load-pattern summaries in the exact shape `summarizeByLoadPattern`
* emits ({always,onDemand,external,unknown:{tokens,count}}), so the campaign
* ledger setters (`setSharedGlobal` / `setRepoTokens`) consume them verbatim.
*
* - `shared`: the global layer, identical across repos set ONCE on the ledger
* root so the roll-up counts it exactly once (the structural double-count guard).
* - `delta`: this repo's own project/local contribution beyond the shared layer.
*
* The split is total: every source lands in exactly one layer.
* @param {Array<{source:string, loadPattern:string, estimated_tokens:number}>} sources
* @returns {{shared:object, delta:object}}
*/
export function splitManifestByOwnership(sources) {
const shared = [];
const delta = [];
for (const s of sources || []) {
(classifyOwnership(s.source) === 'shared' ? shared : delta).push(s);
}
return { shared: summarizeByLoadPattern(shared), delta: summarizeByLoadPattern(delta) };
}
/**
* Distribute the cascade-level estimated tokens across the individual files
* proportional to their byte size. claudeMd.estimatedTokens is computed for

View file

@ -5,7 +5,7 @@ import { resolve, join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { buildManifest } from '../../scanners/manifest.mjs';
import { buildManifest, splitManifestByOwnership, classifyOwnership } from '../../scanners/manifest.mjs';
import { deriveLoadPattern } from '../../scanners/lib/active-config-reader.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
@ -193,6 +193,81 @@ describe('buildManifest — load-pattern accounting (unit)', () => {
});
});
describe('splitManifestByOwnership — shared-global vs per-repo-delta (unit, v5.9 B2b)', () => {
// Minimal source records — only the fields the splitter reads (source, loadPattern,
// estimated_tokens), matching the shape buildManifest stamps onto every source.
const src = (source, loadPattern, tokens) => ({ source, loadPattern, estimated_tokens: tokens });
it('classifyOwnership routes each source string to the right layer', () => {
// Shared: paid once per machine, identical in every repo.
assert.equal(classifyOwnership('user'), 'shared');
assert.equal(classifyOwnership('managed'), 'shared');
assert.equal(classifyOwnership('plugin:foo'), 'shared');
assert.equal(classifyOwnership('~/.claude.json:projects'), 'shared');
// Per-repo delta: the repo's own project/local contribution.
assert.equal(classifyOwnership('project'), 'delta');
assert.equal(classifyOwnership('local'), 'delta');
assert.equal(classifyOwnership('.mcp.json'), 'delta');
// import + anything unrecognized → delta (never silently folded into the shared layer).
assert.equal(classifyOwnership('import'), 'delta');
assert.equal(classifyOwnership('mystery'), 'delta');
assert.equal(classifyOwnership(undefined), 'delta');
});
const sources = [
src('user', 'always', 100), // global CLAUDE.md → shared
src('managed', 'always', 50), // managed policy → shared
src('plugin:foo', 'always', 30), // plugin agent/rule → shared
src('~/.claude.json:projects', 'always', 20), // global MCP → shared
src('project', 'always', 10), // project CLAUDE.md/rule → delta
src('local', 'always', 5), // local → delta
src('.mcp.json', 'always', 7), // project MCP → delta
src('import', 'always', 3), // conservative → delta
];
const { shared, delta } = splitManifestByOwnership(sources);
it('folds global/user/managed/plugin/global-MCP into the shared layer', () => {
assert.equal(shared.always.tokens, 100 + 50 + 30 + 20); // 200
assert.equal(shared.always.count, 4);
});
it('attributes project/local/.mcp.json/import to the per-repo delta', () => {
assert.equal(delta.always.tokens, 10 + 5 + 7 + 3); // 25
assert.equal(delta.always.count, 4);
});
it('preserves load-pattern buckets within each layer', () => {
const mixed = [
src('user', 'always', 100),
src('plugin:foo', 'on-demand', 40), // plugin skill body → shared, onDemand
src('plugin:foo', 'external', 0), // plugin hook → shared, external
src('project', 'on-demand', 8), // delta, onDemand
];
const r = splitManifestByOwnership(mixed);
assert.equal(r.shared.always.tokens, 100);
assert.equal(r.shared.onDemand.tokens, 40);
assert.equal(r.shared.external.count, 1);
assert.equal(r.delta.onDemand.tokens, 8);
});
it('is a total partition — every source lands in exactly one layer (no loss, no dup)', () => {
const wholeTokens = sources.reduce((a, s) => a + s.estimated_tokens, 0);
const buckets = (sum) => sum.always.tokens + sum.onDemand.tokens + sum.external.tokens + sum.unknown.tokens;
const counts = (sum) => sum.always.count + sum.onDemand.count + sum.external.count + sum.unknown.count;
assert.equal(buckets(shared) + buckets(delta), wholeTokens);
assert.equal(counts(shared) + counts(delta), sources.length);
});
it('emits the canonical {always,onDemand,external,unknown:{tokens,count}} shape for both layers', () => {
for (const summary of [shared, delta]) {
for (const bucket of ['always', 'onDemand', 'external', 'unknown']) {
assert.equal(typeof summary[bucket].tokens, 'number', `${bucket}.tokens`);
assert.equal(typeof summary[bucket].count, 'number', `${bucket}.count`);
}
}
});
});
describe('manifest CLI — fixture path (rich-repo with patched HOME)', () => {
let fixture;