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

@ -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