feat(campaign): cross-repo prioritized backlog (v5.7 Fase 2 Block 4b)

buildBacklog(ledger) pure transform + read-only campaign-cli payload field
+ command rendering. The single machine-wide pick-list: per-repo (the ledger
tracks severity counts, not individual findings), severity-weighted
(SEVERITY_WEIGHTS c1000/h100/m10/l1), deterministic tie-break, excludes
implemented/pending/zero-finding repos.

No schema change, no new scanner -> scanner count stays 15, snapshot/backcompat
byte-stable. suite 1138->1150 (lib +9, campaign-cli +3). README badge 1091+->1150+.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 02:44:21 +02:00
commit 49833aded8
7 changed files with 263 additions and 10 deletions

View file

@ -113,7 +113,7 @@ Default: auto-detects scope from git context. Override with `/config-audit full|
node --test 'tests/**/*.test.mjs'
```
1138 tests across 65 test files (21 lib + 34 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`.
1150 tests across 65 test files (21 lib + 34 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`.
### active-config-reader — load-pattern model + rule/agent/output-style enumeration (v5.6 Foundation)
@ -479,6 +479,34 @@ orchestration is judgment-driven and deliberately outside the snapshot suite, ex
`/config-audit optimize` + `knowledge-refresh`. **No new agent** (web/judgment-free, runs in the
command's own context). suite 1127→1138.
### campaign backlog — cross-repo prioritized pick-list (v5.7 Fase 2, Block 4b)
The first half of Block 4 ("one cross-repo prioritized backlog the user picks from"). A pure
lib transform + a read-only CLI-payload field — **no schema change, no new scanner, byte-stable**.
- **`buildBacklog(ledger)`** (`scanners/lib/campaign-ledger.mjs`, pure, mirrors `rollUp`): the
single machine-wide prioritized work list. The actionable unit is a **repo** (the ledger tracks
per-repo severity *counts*, not individual findings — it tracks state, it does not re-run
audits), so each item is one repo: `{path, name, status, sessionId, findingsBySeverity
(normalized), totalFindings, weightedScore, rank}`. **Inclusion:** `status !== 'implemented'`
AND `totalFindings > 0` (implemented = done; pending / zero-finding repos have nothing known to
fix — they still surface in `rollUp.byStatus`). **Order:** DESC by `weightedScore` (exported
`SEVERITY_WEIGHTS = {critical:1000, high:100, medium:10, low:1}`), tie-broken lexicographically
by critical→high→medium→low count, then ascending `name` — fully deterministic, and the
tie-break keeps "criticals always win" even on a weighted-score collision (1 critical vs 10 high).
`rank` is 1-based after the sort.
- **`campaign-cli`** now emits `backlog: buildBacklog(ledger)` in both branches (uninitialized →
`[]`). Purely additive + read-only → fits the Block 3b read-only contract; the existing CLI
tests use targeted asserts (not full `deepEqual`), so the new field doesn't break them.
- **`commands/campaign.md`** renders the backlog as a "Prioritized backlog" pick-list and points
the user at the top item (still a pick-list, NOT an executor — execution is the later 4c block).
**Byte-stability.** `-cli`/lib/command only → scanner count stays **15**, snapshot/backcompat
suite untouched. suite 1138→1150 (lib +9, campaign-cli +3). **Deferred to 4c:** per-repo plan
export to each repo's `docs/` + reuse of backup/rollback for execution. **Deferred until the first
breaking schema change:** `migrateLedger` (4a) — backlog needs no schema bump, so building
migration now would be speculative (`schemaVersion` is already stamped for when it's needed).
## Gotchas
- Session directories accumulate — use `/config-audit cleanup` to manage

View file

@ -12,7 +12,7 @@
![Commands](https://img.shields.io/badge/commands-21-green)
![Agents](https://img.shields.io/badge/agents-7-orange)
![Hooks](https://img.shields.io/badge/hooks-4-red)
![Tests](https://img.shields.io/badge/tests-1091+-brightgreen)
![Tests](https://img.shields.io/badge/tests-1150+-brightgreen)
![License](https://img.shields.io/badge/license-MIT-lightgrey)
A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 15 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, cross-plugin collision, and output-style detection. Zero external dependencies.
@ -293,7 +293,7 @@ Your team configuration changes over time. Track it:
| `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence |
| `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) |
| `/config-audit knowledge-refresh` | Keep the best-practices register fresh — flag stale entries (sources older than ~90d) + poll for new/changed Claude Code practices; **human-approved writes only** (Verifiseringsplikt). Deterministic stale core + web candidate poll |
| `/config-audit campaign` | Machine-wide audit campaign — durable ledger above sessions tracking each repo's lifecycle (pending → audited → planned → implemented) + a machine-wide roll-up by severity, resumable across sessions; **human-approved writes only** (read-only report + deterministic write-CLI) |
| `/config-audit campaign` | Machine-wide audit campaign — durable ledger above sessions tracking each repo's lifecycle (pending → audited → planned → implemented) + a machine-wide roll-up by severity + a single **cross-repo prioritized backlog** to pick from (severity-weighted), resumable across sessions; **human-approved writes only** (read-only report + deterministic write-CLI) |
| `/config-audit discover` | Run discovery phase only |
| `/config-audit analyze` | Run analysis phase only |
| `/config-audit interview` | Set preferences for action plan _(optional)_ |

View file

@ -20,9 +20,9 @@ Every mutation — creating the ledger, adding a repo, changing a status — is
and applied **only on explicit approval**, by invoking one deterministic write-CLI subcommand.
The command never hand-edits the ledger JSON.
This is the **THIN** campaign surface (ledger + roll-up + status). Cross-repo backlog
prioritization and execution are a later block — this command does not run audits or apply
fixes itself; it tracks where each repo stands.
This is the **THIN** campaign surface (ledger + roll-up + status + a cross-repo prioritized
backlog to pick from). Execution is a later block — this command does not run audits or apply
fixes itself; it tracks where each repo stands and shows what to tackle next.
## Two CLIs back this command
@ -65,8 +65,10 @@ attempt a write over a corrupt ledger.)
Read `~/.claude/config-audit/sessions/campaign-report.json` with the Read tool (per the UX
rules — never show the raw JSON). It has `initialized`, `repos[]` (each: `path, name, status,
sessionId, findingsBySeverity, updatedDate`), and `rollUp {totalRepos, byStatus, bySeverity,
reposWithFindings}`.
sessionId, findingsBySeverity, updatedDate`), `rollUp {totalRepos, byStatus, bySeverity,
reposWithFindings}`, and `backlog[]` — the single cross-repo prioritized work list (each:
`path, name, status, findingsBySeverity, totalFindings, weightedScore, rank`), already sorted
DESC by severity (most critical work first).
Present it as two short tables:
@ -84,6 +86,19 @@ Present it as two short tables:
| Repo | Status | Findings (C/H/M/L) | Last updated |
|------|--------|--------------------|--------------|
**Prioritized backlog** — the one cross-repo list to pick from, highest-severity work first.
Render `backlog[]` in `rank` order (it is already sorted); omit this table entirely when the
backlog is empty (nothing outstanding — say "Backlog clear — no outstanding findings across
tracked repos."). Implemented repos and repos with no known findings are deliberately absent.
| # | Repo | Status | Findings (C/H/M/L) | Total |
|---|------|--------|--------------------|-------|
After it, point the user at the top item: "Highest priority: **`<name>`** (`<status>`) — pick it
with `/config-audit` (audit), `/config-audit plan`, or `/config-audit implement` in that repo,
then record progress here with `set-status`." The backlog is a **pick-list**, not an executor —
this command does not run audits or fixes (that is the later execution block).
If `initialized` is false, say so plainly: "No campaign yet. Run `/config-audit campaign init`
to start one." Then — if the mode was `init` or `add` — continue to that step (those bootstrap
a campaign); for `report`/`set-status` on an uninitialized ledger, stop after this message.
@ -173,5 +188,6 @@ Tailor to where the campaign stands:
and `knowledge-refresh`).
- The `-cli` suffix keeps both CLIs out of the scan-orchestrator, so the scanner count and the
byte-stable snapshot suite are unaffected.
- **THIN scope:** ledger + roll-up + status only. Cross-repo backlog prioritization and execution
are a later block — this command tracks state, it does not run audits or apply fixes.
- **THIN scope:** ledger + roll-up + status + a read-only cross-repo prioritized backlog. The
backlog is a derived pick-list (`buildBacklog`, severity-weighted); execution is a later block —
this command tracks state and shows what to tackle next, it does not run audits or apply fixes.

View file

@ -28,6 +28,7 @@ import {
loadLedger,
validateLedger,
rollUp,
buildBacklog,
defaultLedgerPath,
} from './lib/campaign-ledger.mjs';
@ -72,6 +73,7 @@ async function main() {
updatedDate: null,
repos: [],
rollUp: rollUp({ repos: [] }),
backlog: buildBacklog({ repos: [] }),
};
exitCode = 1; // advisory: there is no campaign to report yet
} else {
@ -88,6 +90,7 @@ async function main() {
updatedDate: ledger.updatedDate,
repos: ledger.repos,
rollUp: rollUp(ledger),
backlog: buildBacklog(ledger),
};
exitCode = 0;
}

View file

@ -33,6 +33,14 @@ export const STATUSES = Object.freeze(['pending', 'audited', 'planned', 'impleme
/** Severity buckets aggregated by the machine-wide roll-up. */
const SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low']);
/**
* Order-of-magnitude severity weights for the cross-repo backlog priority score. Each tier
* dominates the next so a single higher-severity finding outranks many lower ones; exact
* score collisions are still broken deterministically by the lexicographic + name tie-break
* in `buildBacklog`. Exported so the score is documented, not a magic number.
*/
export const SEVERITY_WEIGHTS = Object.freeze({ critical: 1000, high: 100, medium: 10, low: 1 });
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/** Validate an injected `now` (required, YYYY-MM-DD). Throws — callers pass today's date. */
@ -135,6 +143,65 @@ export function rollUp(ledger) {
return { totalRepos: ledger.repos.length, byStatus, bySeverity, reposWithFindings };
}
/**
* Build the single, machine-wide PRIORITIZED backlog the user picks from. Pure derivation
* never mutates. The actionable unit is a REPO (the ledger tracks per-repo severity counts,
* not individual findings it tracks state, it does not re-run audits), so each backlog item
* is one repo with outstanding work.
*
* Inclusion: a repo is in the backlog iff it is NOT yet `implemented` AND has at least one
* outstanding finding (`totalFindings > 0`). `implemented` repos are done; `pending` and
* zero-finding repos have nothing known to fix (they still surface in `rollUp.byStatus`).
*
* Order: DESC by `weightedScore` (SEVERITY_WEIGHTS), tie-broken lexicographically by
* criticalhighmediumlow count, then ascending by `name` fully deterministic, and the
* tie-break preserves "criticals always win" even when two repos share a weighted score.
*
* @param {object} ledger
* @returns {Array<{path:string,name:string,status:string,sessionId:string|null,findingsBySeverity:object,totalFindings:number,weightedScore:number,rank:number}>}
*/
export function buildBacklog(ledger) {
const items = [];
for (const repo of ledger.repos) {
if (repo.status === 'implemented') continue;
const f = repo.findingsBySeverity;
if (!f || typeof f !== 'object') continue;
const findingsBySeverity = Object.fromEntries(
SEVERITIES.map((s) => [s, typeof f[s] === 'number' ? f[s] : 0]),
);
const totalFindings = SEVERITIES.reduce((sum, s) => sum + findingsBySeverity[s], 0);
if (totalFindings === 0) continue;
const weightedScore = SEVERITIES.reduce(
(score, s) => score + findingsBySeverity[s] * SEVERITY_WEIGHTS[s],
0,
);
items.push({
path: repo.path,
name: repo.name,
status: repo.status,
sessionId: repo.sessionId ?? null,
findingsBySeverity,
totalFindings,
weightedScore,
});
}
items.sort(
(x, y) =>
y.weightedScore - x.weightedScore ||
y.findingsBySeverity.critical - x.findingsBySeverity.critical ||
y.findingsBySeverity.high - x.findingsBySeverity.high ||
y.findingsBySeverity.medium - x.findingsBySeverity.medium ||
y.findingsBySeverity.low - x.findingsBySeverity.low ||
x.name.localeCompare(y.name),
);
return items.map((item, i) => ({ ...item, rank: i + 1 }));
}
/**
* Validate a parsed ledger against the schema. Never throws returns every problem at once
* so the caller (and tests) can inspect them. Soft by design (loaded data may be corrupt).

View file

@ -6,10 +6,12 @@ import { tmpdir, homedir } from 'node:os';
import {
CAMPAIGN_SCHEMA_VERSION,
STATUSES,
SEVERITY_WEIGHTS,
createLedger,
addRepo,
setRepoStatus,
rollUp,
buildBacklog,
validateLedger,
defaultLedgerPath,
loadLedger,
@ -153,6 +155,109 @@ describe('rollUp', () => {
});
});
describe('buildBacklog', () => {
// Seed a ledger from a compact spec. A repo with `status` (and optional `findings`) is
// transitioned; a bare repo stays `pending` with null findings.
function ledgerWith(specs) {
let l = createLedger({ now: NOW });
for (const s of specs) {
l = addRepo(l, { path: s.path, name: s.name }, { now: NOW });
if (s.status) {
l = setRepoStatus(l, s.path, s.status, {
now: NOW,
findingsBySeverity: s.findings,
sessionId: s.sessionId,
});
}
}
return l;
}
it('SEVERITY_WEIGHTS is the frozen order-of-magnitude weighting', () => {
assert.deepEqual({ ...SEVERITY_WEIGHTS }, { critical: 1000, high: 100, medium: 10, low: 1 });
assert.ok(Object.isFrozen(SEVERITY_WEIGHTS));
});
it('returns [] for an empty ledger', () => {
assert.deepEqual(buildBacklog(createLedger({ now: NOW })), []);
});
it('excludes implemented, pending, and zero-finding repos', () => {
const l = ledgerWith([
{ path: '/r/done', name: 'done', status: 'implemented', findings: { critical: 5, high: 0, medium: 0, low: 0 } },
{ path: '/r/pend', name: 'pend' }, // pending, no findings
{ path: '/r/clean', name: 'clean', status: 'audited', findings: { critical: 0, high: 0, medium: 0, low: 0 } },
]);
assert.deepEqual(buildBacklog(l), []);
});
it('includes audited and planned repos that still have findings', () => {
const l = ledgerWith([
{ path: '/r/a', name: 'a', status: 'audited', findings: { critical: 0, high: 1, medium: 0, low: 0 } },
{ path: '/r/p', name: 'p', status: 'planned', findings: { critical: 0, high: 0, medium: 2, low: 0 } },
]);
const names = buildBacklog(l).map((i) => i.name).sort();
assert.deepEqual(names, ['a', 'p']);
});
it('ranks by severity (critical outranks high outranks low) and assigns rank 1..n', () => {
const l = ledgerWith([
{ path: '/r/low', name: 'low', status: 'audited', findings: { critical: 0, high: 0, medium: 0, low: 9 } },
{ path: '/r/crit', name: 'crit', status: 'audited', findings: { critical: 1, high: 0, medium: 0, low: 0 } },
{ path: '/r/high', name: 'high', status: 'planned', findings: { critical: 0, high: 5, medium: 0, low: 0 } },
]);
const backlog = buildBacklog(l);
assert.deepEqual(backlog.map((i) => i.name), ['crit', 'high', 'low']);
assert.deepEqual(backlog.map((i) => i.rank), [1, 2, 3]);
});
it('computes weightedScore and totalFindings, normalizing missing severity keys to 0', () => {
const l = ledgerWith([
{ path: '/r/x', name: 'x', status: 'audited', findings: { high: 2 } }, // critical/medium/low missing
]);
const [item] = buildBacklog(l);
assert.deepEqual(item.findingsBySeverity, { critical: 0, high: 2, medium: 0, low: 0 });
assert.equal(item.totalFindings, 2);
assert.equal(item.weightedScore, 200);
});
it('breaks a weightedScore tie by critical count, then by name', () => {
const l = ledgerWith([
// both score 1000: 10 high vs 1 critical → critical wins the tie
{ path: '/r/tenHigh', name: 'tenHigh', status: 'audited', findings: { critical: 0, high: 10, medium: 0, low: 0 } },
{ path: '/r/oneCrit', name: 'oneCrit', status: 'audited', findings: { critical: 1, high: 0, medium: 0, low: 0 } },
]);
assert.deepEqual(buildBacklog(l).map((i) => i.name), ['oneCrit', 'tenHigh']);
// fully identical findings → final tie-break is name (ascending)
const l2 = ledgerWith([
{ path: '/r/zebra', name: 'zebra', status: 'audited', findings: { critical: 0, high: 1, medium: 0, low: 0 } },
{ path: '/r/alpha', name: 'alpha', status: 'audited', findings: { critical: 0, high: 1, medium: 0, low: 0 } },
]);
assert.deepEqual(buildBacklog(l2).map((i) => i.name), ['alpha', 'zebra']);
});
it('carries per-repo provenance on each item', () => {
const l = ledgerWith([
{ path: '/r/a', name: 'a', status: 'audited', findings: { critical: 1, high: 0, medium: 0, low: 0 }, sessionId: '20260622_120000' },
]);
const [item] = buildBacklog(l);
assert.equal(item.path, '/r/a');
assert.equal(item.name, 'a');
assert.equal(item.status, 'audited');
assert.equal(item.sessionId, '20260622_120000');
});
it('does not mutate the input ledger', () => {
const l = ledgerWith([
{ path: '/r/a', name: 'a', status: 'audited', findings: { critical: 1, high: 0, medium: 0, low: 0 } },
]);
const snapshot = JSON.stringify(l);
buildBacklog(l);
assert.equal(JSON.stringify(l), snapshot);
});
});
describe('validateLedger', () => {
const good = () => setRepoStatus(
addRepo(createLedger({ now: NOW }), { path: '/r/foo', name: 'foo' }, { now: NOW }),

View file

@ -10,6 +10,7 @@ import {
addRepo,
setRepoStatus,
rollUp,
buildBacklog,
saveLedger,
} from '../../scanners/lib/campaign-ledger.mjs';
@ -126,6 +127,39 @@ describe('campaign-cli — payload shape', () => {
});
});
describe('campaign-cli — backlog', () => {
it('emits a backlog matching the lib, excluding implemented/pending/zero-finding repos', async () => {
const dir = mkdtempSync(join(tmpdir(), 'camp-cli-'));
const { file, ledger } = await seedLedger(dir);
const out = JSON.parse(runCli(['--ledger-file', file]).stdout);
// seed: a=audited(with findings), b=implemented(excluded), c=pending(excluded)
assert.deepEqual(out.backlog, buildBacklog(ledger));
assert.equal(out.backlog.length, 1);
assert.equal(out.backlog[0].name, 'a');
assert.equal(out.backlog[0].rank, 1);
});
it('ranks multiple actionable repos by severity', async () => {
const dir = mkdtempSync(join(tmpdir(), 'camp-cli-'));
let l = createLedger({ now: NOW });
l = addRepo(l, { path: '/r/low', name: 'low' }, { now: NOW });
l = addRepo(l, { path: '/r/crit', name: 'crit' }, { now: NOW });
l = setRepoStatus(l, '/r/low', 'audited', { now: NOW, findingsBySeverity: { critical: 0, high: 0, medium: 0, low: 9 } });
l = setRepoStatus(l, '/r/crit', 'planned', { now: NOW, findingsBySeverity: { critical: 1, high: 0, medium: 0, low: 0 } });
const file = join(dir, 'campaign-ledger.json');
await saveLedger(file, l);
const out = JSON.parse(runCli(['--ledger-file', file]).stdout);
assert.deepEqual(out.backlog.map((i) => i.name), ['crit', 'low']);
});
it('uninitialized payload has an empty backlog', () => {
const dir = mkdtempSync(join(tmpdir(), 'camp-cli-'));
const out = JSON.parse(runCli(['--ledger-file', join(dir, 'nope.json')]).stdout);
assert.deepEqual(out.backlog, []);
});
});
describe('campaign-cli — --output-file', () => {
it('writes JSON to the file and stays silent on stdout', async () => {
const dir = mkdtempSync(join(tmpdir(), 'camp-cli-'));