feat(campaign): refresh-tokens live cross-repo token sweep (v5.9 B2b-2) [skip-docs]
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) <noreply@anthropic.com>
This commit is contained in:
parent
82f881afc4
commit
d664b70520
2 changed files with 170 additions and 7 deletions
|
|
@ -26,10 +26,20 @@
|
|||
* node campaign-write-cli.mjs set-status <path> <status>
|
||||
* [--findings '<json>'] [--session <id>]
|
||||
* [--ledger-file <p>] [--reference-date <d>]
|
||||
* node campaign-write-cli.mjs refresh-tokens [--ledger-file <p>] [--reference-date <d>]
|
||||
* (all accept [--output-file <p>] 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 =
|
||||
|
|
|
|||
|
|
@ -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:<number>} (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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue