feat(mft): v5.6 B1 — load-pattern accounting in manifest

Consume the v5.6 Foundation enumeration in buildManifest:

- Component-level sources: drop the coarse `kind:'plugin'` roll-up (it
  double-counted skills/rules/agents already enumerated once). Kinds are now
  claude-md/skill/rule/agent/output-style/mcp-server/hook.
- Every source carries loadPattern/survivesCompaction/derivationConfidence.
  Rules/agents/output-styles propagate the foundation-derived values; CLAUDE.md
  maps scope→kind (all cascade files always-loaded); skills are tagged on-demand
  (skill-body) so the body cost does not inflate the always-loaded subtotal.
- New `summary` (always/onDemand/external/unknown {tokens,count}); the
  always-loaded subtotal — "tokens that enter context every turn" — is the headline.

manifest is an environment-aware CLI → SC-6/SC-7 verify it by mode-equivalence,
not byte-equal, and it is not in SC-5. Adding fields in place keeps all snapshots
green with no regen (verified). `total` changes (de-duped) — intended correctness fix.
TOK's load-pattern column (byte-equal SC-6) is deferred to the next chunk (B2).

Tests 996→1008 (deterministic buildManifest unit test + CLI presence checks).
Self-audit A/A, scanner count unchanged at 13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-20 17:48:55 +02:00
commit bb647ce35f
7 changed files with 324 additions and 66 deletions

View file

