Dogfooding `campaign` + `knowledge-refresh` against a throwaway ledger. Seven
defects, all found by running the commands as written and measuring, not by
reading them.
The headline pair only existed together. `knowledge-refresh` built
`STALE_AFTER="--stale-after 30"` and expanded it unquoted, trusting the shell to
split it in two. bash does; zsh — the macOS default, and what the Bash tool runs
here — does not. The CLI got one argv entry, matched no flag, and because it had
no unknown-flag branch, silently kept the 90-day default and reported "✓ All 14
register entries were re-verified within the last 90 days": a true-sounding
sentence about a threshold the user had just overridden. Fixing either half alone
leaves a silent wrong answer or a loud one; both are fixed, and a guard now
rejects any template that packs a flag and its value into one variable.
`knowledge-refresh` also read one register and wrote another: step 6 named an
unanchored `knowledge/best-practices.json` while the CLI reads
`${CLAUDE_PLUGIN_ROOT}/…`, which for an installed plugin is the cache. The
validation gate then ran the cached test against the cached register — green no
matter what was written. The two copies were byte-identical that day, which is
exactly why it was invisible.
`campaign` vouched for repos it could not read. `add /finnes/ikke` returned
`added` + exit 0; `refresh-tokens` then put the phantom in `swept[]` with a
0-token delta and left `skipped[]` empty, so the machine-wide bill claimed
coverage of three repos on a machine with two. Paths stay tracked — an unmounted
volume is a legitimate absence — but are reported as `addedUnverified`, and the
command names them.
Two class sweeps, both measured rather than assumed. `posture` was the single
scanner (1 of 14) whose fatal catch exited 1, which ux-rules defines as a normal
WARNING grade — a crash indistinguishable from a result. And all 13 payload
writers failed on a `--output-file` whose parent did not exist, which on a fresh
machine turned `campaign`'s first run into "the ledger may be corrupt"; they now
share `scanners/lib/write-output.mjs`.
Predicted breadth was too wide for the first time in five sessions: 6 of 8 CLIs
predicted to lack unknown-flag rejection, 4 measured. `drift` and `fix` already
reject them, via a construct the grep did not recognise — a grep matches an
implementation, the invariant is a behaviour. The sweep was rewritten to run each
CLI with a bogus flag and read the exit code.
Suite 1453 → 1469/0. Frozen snapshots untouched. `optimize-lens-cli` and
`token-hotspots-cli` share the unknown-flag defect and are deferred to the v5.14
argument-handling chunk with their positional-swallow arm; the count is recorded
in the guard rather than rounded down to zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NHWjN8EnoxSqRvMTLK2NE
305 lines
11 KiB
JavaScript
305 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Manifest scanner CLI (v5 N2) — produce a ranked list of every token source
|
|
* loaded for a given repo path. Built on top of readActiveConfig so the source
|
|
* inventory is identical to whats-active; this CLI flattens and ranks them.
|
|
*
|
|
* Output JSON shape:
|
|
* {
|
|
* meta: { repoPath, generatedAt, durationMs },
|
|
* sources: [
|
|
* { kind: 'claude-md'|'skill'|'rule'|'agent'|'output-style'|'mcp-server'|'hook',
|
|
* name: string, source: string, estimated_tokens: number,
|
|
* loadPattern: 'always'|'on-demand'|'external'|'unknown',
|
|
* survivesCompaction: 'yes'|'no'|'n/a',
|
|
* derivationConfidence: 'confirmed'|'inferred' },
|
|
* ...
|
|
* ],
|
|
* summary: {
|
|
* always: { tokens, count }, // enter context every turn before you type
|
|
* onDemand: { tokens, count }, // loaded on invoke / on file read
|
|
* external: { tokens, count }, // run outside the context window (hooks)
|
|
* unknown: { tokens, count },
|
|
* },
|
|
* total: <sum of sources.estimated_tokens>
|
|
* }
|
|
*
|
|
* v5.6 B — load-pattern accounting. Sources are component-level: the coarse
|
|
* "plugin" roll-up was dropped because a plugin's contributions (skills, rules,
|
|
* agents, output styles, hooks, MCP) are each enumerated once on their own —
|
|
* keeping the roll-up double-counted them and corrupted the always-loaded
|
|
* subtotal. Every record now carries the load pattern derived from the
|
|
* published Claude Code loading model (deriveLoadPattern).
|
|
*
|
|
* Usage:
|
|
* node manifest.mjs [path] [--json] [--output-file <path>]
|
|
*
|
|
* Exit codes: 0=ok, 3=unrecoverable error.
|
|
* Zero external dependencies.
|
|
*/
|
|
|
|
import { resolve } from 'node:path';
|
|
import { stat } from 'node:fs/promises';
|
|
import { writeOutputFile } from './lib/write-output.mjs';
|
|
import { readActiveConfig, deriveLoadPattern } from './lib/active-config-reader.mjs';
|
|
|
|
// CLAUDE.md cascade files are all discovered by walking UP from the repo, so
|
|
// each one is always-loaded; the scope only changes the derivation confidence.
|
|
const CLAUDE_MD_SCOPE_KIND = {
|
|
project: 'claude-md-root',
|
|
local: 'claude-md-root',
|
|
user: 'claude-md-user',
|
|
managed: 'claude-md-managed',
|
|
import: 'claude-md-import',
|
|
};
|
|
|
|
/** Spread the three load-pattern fields onto a source record. */
|
|
function withLoadPattern(record, lp) {
|
|
return {
|
|
...record,
|
|
loadPattern: lp.loadPattern,
|
|
survivesCompaction: lp.survivesCompaction,
|
|
derivationConfidence: lp.derivationConfidence,
|
|
};
|
|
}
|
|
|
|
const sourceLabel = (item, fallback) =>
|
|
item.pluginName ? `plugin:${item.pluginName}` : item.source || fallback;
|
|
|
|
/**
|
|
* Flatten an activeConfig snapshot into a single ranked array of sources, each
|
|
* tagged with its load pattern, plus a load-pattern summary.
|
|
*/
|
|
export function buildManifest(activeConfig) {
|
|
const sources = [];
|
|
|
|
for (const f of activeConfig.claudeMd?.files || []) {
|
|
const tokens = estimateClaudeMdEntryTokens(f, activeConfig);
|
|
const kind = CLAUDE_MD_SCOPE_KIND[f.scope] || 'claude-md-root';
|
|
sources.push(withLoadPattern({
|
|
kind: 'claude-md',
|
|
name: f.path,
|
|
source: f.scope,
|
|
estimated_tokens: tokens,
|
|
}, deriveLoadPattern(kind)));
|
|
}
|
|
|
|
// Skills: the measured tokens are the skill BODY (full file), paid on invoke.
|
|
// The always-loaded part (name+description listing) is small and tracked
|
|
// separately (skill-listing-budget / posture), so the body is tagged
|
|
// on-demand here rather than inflating the always-loaded subtotal.
|
|
for (const s of activeConfig.skills || []) {
|
|
sources.push(withLoadPattern({
|
|
kind: 'skill',
|
|
name: s.name,
|
|
source: sourceLabel(s, 'user'),
|
|
estimated_tokens: s.estimatedTokens || 0,
|
|
}, deriveLoadPattern('skill-body')));
|
|
}
|
|
|
|
// Rules / agents / output styles — the foundation enumeration already derived
|
|
// the load pattern (rules vary by `scoped`), so propagate it verbatim.
|
|
for (const r of activeConfig.rules || []) {
|
|
sources.push(withLoadPattern({
|
|
kind: 'rule',
|
|
name: r.name,
|
|
source: sourceLabel(r, 'project'),
|
|
estimated_tokens: r.estimatedTokens || 0,
|
|
}, r));
|
|
}
|
|
|
|
for (const a of activeConfig.agents || []) {
|
|
sources.push(withLoadPattern({
|
|
kind: 'agent',
|
|
name: a.name,
|
|
source: sourceLabel(a, 'project'),
|
|
estimated_tokens: a.estimatedTokens || 0,
|
|
}, a));
|
|
}
|
|
|
|
for (const o of activeConfig.outputStyles || []) {
|
|
sources.push(withLoadPattern({
|
|
kind: 'output-style',
|
|
name: o.name,
|
|
source: sourceLabel(o, 'project'),
|
|
estimated_tokens: o.estimatedTokens || 0,
|
|
}, o));
|
|
}
|
|
|
|
for (const m of activeConfig.mcpServers || []) {
|
|
if (m && m.enabled === false) continue;
|
|
sources.push(withLoadPattern({
|
|
kind: 'mcp-server',
|
|
name: m.name,
|
|
source: m.source || 'unknown',
|
|
estimated_tokens: m.estimatedTokens || 0,
|
|
}, deriveLoadPattern('mcp')));
|
|
}
|
|
|
|
for (const h of activeConfig.hooks || []) {
|
|
sources.push(withLoadPattern({
|
|
kind: 'hook',
|
|
name: `${h.event}${h.matcher ? `:${h.matcher}` : ''}`,
|
|
source: h.source || h.sourcePath || 'unknown',
|
|
estimated_tokens: h.estimatedTokens || 0,
|
|
}, deriveLoadPattern('hook')));
|
|
}
|
|
|
|
sources.sort((a, b) => b.estimated_tokens - a.estimated_tokens);
|
|
const total = sources.reduce((s, x) => s + (x.estimated_tokens || 0), 0);
|
|
const summary = summarizeByLoadPattern(sources);
|
|
return { sources, total, summary };
|
|
}
|
|
|
|
/**
|
|
* Bucket sources by load pattern into {tokens, count} subtotals. The `always`
|
|
* bucket is the headline: tokens that enter context every turn before the user
|
|
* types anything.
|
|
*/
|
|
export function summarizeByLoadPattern(sources) {
|
|
const mk = () => ({ tokens: 0, count: 0 });
|
|
const summary = { always: mk(), onDemand: mk(), external: mk(), unknown: mk() };
|
|
const BUCKET = { always: 'always', 'on-demand': 'onDemand', external: 'external' };
|
|
for (const s of sources) {
|
|
const key = BUCKET[s.loadPattern] || 'unknown';
|
|
summary[key].tokens += s.estimated_tokens || 0;
|
|
summary[key].count += 1;
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
/**
|
|
* Source strings (the `source` field buildManifest stamps) that belong to the
|
|
* SHARED GLOBAL layer — config paid once per machine and identical in every
|
|
* repo: the global ~/.claude CLAUDE.md (`user`) and managed enterprise policy
|
|
* (`managed`). Installed plugins are also shared but are matched by the
|
|
* `plugin:` prefix below, not by this set.
|
|
*
|
|
* Deliberately NOT here: `~/.claude.json:projects`. Although that file lives in
|
|
* HOME, `readClaudeJsonProjectSlice` returns the slice keyed to the SPECIFIC
|
|
* repo path — those MCP servers are per-repo, load only in their own project,
|
|
* and differ across repos, so they are a delta (folding them into the
|
|
* once-counted shared layer would drop every repo's slice but the first). The
|
|
* only machine-global MCP is plugin-provided (caught by the `plugin:` prefix).
|
|
*/
|
|
const SHARED_GLOBAL_SOURCES = Object.freeze(new Set(['user', 'managed']));
|
|
|
|
/**
|
|
* Classify one manifest source as part of the once-counted shared global layer
|
|
* or a per-repo delta (v5.9 B2b). Anything not positively identified as global
|
|
* (project / local / .mcp.json / ~/.claude.json:projects / @import / unrecognized)
|
|
* falls to `delta`, so a source is never silently folded into the shared layer —
|
|
* a wrong fold would HIDE machine-wide cost, whereas a wrong delta is at worst
|
|
* attributed visibly to a repo.
|
|
* @param {string} source
|
|
* @returns {'shared'|'delta'}
|
|
*/
|
|
export function classifyOwnership(source) {
|
|
if (typeof source === 'string') {
|
|
if (source.startsWith('plugin:')) return 'shared'; // installed plugins are machine-global
|
|
if (SHARED_GLOBAL_SOURCES.has(source)) return 'shared';
|
|
}
|
|
return 'delta';
|
|
}
|
|
|
|
/**
|
|
* Partition manifest sources by ownership for the machine-wide token roll-up,
|
|
* returning two load-pattern summaries in the exact shape `summarizeByLoadPattern`
|
|
* emits ({always,onDemand,external,unknown:{tokens,count}}), so the campaign
|
|
* ledger setters (`setSharedGlobal` / `setRepoTokens`) consume them verbatim.
|
|
*
|
|
* - `shared`: the global layer, identical across repos — set ONCE on the ledger
|
|
* root so the roll-up counts it exactly once (the structural double-count guard).
|
|
* - `delta`: this repo's own project/local contribution beyond the shared layer.
|
|
*
|
|
* The split is total: every source lands in exactly one layer.
|
|
* @param {Array<{source:string, loadPattern:string, estimated_tokens:number}>} sources
|
|
* @returns {{shared:object, delta:object}}
|
|
*/
|
|
export function splitManifestByOwnership(sources) {
|
|
const shared = [];
|
|
const delta = [];
|
|
for (const s of sources || []) {
|
|
(classifyOwnership(s.source) === 'shared' ? shared : delta).push(s);
|
|
}
|
|
return { shared: summarizeByLoadPattern(shared), delta: summarizeByLoadPattern(delta) };
|
|
}
|
|
|
|
/**
|
|
* Distribute the cascade-level estimated tokens across the individual files
|
|
* proportional to their byte size. claudeMd.estimatedTokens is computed for
|
|
* the cascade as a whole, but for ranking we want per-file figures.
|
|
*/
|
|
function estimateClaudeMdEntryTokens(file, activeConfig) {
|
|
const totalBytes = activeConfig.claudeMd?.totalBytes || 0;
|
|
const totalTokens = activeConfig.claudeMd?.estimatedTokens || 0;
|
|
if (totalBytes === 0 || totalTokens === 0) return 0;
|
|
const share = (file.bytes || 0) / totalBytes;
|
|
return Math.round(totalTokens * share);
|
|
}
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
let targetPath = '.';
|
|
let outputFile = null;
|
|
let jsonMode = false;
|
|
// --raw is accepted for CLI surface consistency but is a no-op here:
|
|
// manifest produces a token-source inventory, not findings.
|
|
let rawMode = false;
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--json') jsonMode = true;
|
|
else if (args[i] === '--raw') rawMode = true;
|
|
else if (args[i] === '--output-file' && args[i + 1]) outputFile = args[++i];
|
|
else if (!args[i].startsWith('-')) targetPath = args[i];
|
|
}
|
|
|
|
const absPath = resolve(targetPath);
|
|
try {
|
|
const s = await stat(absPath);
|
|
if (!s.isDirectory()) {
|
|
process.stderr.write(`Error: ${absPath} is not a directory\n`);
|
|
process.exitCode = 3;
|
|
return;
|
|
}
|
|
} catch {
|
|
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
|
|
process.exitCode = 3;
|
|
return;
|
|
}
|
|
|
|
const start = Date.now();
|
|
const activeConfig = await readActiveConfig(absPath, { verbose: true });
|
|
const manifest = buildManifest(activeConfig);
|
|
|
|
const output = {
|
|
meta: {
|
|
tool: 'config-audit:manifest',
|
|
repoPath: absPath,
|
|
generatedAt: new Date().toISOString(),
|
|
durationMs: Date.now() - start,
|
|
},
|
|
sources: manifest.sources,
|
|
summary: manifest.summary,
|
|
total: manifest.total,
|
|
};
|
|
|
|
const json = JSON.stringify(output, null, 2);
|
|
|
|
if (outputFile) {
|
|
await writeOutputFile(outputFile, json, 'utf-8');
|
|
}
|
|
|
|
if (jsonMode || rawMode || !outputFile) {
|
|
process.stdout.write(json + '\n');
|
|
}
|
|
}
|
|
|
|
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
|
if (isDirectRun) {
|
|
main().catch(err => {
|
|
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
process.exitCode = 3;
|
|
});
|
|
}
|