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:
parent
cefa751990
commit
872b8ac281
2 changed files with 126 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue