From 872b8ac2819f3e95498480283a5f78ca93fefbd2 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 23 Jun 2026 17:10:03 +0200 Subject: [PATCH] feat(manifest): ownership split for machine-wide token roll-up (v5.9 B2b-1) [skip-docs] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scanners/manifest.mjs | 50 +++++++++++++++++++++ tests/scanners/manifest.test.mjs | 77 +++++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/scanners/manifest.mjs b/scanners/manifest.mjs index da9c32b..6bafe2f 100644 --- a/scanners/manifest.mjs +++ b/scanners/manifest.mjs @@ -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 diff --git a/tests/scanners/manifest.test.mjs b/tests/scanners/manifest.test.mjs index 519f075..d2ca618 100644 --- a/tests/scanners/manifest.test.mjs +++ b/tests/scanners/manifest.test.mjs @@ -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;