From d664b7052013582f68ee287a6bb3750dc701b12d Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 23 Jun 2026 17:23:15 +0200 Subject: [PATCH] feat(campaign): refresh-tokens live cross-repo token sweep (v5.9 B2b-2) [skip-docs] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IO half of the machine-wide token roll-up (B2a shipped the pure data model). New campaign-write-cli subcommand 'refresh-tokens': for every tracked repo it runs readActiveConfig → buildManifest → splitManifestByOwnership, stores each repo's per-repo delta via setRepoTokens, and captures the HOME-derived shared global layer ONCE (from the first repo that reads cleanly) via setSharedGlobal. Capturing shared once is the structural counted-once guard: a repo that fails to read is recorded in 'skipped' and its delta omitted, never aborting the sweep. Idempotent — a re-sweep replaces (setters overwrite), never accumulates. Determinism unchanged: the clock is still read only via --reference-date. TDD: 3 integration tests first (RED → GREEN) against a fixture with a dominant global CLAUDE.md + two repos. Counted-once guard asserts each stored delta stays a small fraction of the shared layer (a double-fold would make each delta EXCEED shared). Caught a per-repo-MCP misclassification (fixed in 82f881a) + a test-side shape slip (rollUp flattens its token buckets to numbers; stored summaries keep {tokens,count}). Suite 1198 green (1195 + 3). Manifest + all frozen snapshots untouched. [skip-docs]: internal sweep CLI (campaign plumbing), no user-facing surface yet. The rendered token-bill in commands/campaign.md + CLAUDE.md rows land in B2b-3. Co-Authored-By: Claude Opus 4.8 (1M context) --- scanners/campaign-write-cli.mjs | 64 +++++++++++- tests/scanners/campaign-write-cli.test.mjs | 113 ++++++++++++++++++++- 2 files changed, 170 insertions(+), 7 deletions(-) diff --git a/scanners/campaign-write-cli.mjs b/scanners/campaign-write-cli.mjs index 5438112..45ff2ef 100644 --- a/scanners/campaign-write-cli.mjs +++ b/scanners/campaign-write-cli.mjs @@ -26,10 +26,20 @@ * node campaign-write-cli.mjs set-status * [--findings ''] [--session ] * [--ledger-file

] [--reference-date ] + * node campaign-write-cli.mjs refresh-tokens [--ledger-file

] [--reference-date ] * (all accept [--output-file

] to write the result payload to a file instead of stdout) * - * Exit codes: 0 = write performed, 1 = advisory no-op (init when already initialized), - * 3 = error (unknown subcommand, bad args, invalid status, untracked repo, no/corrupt ledger). + * `refresh-tokens` is the live cross-repo token sweep (v5.9 B2b): for every tracked + * repo it runs the manifest's always-loaded accounting (readActiveConfig → buildManifest) + * and splits each source into the shared global layer vs the repo's per-repo delta + * (splitManifestByOwnership). The shared layer is HOME-derived and identical across + * repos, so it is captured ONCE (from the first successful read) and stored at the + * ledger root; each repo gets only its delta. This is the IO half of the machine-wide + * token roll-up whose pure data model shipped in B2a. + * + * Exit codes: 0 = write performed (or no repos to sweep, a benign no-op), 1 = advisory + * no-op (init when already initialized), 3 = error (unknown subcommand, bad args, + * invalid status, untracked repo, no/corrupt ledger). */ import { resolve } from 'node:path'; @@ -38,11 +48,15 @@ import { createLedger, addRepo, setRepoStatus, + setSharedGlobal, + setRepoTokens, rollUp, loadLedger, saveLedger, defaultLedgerPath, } from './lib/campaign-ledger.mjs'; +import { readActiveConfig } from './lib/active-config-reader.mjs'; +import { buildManifest, splitManifestByOwnership } from './manifest.mjs'; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; @@ -88,7 +102,7 @@ async function emit(payload, outputFile, exitCode) { async function main() { const { subcommand, rest, flags } = parseArgs(process.argv.slice(2)); - if (!subcommand) fail('a subcommand is required: init | add | set-status'); + if (!subcommand) fail('a subcommand is required: init | add | set-status | refresh-tokens'); const ledgerPath = resolve(flags.ledgerFile || defaultLedgerPath()); if (flags.referenceDate && !DATE_RE.test(flags.referenceDate)) fail('--reference-date must be YYYY-MM-DD'); @@ -183,7 +197,49 @@ async function main() { ); } - fail(`unknown subcommand "${subcommand}" — expected init | add | set-status`); + if (subcommand === 'refresh-tokens') { + const ledger0 = await loadOrFail(ledgerPath); + if (ledger0 === null) fail(`no ledger at ${ledgerPath} — run "init" or "add" first`); + + let ledger = ledger0; + const swept = []; + const skipped = []; + // The shared global layer is HOME-derived and identical across every repo, so it + // is captured ONCE (from the first repo that reads cleanly) and stored at the + // ledger root — the structural guard against the historic shared-layer double-count. + let sharedSummary = null; + + for (const repo of ledger0.repos) { + let split; + try { + const activeConfig = await readActiveConfig(repo.path, { verbose: false }); + const { sources } = buildManifest(activeConfig); + split = splitManifestByOwnership(sources); + } catch (err) { + skipped.push({ path: repo.path, reason: err.message }); + continue; + } + if (sharedSummary === null) sharedSummary = split.shared; + ledger = setRepoTokens(ledger, repo.path, split.delta, { now }); + swept.push(repo.path); + } + + if (sharedSummary !== null) ledger = setSharedGlobal(ledger, sharedSummary, { now }); + + const written = swept.length > 0; + if (written) await saveLedger(ledgerPath, ledger); + + return emit( + { + status: 'ok', action: 'refresh-tokens', written, ledgerPath, + swept, skipped, sharedGlobal: ledger.sharedGlobal ?? null, rollUp: rollUp(ledger), + }, + flags.outputFile, + 0, + ); + } + + fail(`unknown subcommand "${subcommand}" — expected init | add | set-status | refresh-tokens`); } const isDirectRun = diff --git a/tests/scanners/campaign-write-cli.test.mjs b/tests/scanners/campaign-write-cli.test.mjs index ddf42b5..97a6566 100644 --- a/tests/scanners/campaign-write-cli.test.mjs +++ b/tests/scanners/campaign-write-cli.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; -import { readFileSync, mkdtempSync, existsSync } from 'node:fs'; +import { readFileSync, mkdtempSync, existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { loadLedger, @@ -19,9 +19,13 @@ const NOW = '2026-06-22'; // Every test passes an explicit --ledger-file in a temp dir + an injected --reference-date, // so the write-CLI never touches the real default path under HOME and never reads the clock. // The suite is hermetic + deterministic by construction. -function runWrite(args) { +function runWrite(args, env = {}) { try { - const stdout = execFileSync('node', [CLI, ...args], { encoding: 'utf-8', timeout: 15000 }); + const stdout = execFileSync('node', [CLI, ...args], { + encoding: 'utf-8', + timeout: 30000, + env: { ...process.env, ...env }, + }); return { status: 0, stdout }; } catch (err) { return { status: err.status, stdout: err.stdout || '', stderr: err.stderr || '' }; @@ -184,6 +188,109 @@ describe('campaign-write-cli — set-status', () => { }); }); +describe('campaign-write-cli — refresh-tokens (live cross-repo sweep, v5.9 B2b)', () => { + // Fixture: a fake HOME with a LARGE global CLAUDE.md (the shared layer, made to + // dominate so the counted-once guard is meaningful) + two repos OUTSIDE that HOME, + // each with a small project CLAUDE.md (the per-repo delta). repos sit beside the + // fake home so the cascade walk never picks up the global file as a project file. + function buildSweepFixture() { + const root = mkdtempSync(join(tmpdir(), 'camp-sweep-')); + const fakeHome = join(root, 'home'); + mkdirSync(join(fakeHome, '.claude'), { recursive: true }); + writeFileSync( + join(fakeHome, '.claude', 'CLAUDE.md'), + `# Global user config\n\n${'Shared instruction line that every repo pays for. '.repeat(120)}\n`, + ); + const repoA = join(root, 'repo-a'); + const repoB = join(root, 'repo-b'); + const bodies = [ + [repoA, '# Repo A\n\nProject A note.\n'], + [repoB, '# Repo B\n\nProject B has a little more local text here.\n'], + ]; + for (const [repo, body] of bodies) { + mkdirSync(join(repo, '.git'), { recursive: true }); + writeFileSync(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n'); + writeFileSync(join(repo, 'CLAUDE.md'), body); + } + return { root, fakeHome, repoA, repoB }; + } + + it('sweeps every tracked repo, counts the shared layer ONCE, attributes deltas per repo', async () => { + const { root, fakeHome, repoA, repoB } = buildSweepFixture(); + try { + const file = join(root, 'ledger.json'); + runWrite(['add', repoA, repoB, '--ledger-file', file, '--reference-date', NOW], { HOME: fakeHome }); + + const { status, stdout } = runWrite( + ['refresh-tokens', '--ledger-file', file, '--reference-date', NOW], + { HOME: fakeHome }, + ); + assert.equal(status, 0, stdout); + const out = JSON.parse(stdout); + assert.equal(out.action, 'refresh-tokens'); + assert.equal(out.written, true); + assert.deepEqual(out.swept.slice().sort(), [resolve(repoA), resolve(repoB)].sort()); + assert.deepEqual(out.skipped, []); + + // NOTE on shapes (B2a contract): the STORED summaries — out.sharedGlobal and each + // repo.tokens — carry {always:{tokens,count}}, but rollUp().tokens flattens its + // sharedGlobal/perRepoDelta/machineWide buckets to {always:} (bucketTokens). + const t = out.rollUp.tokens; + assert.ok(t.sharedGlobal.always > 0, 'shared global layer should be non-empty'); + assert.equal(t.reposWithTokens, 2); + assert.equal(t.byRepo.length, 2); + + // Persisted ledger carries the new token fields and stays schema-valid. + const ledgerOut = await loadLedger(file); + assert.ok(validateLedger(ledgerOut).valid, validateLedger(ledgerOut).errors.join('; ')); + assert.deepEqual(ledgerOut.sharedGlobal, out.sharedGlobal); + + // Counted-ONCE guard: each repo's stored delta must NOT contain the dominant + // shared global CLAUDE.md. Had the sweep folded shared into the per-repo tokens, + // each delta would EXCEED the shared layer instead of being a small fraction. + for (const repo of ledgerOut.repos) { + assert.ok(repo.tokens, `${repo.name} should have tokens`); + assert.ok( + repo.tokens.always.tokens < t.sharedGlobal.always, + `${repo.name} delta (${repo.tokens.always.tokens}) must be < shared (${t.sharedGlobal.always})`, + ); + } + + // rollUp invariant: machineWide = shared (once) + sum of per-repo deltas. + const deltaSum = ledgerOut.repos.reduce((s, r) => s + r.tokens.always.tokens, 0); + assert.equal(t.perRepoDelta.always, deltaSum); + assert.equal(t.machineWide.always, t.sharedGlobal.always + deltaSum); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('is idempotent — a second sweep replaces, never accumulates', async () => { + const { root, fakeHome, repoA, repoB } = buildSweepFixture(); + try { + const file = join(root, 'ledger.json'); + runWrite(['add', repoA, repoB, '--ledger-file', file, '--reference-date', NOW], { HOME: fakeHome }); + const first = JSON.parse( + runWrite(['refresh-tokens', '--ledger-file', file, '--reference-date', NOW], { HOME: fakeHome }).stdout, + ); + const second = JSON.parse( + runWrite(['refresh-tokens', '--ledger-file', file, '--reference-date', NOW], { HOME: fakeHome }).stdout, + ); + assert.deepEqual(second.rollUp.tokens, first.rollUp.tokens); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('exits 3 when no ledger exists yet', () => { + const dir = newDir(); + const { status } = runWrite([ + 'refresh-tokens', '--ledger-file', join(dir, 'nope.json'), '--reference-date', NOW, + ]); + assert.equal(status, 3); + }); +}); + describe('campaign-write-cli — determinism + --output-file', () => { it('stamps updatedDate from --reference-date and writes the payload to --output-file', async () => { const dir = newDir();