@ -5,6 +5,8 @@ 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 { deriveLoadPattern } from '../../scanners/lib/active-config-reader.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const PLUGIN_ROOT = resolve(__dirname, '../..');
@ -64,6 +66,131 @@ describe('manifest CLI — real-config path (plugin root)', () => {
it('meta.repoPath matches the requested path', () => {
assert.equal(output.meta.repoPath, PLUGIN_ROOT);
});
it('every source carries a load-pattern triple', () => {
const LOAD_PATTERNS = new Set(['always', 'on-demand', 'external', 'unknown']);
const SURVIVES = new Set(['yes', 'no', 'n/a']);
for (const s of output.sources) {
assert.ok(LOAD_PATTERNS.has(s.loadPattern),
`${s.kind}:${s.name} has invalid loadPattern: ${s.loadPattern}`);
assert.ok(SURVIVES.has(s.survivesCompaction),
`${s.kind}:${s.name} has invalid survivesCompaction: ${s.survivesCompaction}`);
assert.ok(typeof s.derivationConfidence === 'string' && s.derivationConfidence.length > 0,
`${s.kind}:${s.name} missing derivationConfidence`);
}
});
it('emits a load-pattern summary with always/on-demand/external buckets', () => {
assert.ok(output.summary, 'expected output.summary');
for (const bucket of ['always', 'onDemand', 'external']) {
assert.ok(output.summary[bucket], `expected summary.${bucket}`);
assert.equal(typeof output.summary[bucket].tokens, 'number');
assert.equal(typeof output.summary[bucket].count, 'number');
}
// The bucket token sums must reconcile with the per-source loadPattern tags.
const alwaysSum = output.sources
.filter(s => s.loadPattern === 'always')
.reduce((a, s) => a + s.estimated_tokens, 0);
assert.equal(output.summary.always.tokens, alwaysSum,
'summary.always.tokens must equal sum of always-loaded sources');
});
});
describe('buildManifest — load-pattern accounting (unit)', () => {
// Hand-built activeConfig covering every source kind, with a disabled MCP
// server and a plugin roll-up entry that must both be excluded from the
// ranked sources + totals.
const activeConfig = {
claudeMd: {
files: [
{ path: '/root/CLAUDE.md', scope: 'project', bytes: 100 },
{ path: '/home/.claude/CLAUDE.md', scope: 'user', bytes: 100 },
],
totalBytes: 200,
estimatedTokens: 50, // → 25 tokens each
},
// Must be ignored entirely (roll-up double-counts components).
plugins: [{ name: 'plug', estimatedTokens: 9999 }],
skills: [
{ name: 'sk1', source: 'user', pluginName: null, estimatedTokens: 40 },
{ name: 'sk2', source: 'plugin', pluginName: 'plug', estimatedTokens: 30 },
],
rules: [
{ name: 'unscoped.md', source: 'project', pluginName: null, scoped: false,
estimatedTokens: 10, ...deriveLoadPattern('rule', { scoped: false }) },
{ name: 'scoped.md', source: 'project', pluginName: null, scoped: true,
estimatedTokens: 8, ...deriveLoadPattern('rule', { scoped: true }) },
],
agents: [
{ name: 'a1', source: 'project', pluginName: null, estimatedTokens: 5,
...deriveLoadPattern('agent') },
],
outputStyles: [
{ name: 's1', source: 'project', pluginName: null, estimatedTokens: 7,
...deriveLoadPattern('output-style') },
],
mcpServers: [
{ name: 'm1', source: 'project', estimatedTokens: 500, enabled: true },
{ name: 'm2', source: 'project', estimatedTokens: 999, enabled: false },
],
hooks: [
{ event: 'PreToolUse', matcher: 'Edit', source: 'project', estimatedTokens: 0 },
],
};
const built = buildManifest(activeConfig);
const byName = (kind, name) => built.sources.find(s => s.kind === kind && s.name === name);
it('emits no kind:plugin source (roll-up dropped, component-level only)', () => {
assert.ok(built.sources.every(s => s.kind !== 'plugin'));
});
it('excludes the plugin roll-up and disabled MCP from the total', () => {
// 25+25 (claude-md) + 40+30 (skills) + 10+8 (rules) + 5 (agent) + 7 (style) + 500 (mcp m1)
assert.equal(built.total, 650);
});
it('tags skills as on-demand (body paid on invoke, not every turn)', () => {
assert.equal(byName('skill', 'sk1').loadPattern, 'on-demand');
assert.equal(byName('skill', 'sk1').survivesCompaction, 'n/a');
});
it('propagates rule load-pattern (unscoped=always, scoped=on-demand)', () => {
assert.equal(byName('rule', 'unscoped.md').loadPattern, 'always');
assert.equal(byName('rule', 'scoped.md').loadPattern, 'on-demand');
});
it('tags agents and output styles as always-loaded', () => {
assert.equal(byName('agent', 'a1').loadPattern, 'always');
assert.equal(byName('output-style', 's1').loadPattern, 'always');
});
it('tags MCP servers always and hooks external', () => {
assert.equal(byName('mcp-server', 'm1').loadPattern, 'always');
assert.equal(byName('hook', 'PreToolUse:Edit').loadPattern, 'external');
});
it('excludes the disabled MCP server from sources', () => {
assert.equal(byName('mcp-server', 'm2'), undefined);
});
it('maps CLAUDE.md scope to the correct load pattern', () => {
assert.equal(byName('claude-md', '/root/CLAUDE.md').loadPattern, 'always');
assert.equal(byName('claude-md', '/root/CLAUDE.md').survivesCompaction, 'yes');
assert.equal(byName('claude-md', '/home/.claude/CLAUDE.md').loadPattern, 'always');
});
it('summary buckets reconcile with per-source tags', () => {
// always: 25+25+10+5+7+500 = 572 over 6 sources
assert.equal(built.summary.always.tokens, 572);
assert.equal(built.summary.always.count, 6);
// on-demand: 40+30+8 = 78 over 3 sources
assert.equal(built.summary.onDemand.tokens, 78);
assert.equal(built.summary.onDemand.count, 3);
// external: hook (0 tokens) over 1 source
assert.equal(built.summary.external.tokens, 0);
assert.equal(built.summary.external.count, 1);
});
});
describe('manifest CLI — fixture path (rich-repo with patched HOME)', () => {
@ -85,14 +212,20 @@ describe('manifest CLI — fixture path (rich-repo with patched HOME)', () => {
`expected sources.length >= 5, got ${out.sources.length}: ${out.sources.map(s => `${s.kind}:${s.name}`).join(', ')}`);
});
it('includes both plugins (manifest-plugin-a + manifest-plugin-b)', () => {
it('drops the coarse plugin roll-up (component-level only, no kind:plugin)', () => {
const proc = runCli([fixture.root, '--json'], { HOME: fixture.fakeHome });
const out = JSON.parse(proc.stdout);
const pluginNames = out.sources.filter(s => s.kind === 'plugin').map(s => s.name);
assert.ok(pluginNames.includes('manifest-plugin-a'),
`expected manifest-plugin-a in plugins; got: ${pluginNames.join(', ')}`);
assert.ok(pluginNames.includes('manifest-plugin-b'),
`expected manifest-plugin-b in plugins; got: ${pluginNames.join(', ')}`);
assert.ok(out.sources.every(s => s.kind !== 'plugin'),
`expected no kind:plugin source (roll-up dropped); got: ${out.sources.map(s => s.kind).join(', ')}`);
});
it('surfaces plugin-provided skills as component-level sources', () => {
const proc = runCli([fixture.root, '--json'], { HOME: fixture.fakeHome });
const out = JSON.parse(proc.stdout);
const fromA = out.sources.find(s => s.kind === 'skill' && s.name === 'alpha-skill');
assert.ok(fromA, 'expected alpha-skill as a component-level skill source');
assert.ok(/^plugin:/.test(fromA.source),
`expected plugin-provided skill source to be namespaced plugin:*; got: ${fromA.source}`);
});
it('includes 3 fixture skills (alpha-skill, beta-skill, gamma-skill)', () => {