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:
Kjell Tore Guttormsen 2026-08-10 05:07:23 +02:00
commit 9ae4be26d2
16 changed files with 512 additions and 31 deletions

View file

@ -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 () => {

View file

@ -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');
});

View file

@ -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', () => {