diff --git a/CLAUDE.md b/CLAUDE.md index 0a60a44..9203923 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index e138712..5a9d3b5 100644 --- a/README.md +++ b/README.md @@ -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)_ | diff --git a/commands/campaign.md b/commands/campaign.md index d3428c5..ffeb2b4 100644 --- a/commands/campaign.md +++ b/commands/campaign.md @@ -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: **``** (``) — 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. diff --git a/scanners/campaign-cli.mjs b/scanners/campaign-cli.mjs index fc3846b..913ecda 100644 --- a/scanners/campaign-cli.mjs +++ b/scanners/campaign-cli.mjs @@ -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; } diff --git a/scanners/lib/campaign-ledger.mjs b/scanners/lib/campaign-ledger.mjs index e8a47c9..12a2dba 100644 --- a/scanners/lib/campaign-ledger.mjs +++ b/scanners/lib/campaign-ledger.mjs @@ -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 + * critical→high→medium→low 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). diff --git a/tests/lib/campaign-ledger.test.mjs b/tests/lib/campaign-ledger.test.mjs index c1fe43a..8bc8119 100644 --- a/tests/lib/campaign-ledger.test.mjs +++ b/tests/lib/campaign-ledger.test.mjs @@ -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 }), diff --git a/tests/scanners/campaign-cli.test.mjs b/tests/scanners/campaign-cli.test.mjs index dea642b..6d784db 100644 --- a/tests/scanners/campaign-cli.test.mjs +++ b/tests/scanners/campaign-cli.test.mjs @@ -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-'));