feat(scanners): model/effort routing becomes a lever, not a 25th dimension (C4)
New GAP finding CA-GAP-028: authored subagents exist and not one of them names `model:` or `effort:`, so every delegated task runs on the main conversation's model (`model` defaults to `inherit`). Cites BP-MODEL-001/002, landed in C1. `whats-active` and `manifest` now carry `model`/`effort` per agent. Shipped as a conditional LEVER rather than a 25th dimension, and the choice was made by measurement: as a t3 dimension the agent-less marketplace-medium fixture would count it vacuously-present, moving the denominators 41->42 and utilization 44->45 — which flips `segment` "Developing"->"Competent" in the frozen v5.0.0 posture baseline, a field strip-retired-gap.mjs does not mask. A lever never enters those denominators. The general rule is now an invariant in CLAUDE.md. One check across both axes, not one per axis: it fires only when neither is used anywhere, so a deliberate everything-on-one-model policy stays silent. Cost is recall, chosen for precision. Found by dogfooding, fixed red-first: `model: inherit` is the documented default spelled out, so it must not count as routing — otherwise a config opts out of the opportunity without changing anything real. Two pre-existing defects surfaced and closed on the way: - The humanizer guard asserted TRANSLATIONS.GAP.static EQUALS the dimension titles, which forbade humanizing any lever — all three existing levers fell through to the generic "feature opportunity" default, wrong for a budget lever. Guard now requires coverage of every emittable title, seen red against those three before the entries were written. - Two hand-written copies of the lever list (finding-codes guard, humanizer guard) merged into one exported LEVERS registry carrying code AND title. - suppression-validation pinned CA-GAP-028 as an unoccupied number; C4 claimed it. Fixed structurally with a derived first-free id, not by picking a new literal — same class as #60's "bump this again". Suite 1596/0. Frozen v5.0.0 snapshots untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq3nye21RVYk4pZLeT8pGz
This commit is contained in:
parent
e861e63a7b
commit
9ae4be26d2
16 changed files with 512 additions and 31 deletions
|
|
@ -1075,6 +1075,32 @@ describe('enumerateAgents (v5.6)', () => {
|
|||
assert.deepEqual(agents.map(a => a.name).sort(), ['reviewer']);
|
||||
});
|
||||
|
||||
// C4: the two routing axes are part of the inventory, so `whats-active` and
|
||||
// `manifest` can answer "what does each agent actually run on?" without the
|
||||
// reader having to open the files again.
|
||||
it('C4: surfaces model and effort per agent', async () => {
|
||||
const dir = join(root, '.claude', 'agents');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, 'cheap.md'), '---\nname: cheap\ndescription: mechanical work\nmodel: haiku\neffort: low\n---\nbody\n');
|
||||
const agents = await enumerateAgents(root, []);
|
||||
const a = agents.find(x => x.name === 'cheap');
|
||||
assert.equal(a.model, 'haiku');
|
||||
assert.equal(a.effort, 'low');
|
||||
});
|
||||
|
||||
// Explicit null, not an absent key: `inherit` is the documented default, so a
|
||||
// consumer must be able to tell "not pinned" apart from "field unknown".
|
||||
it('C4: reports null for an agent that pins neither axis', async () => {
|
||||
const dir = join(root, '.claude', 'agents');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, 'plain.md'), '---\nname: plain\ndescription: no pins\n---\nbody\n');
|
||||
const agents = await enumerateAgents(root, []);
|
||||
const a = agents.find(x => x.name === 'plain');
|
||||
assert.ok('model' in a && 'effort' in a, 'both keys must be present');
|
||||
assert.equal(a.model, null);
|
||||
assert.equal(a.effort, null);
|
||||
});
|
||||
|
||||
// M-BUG-3: CC scans agents dirs recursively, so a valid agent in a subfolder
|
||||
// (e.g. agents/review/security.md) is registered and must be counted.
|
||||
it('M-BUG-3: recurses into agent subdirectories', async () => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FINDING_CODES, RETIRED_CODES, codeNumber, findingId, allFindingIds } from '../../scanners/lib/finding-codes.mjs';
|
||||
import { GAP_CHECKS } from '../../scanners/feature-gap-scanner.mjs';
|
||||
import { GAP_CHECKS, LEVERS } from '../../scanners/feature-gap-scanner.mjs';
|
||||
|
||||
describe('finding-code registry', () => {
|
||||
it('gives every check a distinct number within its scanner', () => {
|
||||
|
|
@ -59,7 +59,9 @@ describe('finding-code registry', () => {
|
|||
const missing = shipped.filter((id) => !declared.has(id));
|
||||
assert.deepEqual(missing, [], 'a GAP dimension has no declared code');
|
||||
|
||||
const levers = ['bundled-skills-lever', 'cli-over-mcp-lever', 'filter-hook-output-lever'];
|
||||
// Derived from the scanner, not listed again here: a hand-written copy of
|
||||
// this list is the drift class the registry exists to prevent.
|
||||
const levers = Object.values(LEVERS).map((l) => l.code);
|
||||
const orphans = [...declared].filter((k) => !shipped.includes(k) && !levers.includes(k));
|
||||
assert.deepEqual(orphans, [], 'a declared GAP code matches no shipped dimension');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,19 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseIgnoreFile, unknownSuppressions } from '../../scanners/lib/suppression.mjs';
|
||||
import { FINDING_CODES } from '../../scanners/lib/finding-codes.mjs';
|
||||
|
||||
/**
|
||||
* The next number no check in `scanner` occupies, DERIVED rather than written
|
||||
* down. An unoccupied number is a moving target — every added check claims one —
|
||||
* so a literal here expires the moment the registry grows, which is what C4
|
||||
* (claiming CA-GAP-028) demonstrated. Derived, the input is unoccupied by
|
||||
* construction; the assertion it feeds is unchanged.
|
||||
*/
|
||||
function firstFreeId(scanner) {
|
||||
const n = Math.max(...Object.values(FINDING_CODES[scanner])) + 1;
|
||||
return `CA-${scanner}-${String(n).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
describe('unknownSuppressions', () => {
|
||||
it('accepts an exact ID that names a declared check', () => {
|
||||
|
|
@ -19,16 +32,18 @@ describe('unknownSuppressions', () => {
|
|||
});
|
||||
|
||||
it('reports an exact ID that names no declared check', () => {
|
||||
// CA-GAP-099 has never existed; CA-PLH-021 is past the end of PLH's range.
|
||||
const s = parseIgnoreFile('CA-GAP-099\nCA-PLH-021\n');
|
||||
assert.deepEqual(unknownSuppressions(s), ['CA-GAP-099', 'CA-PLH-021']);
|
||||
// CA-GAP-099 has never existed; the PLH one is past the end of PLH's range.
|
||||
const pastEnd = firstFreeId('PLH');
|
||||
const s = parseIgnoreFile(`CA-GAP-099\n${pastEnd}\n`);
|
||||
assert.deepEqual(unknownSuppressions(s), ['CA-GAP-099', pastEnd]);
|
||||
});
|
||||
|
||||
it('reports an ID whose number was retired rather than pretending it matches', () => {
|
||||
// GAP's retired autoMode dimension sat at 25 under the registry's numbering
|
||||
// had it survived; nothing occupies it now.
|
||||
const s = parseIgnoreFile('CA-GAP-028\n');
|
||||
assert.deepEqual(unknownSuppressions(s), ['CA-GAP-028']);
|
||||
it('reports an ID whose number no check occupies rather than pretending it matches', () => {
|
||||
// The registry never reissues a retired key's number, so an ID can name a
|
||||
// hole. Any unoccupied number exercises the same path.
|
||||
const free = firstFreeId('GAP');
|
||||
const s = parseIgnoreFile(`${free}\n`);
|
||||
assert.deepEqual(unknownSuppressions(s), [free]);
|
||||
});
|
||||
|
||||
it('accepts a scanner-wide glob for a real scanner', () => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { resolve, join, dirname } from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { scan, opportunitySummary, bundledSkillsLeverFinding, cliOverMcpLeverFinding, filterHookLeverFinding, GAP_CHECKS } from '../../scanners/feature-gap-scanner.mjs';
|
||||
import { scan, opportunitySummary, bundledSkillsLeverFinding, cliOverMcpLeverFinding, filterHookLeverFinding, GAP_CHECKS, LEVERS } from '../../scanners/feature-gap-scanner.mjs';
|
||||
import { TITLE_TO_ID as GAP_TITLE_TO_ID, TIER_COUNTS, TOTAL_DIMENSIONS } from '../../scanners/lib/scoring.mjs';
|
||||
import { TRANSLATIONS } from '../../scanners/lib/humanizer-data.mjs';
|
||||
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||
|
|
@ -535,12 +535,21 @@ describe('GAP scanner — retired dimensions (D1)', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('keeps the three title tables carrying exactly the same titles', () => {
|
||||
it('keeps the scoring title table carrying exactly the dimension titles', () => {
|
||||
const checks = GAP_CHECKS.map(g => g.title).sort();
|
||||
const scoring = Object.keys(GAP_TITLE_TO_ID).sort();
|
||||
const humanizer = Object.keys(TRANSLATIONS.GAP.static).sort();
|
||||
assert.deepEqual(scoring, checks, 'scoring TITLE_TO_ID drifted from GAP_CHECKS');
|
||||
assert.deepEqual(humanizer, checks, 'humanizer TRANSLATIONS.GAP drifted from GAP_CHECKS');
|
||||
});
|
||||
|
||||
// The humanizer's table is NOT the dimension table: levers are findings too,
|
||||
// and a title with no static entry falls through to the generic GAP _default
|
||||
// ("You have a feature opportunity worth a look") — wrong for a budget lever.
|
||||
// The invariant is therefore coverage of EVERY title GAP can emit, asserted
|
||||
// blanket rather than as a relation between the two dimension tables.
|
||||
it('humanizes every title the scanner can emit — dimensions AND levers', () => {
|
||||
const emittable = [...GAP_CHECKS.map(g => g.title), ...Object.values(LEVERS).map(l => l.title)].sort();
|
||||
const humanizer = Object.keys(TRANSLATIONS.GAP.static).sort();
|
||||
assert.deepEqual(humanizer, emittable, 'humanizer TRANSLATIONS.GAP drifted from the emittable titles');
|
||||
});
|
||||
|
||||
// The scoring denominators are a FOURTH copy of the dimension inventory, and
|
||||
|
|
|
|||
187
tests/scanners/gap-agent-model-routing.test.mjs
Normal file
187
tests/scanners/gap-agent-model-routing.test.mjs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* C4 — agent model/effort routing lever (CA-GAP-028).
|
||||
*
|
||||
* A LEVER, not a dimension: it fires only when authored agents exist, so it has
|
||||
* no meaningful "present/absent" reading on a config with no agents at all —
|
||||
* exactly the shape the three existing levers already have. The design was
|
||||
* decided by measurement, not taste: as a GAP_CHECKS dimension it would have
|
||||
* counted as vacuously-present on the agent-less marketplace-medium fixture,
|
||||
* moving MAX_WEIGHTED 41→42 and utilization 44→45, which flips `segment`
|
||||
* "Developing"→"Competent" in the frozen v5.0.0 posture baseline (segment is
|
||||
* NOT among the fields strip-retired-gap.mjs drops from comparison).
|
||||
*
|
||||
* The silence has two independent causes and they must not be conflated:
|
||||
* - no authored agents at all (t2_6 "No custom subagents" owns that case), and
|
||||
* - the only agents on disk being plugin-bundled, which isAuthoredConfig
|
||||
* excludes.
|
||||
* P5 below pins the second one specifically, so that arm cannot pass for the
|
||||
* first one's reason.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { scan, agentModelRoutingLeverFinding } from '../../scanners/feature-gap-scanner.mjs';
|
||||
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||
import { withHermeticHome } from '../helpers/hermetic-home.mjs';
|
||||
|
||||
const LEVER_TITLE = 'Subagents pin neither model nor effort';
|
||||
|
||||
/** Build a throwaway project; `agents` maps a relative dir to agent frontmatter lines. */
|
||||
async function makeProject(agents) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'config-audit-c4-'));
|
||||
// A CLAUDE.md keeps the fixture from being a bare directory; irrelevant to the lever.
|
||||
await writeFile(join(root, 'CLAUDE.md'), '# Project\n');
|
||||
for (const [relDir, files] of Object.entries(agents)) {
|
||||
const dir = join(root, relDir);
|
||||
await mkdir(dir, { recursive: true });
|
||||
for (const [name, frontmatter] of Object.entries(files)) {
|
||||
await writeFile(
|
||||
join(dir, name),
|
||||
`---\nname: ${name.replace(/\.md$/, '')}\ndescription: does a thing\n${frontmatter}---\nBody.\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Scan a project with hermetic HOME and no global discovery. */
|
||||
async function scanProject(root) {
|
||||
const discovery = await discoverConfigFiles(resolve(root));
|
||||
const result = await withHermeticHome(() => scan(resolve(root), discovery));
|
||||
return { result, discovery };
|
||||
}
|
||||
|
||||
const leverFindings = (result) => result.findings.filter(f => f.title === LEVER_TITLE);
|
||||
const hasNoSubagentsGap = (result) => result.findings.some(f => f.title === 'No custom subagents');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit — the pure lever function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('agentModelRoutingLeverFinding (pure)', () => {
|
||||
it('stays silent when no authored agents exist', () => {
|
||||
assert.equal(
|
||||
agentModelRoutingLeverFinding({ agentCount: 0, modelPinned: 0, effortPinned: 0 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('stays silent when at least one agent pins model', () => {
|
||||
assert.equal(
|
||||
agentModelRoutingLeverFinding({ agentCount: 3, modelPinned: 1, effortPinned: 0 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('stays silent when at least one agent pins effort (the separate axis)', () => {
|
||||
assert.equal(
|
||||
agentModelRoutingLeverFinding({ agentCount: 3, modelPinned: 0, effortPinned: 1 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('fires when agents exist and neither axis is used anywhere', () => {
|
||||
const f = agentModelRoutingLeverFinding({ agentCount: 3, modelPinned: 0, effortPinned: 0 });
|
||||
assert.ok(f, 'expected a finding');
|
||||
assert.equal(f.id, 'CA-GAP-028');
|
||||
assert.equal(f.scanner, 'GAP');
|
||||
assert.equal(f.severity, 'info');
|
||||
assert.equal(f.category, 'model-fit');
|
||||
assert.equal(f.title, LEVER_TITLE);
|
||||
});
|
||||
|
||||
it('cites both register entries and their primary sources', () => {
|
||||
const f = agentModelRoutingLeverFinding({ agentCount: 2, modelPinned: 0, effortPinned: 0 });
|
||||
const text = `${f.description} ${f.recommendation}`;
|
||||
assert.match(text, /BP-MODEL-001/);
|
||||
assert.match(text, /BP-MODEL-002/);
|
||||
assert.match(text, /code\.claude\.com\/docs\/en\/sub-agents/);
|
||||
assert.match(text, /code\.claude\.com\/docs\/en\/model-config/);
|
||||
});
|
||||
|
||||
it('carries the measured counts as evidence', () => {
|
||||
const f = agentModelRoutingLeverFinding({ agentCount: 4, modelPinned: 0, effortPinned: 0 });
|
||||
assert.match(f.evidence, /authored_agents=4/);
|
||||
assert.match(f.evidence, /model_pinned=0/);
|
||||
assert.match(f.evidence, /effort_pinned=0/);
|
||||
});
|
||||
|
||||
it('frames the opportunity without asserting the config is wrong', () => {
|
||||
const f = agentModelRoutingLeverFinding({ agentCount: 2, modelPinned: 0, effortPinned: 0 });
|
||||
// `inherit` is the documented default and the reason the opportunity exists.
|
||||
assert.match(f.description, /inherit/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration — fire/silent matrix through scan()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GAP scanner — C4 fire/silent matrix', () => {
|
||||
it('P1: fires once when authored agents pin neither axis', async () => {
|
||||
const root = await makeProject({
|
||||
'.claude/agents': { 'alpha.md': '', 'beta.md': '' },
|
||||
});
|
||||
const { result } = await scanProject(root);
|
||||
const hits = leverFindings(result);
|
||||
assert.equal(hits.length, 1, 'expected exactly one lever finding');
|
||||
assert.equal(hits[0].id, 'CA-GAP-028');
|
||||
});
|
||||
|
||||
// Found by dogfooding this repo's own machine: several installed agents write
|
||||
// `model: inherit` explicitly. `inherit` IS the documented default
|
||||
// (BP-MODEL-001), so naming it routes nothing and must not buy silence — the
|
||||
// opportunity is exactly as open as with the field absent.
|
||||
it('P2b: fires when the only "pin" is the default value spelled out', async () => {
|
||||
const root = await makeProject({
|
||||
'.claude/agents': { 'alpha.md': 'model: inherit\n', 'beta.md': '' },
|
||||
});
|
||||
const { result } = await scanProject(root);
|
||||
const hits = leverFindings(result);
|
||||
assert.equal(hits.length, 1, 'model: inherit must not count as routing');
|
||||
assert.match(hits[0].evidence, /model_pinned=0/);
|
||||
});
|
||||
|
||||
it('P2: silent when one agent pins model', async () => {
|
||||
const root = await makeProject({
|
||||
'.claude/agents': { 'alpha.md': 'model: haiku\n', 'beta.md': '' },
|
||||
});
|
||||
const { result } = await scanProject(root);
|
||||
assert.equal(leverFindings(result).length, 0);
|
||||
});
|
||||
|
||||
it('P3: silent when one agent pins effort and none pins model', async () => {
|
||||
const root = await makeProject({
|
||||
'.claude/agents': { 'alpha.md': 'effort: low\n', 'beta.md': '' },
|
||||
});
|
||||
const { result } = await scanProject(root);
|
||||
assert.equal(leverFindings(result).length, 0);
|
||||
});
|
||||
|
||||
it('P4: silent when there are no agents at all (t2_6 owns that case)', async () => {
|
||||
const root = await makeProject({});
|
||||
const { result } = await scanProject(root);
|
||||
assert.equal(leverFindings(result).length, 0);
|
||||
assert.ok(hasNoSubagentsGap(result), 'expected the "No custom subagents" dimension instead');
|
||||
});
|
||||
|
||||
it('P5: silent for the EXCLUSION reason when the only agent is plugin-bundled', async () => {
|
||||
const root = await makeProject({
|
||||
'.claude/plugins/somePlugin/agents': { 'vendored.md': '' },
|
||||
});
|
||||
const { result, discovery } = await scanProject(root);
|
||||
// The discriminator: the file IS on disk and IS discovered as an agent —
|
||||
// so silence here cannot be the P4 "no agent files" reason.
|
||||
assert.ok(
|
||||
discovery.files.some(f => f.type === 'agent-md'),
|
||||
'fixture invalid: no agent file was discovered at all',
|
||||
);
|
||||
assert.equal(leverFindings(result).length, 0);
|
||||
assert.ok(
|
||||
hasNoSubagentsGap(result),
|
||||
'the plugin-bundled agent must not count as an authored subagent either',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -123,7 +123,7 @@ describe('buildManifest — load-pattern accounting (unit)', () => {
|
|||
],
|
||||
agents: [
|
||||
{ name: 'a1', source: 'project', pluginName: null, estimatedTokens: 5,
|
||||
...deriveLoadPattern('agent') },
|
||||
model: 'sonnet', effort: null, ...deriveLoadPattern('agent') },
|
||||
],
|
||||
outputStyles: [
|
||||
{ name: 's1', source: 'project', pluginName: null, estimatedTokens: 7,
|
||||
|
|
@ -165,6 +165,14 @@ describe('buildManifest — load-pattern accounting (unit)', () => {
|
|||
assert.equal(byName('output-style', 's1').loadPattern, 'always');
|
||||
});
|
||||
|
||||
// C4. withLoadPattern copies the explicit row object plus three load-pattern
|
||||
// fields and nothing else, so routing fields do NOT ride along from the
|
||||
// enumeration — they have to be named in the row.
|
||||
it('C4: carries model and effort on agent rows', () => {
|
||||
assert.equal(byName('agent', 'a1').model, 'sonnet');
|
||||
assert.equal(byName('agent', 'a1').effort, null);
|
||||
});
|
||||
|
||||
it('tags MCP servers always and hooks external', () => {
|
||||
assert.equal(byName('mcp-server', 'm1').loadPattern, 'always');
|
||||
assert.equal(byName('hook', 'PreToolUse:Edit').loadPattern, 'external');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue