process.exit() terminates immediately, but Node writes stdout asynchronously when stdout is a pipe — everything still buffered is dropped. scan-orchestrator measured 246 854 bytes to a file against 65 536 to a pipe (131 072 on another run; the cut point is a flush race), so every machine consumer that pipes the envelope got truncated, unparseable JSON. The failure reads like a corrupt file, not like a cut-off, which is what made it survive this long. Reported by org-ops, whose census pipes our output. Closes the class rather than the one CLI where it was visible. campaign-cli, campaign-export-cli, campaign-write-cli, knowledge-refresh-cli, drift-cli and fix-cli all exited the same way on their success paths and were green only because their payloads fit the pipe buffer today; size is not correctness. All 38 sites across 14 files now set process.exitCode and return, which is the pattern self-audit.mjs already used. Two contracts needed care rather than substitution: fail() is a never-returns guard at ~25 call sites, so it throws a CliUsageError the top-level catch renders with the identical "Error: " prefix and exit code 3; the path guards needed an explicit return so main() stops instead of running on. Exit codes and stderr text are unchanged, and the frozen v5.0.0 snapshots are untouched. The class sweep is landed as a test, not as fourteen edits — it caught one site this commit had missed. Suite 1443/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8sS1DuDV6bUJcyumLwbvj
304 lines
11 KiB
JavaScript
304 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 { writeFile, stat } from 'node:fs/promises';
|
|
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 writeFile(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;
|
|
});
|
|
}
|