fix: resolve four bug reports (#2290, #2282, #2276, #2272)

- #2290 suggest-compact: honor ECC_CONTEXT_WINDOW_TOKENS / CLAUDE_CODE_AUTO_COMPACT_WINDOW
  so 400k-window models (Opus 4.x) no longer report ~double context usage; add
  override + isolation tests in transcript-context.test.js.
- #2282 install: bare-language syntax is legacy-only by design, but the error
  now distinguishes a supported-but-wrong-mode target (gemini/codex/…) from a
  genuinely unknown one and points to --profile/--modules/--skills.
- #2276 cost-report: the command + cost-tracking skill targeted a SQLite DB no
  tracker writes. Repoint both at the real ~/.claude/metrics/costs.jsonl (JSONL,
  estimated_cost_usd), reduce cumulative-per-session snapshots to latest-per-session,
  and use node instead of sqlite3 for cross-platform support.
- #2272 gateguard: make the 'confirm no existing file' checklist item
  tool-agnostic (Glob/Grep or find/grep via Bash) so hosts without a Glob tool
  don't get a dead tool call.

Full suite 2839/2839; lint green.
This commit is contained in:
Affaan Mustafa 2026-06-18 16:49:58 -04:00
parent 51184b692e
commit b3268fef80
7 changed files with 325 additions and 424 deletions

View File

@ -1,107 +1,81 @@
---
description: Generate a local Claude Code cost report from a cost-tracker SQLite database.
description: Generate a local Claude Code cost report from the ECC cost-tracker metrics log.
argument-hint: [csv]
---
# Cost Report
Query the local cost-tracking database and present a spending report by day,
project, tool, and session. This command assumes a cost-tracking hook or plugin
is already writing usage rows to `~/.claude-cost-tracker/usage.db`.
Summarize local Claude Code spend by day, model, and session from the metrics
log that ECC's `stop:cost-tracker` hook writes.
## What This Command Does
## Where the data lives
1. Check that `sqlite3` is available.
2. Check that `~/.claude-cost-tracker/usage.db` exists.
3. Run aggregate queries against the `usage` table.
4. Present a compact report, or export recent rows as CSV when the argument is
`csv`.
The tracker appends one JSON object per session-stop to
`~/.claude/metrics/costs.jsonl`. Each row is a **cumulative snapshot for that
session**, so the report takes the **latest row per `session_id`** and sums
across sessions (summing every row would multiply-count).
## Prerequisites
Row schema:
`{ timestamp, session_id, transcript_path, model, input_tokens, output_tokens, cache_write_tokens, cache_read_tokens, estimated_cost_usd }`
The database must be populated by a local cost tracker. If the file is missing,
tell the user the tracker is not set up and suggest installing or enabling a
trusted Claude Code cost-tracking hook/plugin first.
## What this command does
1. Check that `~/.claude/metrics/costs.jsonl` exists. If it does not, tell the
user the tracker is not set up yet (it populates after the first session ends
with the `stop:cost-tracker` hook enabled).
2. Reduce rows to the latest snapshot per session and aggregate.
3. Present a compact report, or export recent rows as CSV when the argument is `csv`.
`node` is used instead of `sqlite3`/`jq` so this works identically on macOS,
Linux, and Windows.
## Report
```bash
test -f ~/.claude-cost-tracker/usage.db && echo "Database found" || echo "Database not found"
node -e '
const fs=require("fs"),os=require("os"),path=require("path");
const f=path.join(os.homedir(),".claude","metrics","costs.jsonl");
if(!fs.existsSync(f)){console.log("Cost tracker not set up: "+f+" not found. Enable the stop:cost-tracker hook and finish a session first.");process.exit(0);}
const rows=fs.readFileSync(f,"utf8").split(/\r?\n/).filter(Boolean).map(l=>{try{return JSON.parse(l)}catch{return null}}).filter(Boolean);
const bySession=new Map();
for(const r of rows){const k=r.session_id||r.transcript_path||r.timestamp;const p=bySession.get(k);if(!p||String(r.timestamp)>String(p.timestamp))bySession.set(k,r);}
const latest=[...bySession.values()];
const cost=r=>Number(r.estimated_cost_usd)||0;
const day=r=>String(r.timestamp||"").slice(0,10);
const today=new Date().toISOString().slice(0,10);
const d=new Date(Date.now()-864e5).toISOString().slice(0,10);
const sum=a=>a.reduce((s,r)=>s+cost(r),0);
const f4=n=>"$"+n.toFixed(4);
console.log("=== Cost summary ===");
console.log("today: "+f4(sum(latest.filter(r=>day(r)===today))));
console.log("yesterday: "+f4(sum(latest.filter(r=>day(r)===d))));
console.log("total: "+f4(sum(latest))+" ("+latest.length+" sessions)");
const by=(key)=>{const m=new Map();for(const r of latest){const k=key(r)||"(unknown)";m.set(k,(m.get(k)||0)+cost(r));}return [...m.entries()].sort((a,b)=>b[1]-a[1]);};
console.log("\n=== By model ===");for(const [k,v] of by(r=>r.model))console.log(f4(v).padStart(12)+" "+k);
console.log("\n=== Last 7 days ===");
const days=new Map();for(const r of latest){const k=day(r);days.set(k,(days.get(k)||0)+cost(r));}
[...days.entries()].sort((a,b)=>b[0]<a[0]?-1:1).slice(0,7).forEach(([k,v])=>console.log(k+" "+f4(v)));
'
```
## Summary Query
## CSV export (`/cost-report csv`)
```bash
sqlite3 -header -column ~/.claude-cost-tracker/usage.db "
SELECT
ROUND(COALESCE(SUM(CASE WHEN date(timestamp) = date('now') THEN cost_usd END), 0), 4) AS today_cost,
ROUND(COALESCE(SUM(CASE WHEN date(timestamp) = date('now', '-1 day') THEN cost_usd END), 0), 4) AS yesterday_cost,
ROUND(COALESCE(SUM(cost_usd), 0), 4) AS total_cost,
COUNT(*) AS total_calls,
COUNT(DISTINCT session_id) AS sessions
FROM usage;
"
node -e '
const fs=require("fs"),os=require("os"),path=require("path");
const f=path.join(os.homedir(),".claude","metrics","costs.jsonl");
if(!fs.existsSync(f)){console.error("no data");process.exit(0);}
const rows=fs.readFileSync(f,"utf8").split(/\r?\n/).filter(Boolean).map(l=>{try{return JSON.parse(l)}catch{return null}}).filter(Boolean).slice(-100);
console.log("timestamp,session_id,model,input_tokens,output_tokens,cache_write_tokens,cache_read_tokens,estimated_cost_usd");
for(const r of rows)console.log([r.timestamp,r.session_id,r.model,r.input_tokens,r.output_tokens,r.cache_write_tokens,r.cache_read_tokens,r.estimated_cost_usd].join(","));
'
```
## Project Breakdown
## Report format
```bash
sqlite3 -header -column ~/.claude-cost-tracker/usage.db "
SELECT project, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls
FROM usage
GROUP BY project
ORDER BY cost DESC;
"
```
1. Summary: today, yesterday, total, session count.
2. By model: models ranked by total cost.
3. Last seven days: date and cost.
## Tool Breakdown
```bash
sqlite3 -header -column ~/.claude-cost-tracker/usage.db "
SELECT tool_name, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls
FROM usage
GROUP BY tool_name
ORDER BY cost DESC;
"
```
## Last Seven Days
```bash
sqlite3 -header -column ~/.claude-cost-tracker/usage.db "
SELECT date(timestamp) AS date, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls
FROM usage
GROUP BY date(timestamp)
ORDER BY date DESC
LIMIT 7;
"
```
## CSV Export
If the user asks for `/cost-report csv`, export the most recent usage rows with
an explicit column list:
```bash
sqlite3 -csv -header ~/.claude-cost-tracker/usage.db "
SELECT timestamp, project, tool_name, input_tokens, output_tokens, cost_usd, session_id, model
FROM usage
ORDER BY timestamp DESC
LIMIT 100;
"
```
## Report Format
Format the response as:
1. Summary: today, yesterday, total, calls, sessions.
2. By project: projects ranked by total cost.
3. By tool: tools ranked by total cost.
4. Last seven days: date, cost, call count.
Use four decimal places for sub-dollar amounts. Do not estimate pricing from raw
tokens in this command; rely on the precomputed `cost_usd` values written by the
tracker.
## Source
Salvaged from stale community PR #1304 by `MayurBhavsar`.
Rely on the precomputed `estimated_cost_usd` values written by the tracker; do
not re-estimate pricing from raw tokens here.

View File

@ -49,7 +49,7 @@
},
{
"command": "cost-report",
"description": "Generate a local Claude Code cost report from a cost-tracker SQLite database.",
"description": "Generate a local Claude Code cost report from the ECC cost-tracker metrics log.",
"type": "testing",
"primaryAgents": [],
"allAgents": [],

View File

@ -25,11 +25,7 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const {
extractCommandSubstitutions,
extractSubshellGroups,
extractBraceGroups
} = require('../lib/shell-substitution');
const { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups } = require('../lib/shell-substitution');
// Session state — scoped per session to avoid cross-session races.
const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard');
@ -88,10 +84,10 @@ function getExtraDestructiveRegex() {
extraDestructiveCacheRegex = null;
if (!extraDestructiveWarnLogged) {
try {
process.stderr.write(
`[gateguard-fact-force] ignoring invalid GATEGUARD_BASH_EXTRA_DESTRUCTIVE regex: ${err.message}\n`
);
} catch (_) { /* stderr write failure is non-fatal */ }
process.stderr.write(`[gateguard-fact-force] ignoring invalid GATEGUARD_BASH_EXTRA_DESTRUCTIVE regex: ${err.message}\n`);
} catch (_) {
/* stderr write failure is non-fatal */
}
extraDestructiveWarnLogged = true;
}
}
@ -112,9 +108,7 @@ function isRoutineBashGateDisabled() {
* @returns {string}
*/
function stripQuotedStrings(input) {
return input
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
.replace(/"(?:[^"\\]|\\.)*"/g, '""');
return input.replace(/'(?:[^'\\]|\\.)*'/g, "''").replace(/"(?:[^"\\]|\\.)*"/g, '""');
}
/**
@ -168,7 +162,6 @@ function tokenize(segment) {
return segment.split(/\s+/).filter(Boolean);
}
/**
* Tokenize a short allowlisted shell command while preserving quoted
* arguments. This is intentionally smaller than a full shell parser: the
@ -236,7 +229,10 @@ function tokenizeAllowlistedShellWords(input) {
*/
function commandBasename(token) {
if (!token) return '';
return token.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase();
return token
.replace(/^.*[\\/]/, '')
.replace(/\.exe$/i, '')
.toLowerCase();
}
/**
@ -553,7 +549,10 @@ function isDestructiveBash(command) {
// that false-negative while also catching `&&`, `;`, `|`, and `||` compound forms.
const bodies = collectExecutableBodies(raw);
for (const body of bodies) {
for (const rawSeg of body.split(/[;|&]+/).map(s => s.trim()).filter(Boolean)) {
for (const rawSeg of body
.split(/[;|&]+/)
.map(s => s.trim())
.filter(Boolean)) {
if (isDestructiveFindExec(rawSeg)) return true;
}
}
@ -573,7 +572,9 @@ function isDestructiveBash(command) {
// --- State management (per-session, atomic writes, bounded) ---
function normalizeEnvValue(value) {
return String(value || '').trim().toLowerCase();
return String(value || '')
.trim()
.toLowerCase();
}
function isGateGuardDisabled() {
@ -886,8 +887,7 @@ function isReadOnlyGitIntrospection(command) {
if (args.length === 2) {
const [first, second] = args;
// ref + flag
if (!first.startsWith('--') && /^[a-zA-Z0-9._:/ -]+$/.test(first) &&
(second === '--stat' || second === '--name-only')) {
if (!first.startsWith('--') && /^[a-zA-Z0-9._:/ -]+$/.test(first) && (second === '--stat' || second === '--name-only')) {
return true;
}
return false;
@ -932,7 +932,7 @@ function writeGateMsg(filePath) {
`Before creating ${safe}, present these facts:`,
'',
'1. Name the file(s) and line(s) that will call this new file',
'2. Confirm no existing file serves the same purpose (use Glob)',
'2. Confirm no existing file serves the same purpose (search the tree — Glob/Grep, or find/grep via Bash)',
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
"4. Quote the user's current instruction verbatim",
'',
@ -983,11 +983,7 @@ function routineBashMsg() {
function withRecoveryHint(message, hookIds = [EDIT_WRITE_HOOK_ID]) {
const disableTargets = hookIds.map(hookId => `\`${hookId}\``).join(' or ');
return [
message,
'',
`Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.`
].join('\n');
return [message, '', `Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.`].join('\n');
}
function isSubagentInvocation(data) {
@ -995,12 +991,7 @@ function isSubagentInvocation(data) {
return false;
}
const candidates = [
data.agent_id,
data.agentId,
data.parent_tool_use_id,
data.parentToolUseId
];
const candidates = [data.agent_id, data.agentId, data.parent_tool_use_id, data.parentToolUseId];
return candidates.some(candidate => typeof candidate === 'string' && candidate.trim());
}

View File

@ -5,20 +5,12 @@ const { execFileSync } = require('child_process');
const { toCursorAgentRelativePath } = require('./cursor-agent-names');
const { LEGACY_INSTALL_TARGETS, parseInstallArgs } = require('./install/request');
const {
SUPPORTED_INSTALL_TARGETS,
listLegacyCompatibilityLanguages,
resolveLegacyCompatibilitySelection,
resolveInstallPlan,
} = require('./install-manifests');
const { SUPPORTED_INSTALL_TARGETS, listLegacyCompatibilityLanguages, resolveLegacyCompatibilitySelection, resolveInstallPlan } = require('./install-manifests');
const { getInstallTargetAdapter } = require('./install-targets/registry');
const LANGUAGE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
const CLAUDE_ECC_NAMESPACE = 'ecc';
const EXCLUDED_GENERATED_SOURCE_SUFFIXES = [
'/ecc-install-state.json',
'/ecc/install-state.json',
];
const EXCLUDED_GENERATED_SOURCE_SUFFIXES = ['/ecc-install-state.json', '/ecc/install-state.json'];
function getSourceRoot() {
return path.join(__dirname, '../..');
@ -26,9 +18,7 @@ function getSourceRoot() {
function getPackageVersion(sourceRoot) {
try {
const packageJson = JSON.parse(
fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8')
);
const packageJson = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8'));
return packageJson.version || null;
} catch (_error) {
return null;
@ -37,9 +27,7 @@ function getPackageVersion(sourceRoot) {
function getManifestVersion(sourceRoot) {
try {
const modulesManifest = JSON.parse(
fs.readFileSync(path.join(sourceRoot, 'manifests', 'install-modules.json'), 'utf8')
);
const modulesManifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'manifests', 'install-modules.json'), 'utf8'));
return modulesManifest.version || 1;
} catch (_error) {
return 1;
@ -52,7 +40,7 @@ function getRepoCommit(sourceRoot) {
cwd: sourceRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
timeout: 5000
}).trim();
} catch (_error) {
return null;
@ -64,32 +52,35 @@ function readDirectoryNames(dirPath) {
return [];
}
return fs.readdirSync(dirPath, { withFileTypes: true })
return fs
.readdirSync(dirPath, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort();
}
function listAvailableLanguages(sourceRoot = getSourceRoot()) {
return [...new Set([
...listLegacyCompatibilityLanguages(),
...readDirectoryNames(path.join(sourceRoot, 'rules'))
.filter(name => name !== 'common'),
])].sort();
return [...new Set([...listLegacyCompatibilityLanguages(), ...readDirectoryNames(path.join(sourceRoot, 'rules')).filter(name => name !== 'common')])].sort();
}
function validateLegacyTarget(target) {
if (!LEGACY_INSTALL_TARGETS.includes(target)) {
if (LEGACY_INSTALL_TARGETS.includes(target)) {
return;
}
// A target can be fully supported yet not installable via the bare-language
// positional syntax (which is legacy-only). Guide the user to the right mode
// instead of implying the target is unknown (#2282).
if (SUPPORTED_INSTALL_TARGETS.includes(target)) {
throw new Error(
`Unknown install target: ${target}. Expected one of ${LEGACY_INSTALL_TARGETS.join(', ')}`
`Target '${target}' is supported, but the bare-language install syntax only accepts ${LEGACY_INSTALL_TARGETS.join(', ')}. ` +
`Install '${target}' with a component selection instead, e.g. \`install.sh --target ${target} --profile full\` ` +
`(or --modules <id,...> / --skills <id,...>).`
);
}
throw new Error(`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`);
}
const IGNORED_DIRECTORY_NAMES = new Set([
'node_modules',
'.git',
]);
const IGNORED_DIRECTORY_NAMES = new Set(['node_modules', '.git']);
function listFilesRecursive(dirPath) {
if (!fs.existsSync(dirPath)) {
@ -141,7 +132,7 @@ function buildCopyFileOperation({ moduleId, sourcePath, sourceRelativePath, dest
destinationPath,
strategy,
ownership: 'managed',
scaffoldOnly: false,
scaffoldOnly: false
};
}
@ -156,20 +147,20 @@ function addRecursiveCopyOperations(operations, options) {
for (const relativeFile of relativeFiles) {
const sourceRelativePath = path.join(options.sourceRelativeDir, relativeFile);
const sourcePath = path.join(options.sourceRoot, sourceRelativePath);
const destinationRelativePath = typeof options.destinationRelativePathTransform === 'function'
? options.destinationRelativePathTransform(relativeFile, sourceRelativePath)
: relativeFile;
const destinationRelativePath = typeof options.destinationRelativePathTransform === 'function' ? options.destinationRelativePathTransform(relativeFile, sourceRelativePath) : relativeFile;
if (!destinationRelativePath) {
continue;
}
const destinationPath = path.join(options.destinationDir, destinationRelativePath);
operations.push(buildCopyFileOperation({
moduleId: options.moduleId,
sourcePath,
sourceRelativePath,
destinationPath,
strategy: options.strategy || 'preserve-relative-path',
}));
operations.push(
buildCopyFileOperation({
moduleId: options.moduleId,
sourcePath,
sourceRelativePath,
destinationPath,
strategy: options.strategy || 'preserve-relative-path'
})
);
}
return relativeFiles.length;
@ -181,13 +172,15 @@ function addFileCopyOperation(operations, options) {
return false;
}
operations.push(buildCopyFileOperation({
moduleId: options.moduleId,
sourcePath,
sourceRelativePath: options.sourceRelativePath,
destinationPath: options.destinationPath,
strategy: options.strategy || 'preserve-relative-path',
}));
operations.push(
buildCopyFileOperation({
moduleId: options.moduleId,
sourcePath,
sourceRelativePath: options.sourceRelativePath,
destinationPath: options.destinationPath,
strategy: options.strategy || 'preserve-relative-path'
})
);
return true;
}
@ -218,7 +211,7 @@ function addCursorAgentDataScaffoldOperations(operations, options) {
sourceRoot: options.sourceRoot,
sourceRelativePath: path.join('scaffolds', 'cursor', 'ecc-agent-data.json'),
destinationPath: path.join(options.targetRoot, 'ecc-agent-data.json'),
strategy: 'preserve-relative-path',
strategy: 'preserve-relative-path'
});
addFileCopyOperation(operations, {
@ -226,21 +219,17 @@ function addCursorAgentDataScaffoldOperations(operations, options) {
sourceRoot: options.sourceRoot,
sourceRelativePath: path.join('scaffolds', 'cursor', 'rules', 'ecc-agent-data-home.mdc'),
destinationPath: path.join(options.targetRoot, 'rules', 'ecc-agent-data-home.mdc'),
strategy: 'preserve-relative-path',
strategy: 'preserve-relative-path'
});
addJsonMergeOperation(operations, {
moduleId: options.moduleId,
sourceRoot: options.sourceRoot,
sourceRelativePath: path.join('scaffolds', 'cursor', 'hooks.json'),
destinationPath: path.join(options.targetRoot, 'hooks.json'),
destinationPath: path.join(options.targetRoot, 'hooks.json')
});
const cursorSessionHookDeps = [
path.join('scripts', 'hooks', 'cursor-session-env.js'),
path.join('scripts', 'lib', 'agent-data-home.js'),
path.join('scripts', 'lib', 'utils.js'),
];
const cursorSessionHookDeps = [path.join('scripts', 'hooks', 'cursor-session-env.js'), path.join('scripts', 'lib', 'agent-data-home.js'), path.join('scripts', 'lib', 'utils.js')];
for (const sourceRelativePath of cursorSessionHookDeps) {
addFileCopyOperation(operations, {
@ -248,7 +237,7 @@ function addCursorAgentDataScaffoldOperations(operations, options) {
sourceRoot: options.sourceRoot,
sourceRelativePath,
destinationPath: path.join(options.targetRoot, sourceRelativePath),
strategy: 'preserve-relative-path',
strategy: 'preserve-relative-path'
});
}
}
@ -267,7 +256,7 @@ function addJsonMergeOperation(operations, options) {
strategy: 'merge-json',
ownership: 'managed',
scaffoldOnly: false,
mergePayload: readJsonObject(sourcePath, options.sourceRelativePath),
mergePayload: readJsonObject(sourcePath, options.sourceRelativePath)
});
return true;
@ -279,7 +268,8 @@ function addMatchingRuleOperations(operations, options) {
return 0;
}
const files = fs.readdirSync(sourceDir, { withFileTypes: true })
const files = fs
.readdirSync(sourceDir, { withFileTypes: true })
.filter(entry => entry.isFile() && options.matcher(entry.name))
.map(entry => entry.name)
.sort();
@ -287,18 +277,17 @@ function addMatchingRuleOperations(operations, options) {
for (const fileName of files) {
const sourceRelativePath = path.join(options.sourceRelativeDir, fileName);
const sourcePath = path.join(options.sourceRoot, sourceRelativePath);
const destinationPath = path.join(
options.destinationDir,
options.rename ? options.rename(fileName) : fileName
);
const destinationPath = path.join(options.destinationDir, options.rename ? options.rename(fileName) : fileName);
operations.push(buildCopyFileOperation({
moduleId: options.moduleId,
sourcePath,
sourceRelativePath,
destinationPath,
strategy: options.strategy || 'flatten-copy',
}));
operations.push(
buildCopyFileOperation({
moduleId: options.moduleId,
sourcePath,
sourceRelativePath,
destinationPath,
strategy: options.strategy || 'flatten-copy'
})
);
}
return files.length;
@ -324,7 +313,7 @@ function planClaudeStyleLegacyInstall(context, { adapterId, adapterRootInput, ru
moduleId: 'legacy-claude-rules',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('rules', 'common'),
destinationDir: path.join(rulesDir, 'common'),
destinationDir: path.join(rulesDir, 'common')
});
for (const language of context.languages) {
@ -343,7 +332,7 @@ function planClaudeStyleLegacyInstall(context, { adapterId, adapterRootInput, ru
moduleId: 'legacy-claude-rules',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('rules', language),
destinationDir: path.join(rulesDir, language),
destinationDir: path.join(rulesDir, language)
});
}
@ -356,7 +345,7 @@ function planClaudeStyleLegacyInstall(context, { adapterId, adapterRootInput, ru
installStatePath,
operations,
warnings,
selectedModules: ['legacy-claude-rules'],
selectedModules: ['legacy-claude-rules']
};
}
@ -364,7 +353,7 @@ function planClaudeLegacyInstall(context) {
return planClaudeStyleLegacyInstall(context, {
adapterId: 'claude',
adapterRootInput: { homeDir: context.homeDir },
rulesDir: context.claudeRulesDir || null,
rulesDir: context.claudeRulesDir || null
});
}
@ -372,7 +361,7 @@ function planClaudeProjectLegacyInstall(context) {
return planClaudeStyleLegacyInstall(context, {
adapterId: 'claude-project',
adapterRootInput: { repoRoot: context.projectRoot },
rulesDir: null,
rulesDir: null
});
}
@ -388,14 +377,12 @@ function planCursorLegacyInstall(context) {
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'rules'),
destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => /^common-.*\.md$/.test(fileName),
matcher: fileName => /^common-.*\.md$/.test(fileName)
});
for (const language of context.languages) {
if (!LANGUAGE_NAME_PATTERN.test(language)) {
warnings.push(
`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`
);
warnings.push(`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`);
continue;
}
@ -404,7 +391,7 @@ function planCursorLegacyInstall(context) {
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'rules'),
destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => fileName.startsWith(`${language}-`) && fileName.endsWith('.md'),
matcher: fileName => fileName.startsWith(`${language}-`) && fileName.endsWith('.md')
});
if (matches === 0) {
@ -417,44 +404,44 @@ function planCursorLegacyInstall(context) {
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'agents'),
destinationDir: path.join(targetRoot, 'agents'),
destinationRelativePathTransform: toCursorAgentRelativePath,
destinationRelativePathTransform: toCursorAgentRelativePath
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'skills'),
destinationDir: path.join(targetRoot, 'skills'),
destinationDir: path.join(targetRoot, 'skills')
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'commands'),
destinationDir: path.join(targetRoot, 'commands'),
destinationDir: path.join(targetRoot, 'commands')
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'hooks'),
destinationDir: path.join(targetRoot, 'hooks'),
destinationDir: path.join(targetRoot, 'hooks')
});
addFileCopyOperation(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativePath: path.join('.cursor', 'hooks.json'),
destinationPath: path.join(targetRoot, 'hooks.json'),
destinationPath: path.join(targetRoot, 'hooks.json')
});
addJsonMergeOperation(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativePath: '.mcp.json',
destinationPath: path.join(targetRoot, 'mcp.json'),
destinationPath: path.join(targetRoot, 'mcp.json')
});
addCursorAgentDataScaffoldOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
targetRoot,
targetRoot
});
return {
@ -466,7 +453,7 @@ function planCursorLegacyInstall(context) {
installStatePath,
operations,
warnings,
selectedModules: ['legacy-cursor-install'],
selectedModules: ['legacy-cursor-install']
};
}
@ -478,9 +465,7 @@ function planAntigravityLegacyInstall(context) {
const warnings = [];
if (isDirectoryNonEmpty(path.join(targetRoot, 'rules'))) {
warnings.push(
`Destination ${path.join(targetRoot, 'rules')}/ already exists and files may be overwritten`
);
warnings.push(`Destination ${path.join(targetRoot, 'rules')}/ already exists and files may be overwritten`);
}
addMatchingRuleOperations(operations, {
@ -489,14 +474,12 @@ function planAntigravityLegacyInstall(context) {
sourceRelativeDir: path.join('rules', 'common'),
destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => fileName.endsWith('.md'),
rename: fileName => `common-${fileName}`,
rename: fileName => `common-${fileName}`
});
for (const language of context.languages) {
if (!LANGUAGE_NAME_PATTERN.test(language)) {
warnings.push(
`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`
);
warnings.push(`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`);
continue;
}
@ -512,7 +495,7 @@ function planAntigravityLegacyInstall(context) {
sourceRelativeDir: path.join('rules', language),
destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => fileName.endsWith('.md'),
rename: fileName => `${language}-${fileName}`,
rename: fileName => `${language}-${fileName}`
});
}
@ -520,19 +503,19 @@ function planAntigravityLegacyInstall(context) {
moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: 'commands',
destinationDir: path.join(targetRoot, 'workflows'),
destinationDir: path.join(targetRoot, 'workflows')
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: 'agents',
destinationDir: path.join(targetRoot, 'skills'),
destinationDir: path.join(targetRoot, 'skills')
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: 'skills',
destinationDir: path.join(targetRoot, 'skills'),
destinationDir: path.join(targetRoot, 'skills')
});
return {
@ -544,7 +527,7 @@ function planAntigravityLegacyInstall(context) {
installStatePath,
operations,
warnings,
selectedModules: ['legacy-antigravity-install'],
selectedModules: ['legacy-antigravity-install']
};
}
@ -561,7 +544,7 @@ function createLegacyInstallPlan(options = {}) {
projectRoot,
homeDir,
languages: Array.isArray(options.languages) ? options.languages : [],
claudeRulesDir: options.claudeRulesDir || process.env.CLAUDE_RULES_DIR || null,
claudeRulesDir: options.claudeRulesDir || process.env.CLAUDE_RULES_DIR || null
};
let plan;
@ -578,7 +561,7 @@ function createLegacyInstallPlan(options = {}) {
const source = {
repoVersion: getPackageVersion(sourceRoot),
repoCommit: getRepoCommit(sourceRoot),
manifestVersion: getManifestVersion(sourceRoot),
manifestVersion: getManifestVersion(sourceRoot)
};
const statePreview = createStatePreview({
@ -589,14 +572,14 @@ function createLegacyInstallPlan(options = {}) {
profile: null,
modules: [],
legacyLanguages: context.languages,
legacyMode: true,
legacyMode: true
},
resolution: {
selectedModules: plan.selectedModules,
skippedModules: [],
skippedModules: []
},
operations: plan.operations,
source,
source
});
return {
@ -605,7 +588,7 @@ function createLegacyInstallPlan(options = {}) {
adapter: {
id: plan.adapter.id,
target: plan.adapter.target,
kind: plan.adapter.kind,
kind: plan.adapter.kind
},
targetRoot: plan.targetRoot,
installRoot: plan.installRoot,
@ -613,7 +596,7 @@ function createLegacyInstallPlan(options = {}) {
warnings: plan.warnings,
languages: context.languages,
operations: plan.operations,
statePreview,
statePreview
};
}
@ -621,19 +604,15 @@ function createLegacyCompatInstallPlan(options = {}) {
const sourceRoot = options.sourceRoot || getSourceRoot();
const projectRoot = options.projectRoot || process.cwd();
const target = options.target || 'claude';
const includeComponentIds = Array.isArray(options.includeComponentIds)
? [...options.includeComponentIds]
: [];
const excludeComponentIds = Array.isArray(options.excludeComponentIds)
? [...options.excludeComponentIds]
: [];
const includeComponentIds = Array.isArray(options.includeComponentIds) ? [...options.includeComponentIds] : [];
const excludeComponentIds = Array.isArray(options.excludeComponentIds) ? [...options.excludeComponentIds] : [];
validateLegacyTarget(target);
const selection = resolveLegacyCompatibilitySelection({
repoRoot: sourceRoot,
target,
legacyLanguages: options.legacyLanguages || [],
legacyLanguages: options.legacyLanguages || []
});
return createManifestInstallPlan({
@ -651,25 +630,24 @@ function createLegacyCompatInstallPlan(options = {}) {
requestModuleIds: [],
requestIncludeComponentIds: includeComponentIds,
requestExcludeComponentIds: excludeComponentIds,
mode: 'legacy-compat',
mode: 'legacy-compat'
});
}
function materializeScaffoldOperation(sourceRoot, operation) {
if (operation.kind === 'merge-json') {
return [{
kind: 'merge-json',
moduleId: operation.moduleId,
sourceRelativePath: operation.sourceRelativePath,
destinationPath: operation.destinationPath,
strategy: operation.strategy || 'merge-json',
ownership: operation.ownership || 'managed',
scaffoldOnly: Object.hasOwn(operation, 'scaffoldOnly') ? operation.scaffoldOnly : false,
mergePayload: readJsonObject(
path.join(sourceRoot, operation.sourceRelativePath),
operation.sourceRelativePath
),
}];
return [
{
kind: 'merge-json',
moduleId: operation.moduleId,
sourceRelativePath: operation.sourceRelativePath,
destinationPath: operation.destinationPath,
strategy: operation.strategy || 'merge-json',
ownership: operation.ownership || 'managed',
scaffoldOnly: Object.hasOwn(operation, 'scaffoldOnly') ? operation.scaffoldOnly : false,
mergePayload: readJsonObject(path.join(sourceRoot, operation.sourceRelativePath), operation.sourceRelativePath)
}
];
}
const sourcePath = path.join(sourceRoot, operation.sourceRelativePath);
@ -683,13 +661,15 @@ function materializeScaffoldOperation(sourceRoot, operation) {
const stat = fs.statSync(sourcePath);
if (stat.isFile()) {
return [buildCopyFileOperation({
moduleId: operation.moduleId,
sourcePath,
sourceRelativePath: operation.sourceRelativePath,
destinationPath: operation.destinationPath,
strategy: operation.strategy,
})];
return [
buildCopyFileOperation({
moduleId: operation.moduleId,
sourcePath,
sourceRelativePath: operation.sourceRelativePath,
destinationPath: operation.destinationPath,
strategy: operation.strategy
})
];
}
const relativeFiles = listFilesRecursive(sourcePath).filter(relativeFile => {
@ -703,7 +683,7 @@ function materializeScaffoldOperation(sourceRoot, operation) {
sourcePath: path.join(sourcePath, relativeFile),
sourceRelativePath,
destinationPath: path.join(operation.destinationPath, relativeFile),
strategy: operation.strategy,
strategy: operation.strategy
});
});
}
@ -712,21 +692,19 @@ function createManifestInstallPlan(options = {}) {
const sourceRoot = options.sourceRoot || getSourceRoot();
const projectRoot = options.projectRoot || process.cwd();
const target = options.target || 'claude';
const legacyLanguages = Array.isArray(options.legacyLanguages)
? [...options.legacyLanguages]
: [];
const requestProfileId = Object.hasOwn(options, 'requestProfileId')
? options.requestProfileId
: (options.profileId || null);
const requestModuleIds = Object.hasOwn(options, 'requestModuleIds')
? [...options.requestModuleIds]
: (Array.isArray(options.moduleIds) ? [...options.moduleIds] : []);
const legacyLanguages = Array.isArray(options.legacyLanguages) ? [...options.legacyLanguages] : [];
const requestProfileId = Object.hasOwn(options, 'requestProfileId') ? options.requestProfileId : options.profileId || null;
const requestModuleIds = Object.hasOwn(options, 'requestModuleIds') ? [...options.requestModuleIds] : Array.isArray(options.moduleIds) ? [...options.moduleIds] : [];
const requestIncludeComponentIds = Object.hasOwn(options, 'requestIncludeComponentIds')
? [...options.requestIncludeComponentIds]
: (Array.isArray(options.includeComponentIds) ? [...options.includeComponentIds] : []);
: Array.isArray(options.includeComponentIds)
? [...options.includeComponentIds]
: [];
const requestExcludeComponentIds = Object.hasOwn(options, 'requestExcludeComponentIds')
? [...options.requestExcludeComponentIds]
: (Array.isArray(options.excludeComponentIds) ? [...options.excludeComponentIds] : []);
: Array.isArray(options.excludeComponentIds)
? [...options.excludeComponentIds]
: [];
const plan = resolveInstallPlan({
repoRoot: sourceRoot,
projectRoot,
@ -735,14 +713,14 @@ function createManifestInstallPlan(options = {}) {
moduleIds: options.moduleIds || [],
includeComponentIds: options.includeComponentIds || [],
excludeComponentIds: options.excludeComponentIds || [],
target,
target
});
const adapter = getInstallTargetAdapter(target);
const operations = plan.operations.flatMap(operation => materializeScaffoldOperation(sourceRoot, operation));
const source = {
repoVersion: getPackageVersion(sourceRoot),
repoCommit: getRepoCommit(sourceRoot),
manifestVersion: getManifestVersion(sourceRoot),
manifestVersion: getManifestVersion(sourceRoot)
};
const statePreview = createStatePreview({
adapter,
@ -754,14 +732,14 @@ function createManifestInstallPlan(options = {}) {
includeComponents: requestIncludeComponentIds,
excludeComponents: requestExcludeComponentIds,
legacyLanguages,
legacyMode: Boolean(options.legacyMode),
legacyMode: Boolean(options.legacyMode)
},
resolution: {
selectedModules: plan.selectedModuleIds,
skippedModules: plan.skippedModuleIds,
skippedModules: plan.skippedModuleIds
},
operations,
source,
source
});
return {
@ -770,7 +748,7 @@ function createManifestInstallPlan(options = {}) {
adapter: {
id: adapter.id,
target: adapter.target,
kind: adapter.kind,
kind: adapter.kind
},
targetRoot: plan.targetRoot,
installRoot: plan.targetRoot,
@ -787,7 +765,7 @@ function createManifestInstallPlan(options = {}) {
skippedModuleIds: plan.skippedModuleIds,
excludedModuleIds: plan.excludedModuleIds,
operations,
statePreview,
statePreview
};
}
@ -800,5 +778,5 @@ module.exports = {
createLegacyInstallPlan,
getSourceRoot,
listAvailableLanguages,
parseInstallArgs,
parseInstallArgs
};

View File

@ -98,9 +98,7 @@ function readLatestContextTokens(transcriptPath, options = {}) {
return null;
}
const tailBytes = Number.isInteger(options.tailBytes) && options.tailBytes > 0
? options.tailBytes
: DEFAULT_TRANSCRIPT_TAIL_BYTES;
const tailBytes = Number.isInteger(options.tailBytes) && options.tailBytes > 0 ? options.tailBytes : DEFAULT_TRANSCRIPT_TAIL_BYTES;
const tail = readFileTail(transcriptPath, tailBytes);
if (!tail) {
@ -124,9 +122,7 @@ function readLatestContextTokens(transcriptPath, options = {}) {
const tokens = extractUsageTokens(record);
if (tokens > 0) {
const model = record.message && typeof record.message.model === 'string'
? record.message.model
: '';
const model = record.message && typeof record.message.model === 'string' ? record.message.model : '';
return { tokens, model };
}
}
@ -141,6 +137,15 @@ function readLatestContextTokens(transcriptPath, options = {}) {
* suffix); otherwise the standard 200k window.
*/
function resolveContextWindowTokens(tokens, model) {
// Explicit window override wins: 400k models (e.g. Opus 4.x) match neither the
// 200k default nor the 1M marker and would otherwise report ~double usage (#2290).
// Honor ECC's own knob and Claude Code's native CLAUDE_CODE_AUTO_COMPACT_WINDOW.
const env = (typeof process !== 'undefined' && process.env) || {};
const envWindow = Number.parseInt(env.ECC_CONTEXT_WINDOW_TOKENS || env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '', 10);
if (Number.isInteger(envWindow) && envWindow > 0) {
return envWindow;
}
if (typeof model === 'string' && model.includes(LARGE_WINDOW_MODEL_MARKER)) {
return LARGE_CONTEXT_WINDOW_TOKENS;
}
@ -169,9 +174,7 @@ function resolveContextThreshold(env, windowTokens) {
}
}
return windowTokens >= LARGE_CONTEXT_WINDOW_TOKENS
? DEFAULT_CONTEXT_THRESHOLD_LARGE
: DEFAULT_CONTEXT_THRESHOLD_STANDARD;
return windowTokens >= LARGE_CONTEXT_WINDOW_TOKENS ? DEFAULT_CONTEXT_THRESHOLD_LARGE : DEFAULT_CONTEXT_THRESHOLD_STANDARD;
}
/**
@ -181,9 +184,7 @@ function resolveContextThreshold(env, windowTokens) {
function resolveContextInterval(env) {
const raw = env && env.COMPACT_CONTEXT_INTERVAL;
const parsed = Number.parseInt(raw, 10);
return Number.isInteger(parsed) && parsed > 0 && parsed <= MAX_TOKEN_SETTING
? parsed
: DEFAULT_CONTEXT_INTERVAL_TOKENS;
return Number.isInteger(parsed) && parsed > 0 && parsed <= MAX_TOKEN_SETTING ? parsed : DEFAULT_CONTEXT_INTERVAL_TOKENS;
}
/**
@ -205,9 +206,7 @@ function computeContextBucket(tokens, threshold, interval) {
* Human-readable label for a context window size (e.g. "200k", "1M").
*/
function formatWindowLabel(windowTokens) {
return windowTokens >= LARGE_CONTEXT_WINDOW_TOKENS
? '1M'
: `${Math.round(windowTokens / 1000)}k`;
return windowTokens >= LARGE_CONTEXT_WINDOW_TOKENS ? '1M' : `${Math.round(windowTokens / 1000)}k`;
}
module.exports = {

View File

@ -1,148 +1,97 @@
---
name: cost-tracking
description: Track and report Claude Code token usage, spending, and budgets from a local cost-tracking database. Use when the user asks about costs, spending, usage, tokens, budgets, or cost breakdowns by project, tool, session, or date.
description: Track and report Claude Code token usage, spending, and budgets from the local ECC cost-tracker metrics log. Use when the user asks about costs, spending, usage, tokens, budgets, or cost breakdowns by model, session, or date.
metadata:
origin: community
---
# Cost Tracking
Use this skill to analyze Claude Code cost and usage history from a local SQLite
database. It is intended for users who already have a cost-tracking hook or
plugin writing usage rows to `~/.claude-cost-tracker/usage.db`.
Use this skill to analyze Claude Code cost and usage history from the metrics log
that ECC's `stop:cost-tracker` hook writes.
Source: salvaged from stale community PR #1304 by `MayurBhavsar`.
## Where the data lives
The tracker appends one JSON object per session-stop to
`~/.claude/metrics/costs.jsonl`. Each row is a **cumulative snapshot for that
session**, so to total spend you take the **latest row per `session_id`** and
sum across sessions — summing every row multiply-counts.
Row schema:
| Field | Meaning |
| --- | --- |
| `timestamp` | ISO timestamp of the snapshot |
| `session_id` | Claude Code session identifier |
| `transcript_path` | Path to the session transcript |
| `model` | Model used |
| `input_tokens` / `output_tokens` | Token counts |
| `cache_write_tokens` / `cache_read_tokens` | Prompt-cache token counts |
| `estimated_cost_usd` | Precomputed cumulative cost in USD for the session |
Prefer `estimated_cost_usd` over hand-calculating pricing — model and cache
prices change, and the tracker is the source of truth.
## When to Use
- The user asks "how much have I spent?", "what did this session cost?", or
"what is my token usage?"
- The user mentions budgets, spending limits, overruns, or cost controls.
- The user wants a cost breakdown by project, tool, session, model, or date.
- The user wants to compare today against yesterday or inspect a recent trend.
- The user asks for a CSV export of recent usage records.
- The user wants a cost breakdown by model, session, or date, or a CSV export.
## How It Works
First verify prerequisites:
First verify the log exists (use `node`, not `sqlite3` — the tracker writes
JSONL, and `node` is cross-platform):
```bash
command -v sqlite3 >/dev/null && echo "sqlite3 available" || echo "sqlite3 missing"
test -f ~/.claude-cost-tracker/usage.db && echo "Database found" || echo "Database not found"
node -e 'const fs=require("fs"),os=require("os"),p=require("path");const f=p.join(os.homedir(),".claude","metrics","costs.jsonl");console.log(fs.existsSync(f)?"cost log found":"cost log not found: "+f)'
```
If the database is missing, do not fabricate usage data. Tell the user that cost
tracking is not configured and suggest installing or enabling a trusted local
cost-tracking hook/plugin.
If the log is missing, do not fabricate usage data. Tell the user that cost
tracking populates after the first session ends with the `stop:cost-tracker`
hook enabled.
The expected `usage` table usually contains one row per tool call or model
interaction. Column names vary by tracker, but the examples below assume:
| Column | Meaning |
| --- | --- |
| `timestamp` | ISO timestamp for the usage event |
| `project` | Project or repository name |
| `tool_name` | Tool or event name |
| `input_tokens` | Input token count, when recorded |
| `output_tokens` | Output token count, when recorded |
| `cost_usd` | Precomputed cost in USD |
| `session_id` | Claude Code session identifier |
| `model` | Model used for the event |
Prefer `cost_usd` over hand-calculating pricing. Model prices and cache pricing
change over time, and the tracker should be the source of truth for how each row
was priced.
## Examples
### Quick Summary
## Example — summary, by model, last 7 days
```bash
sqlite3 ~/.claude-cost-tracker/usage.db "
SELECT
'Today: $' || ROUND(COALESCE(SUM(CASE WHEN date(timestamp) = date('now') THEN cost_usd END), 0), 4) ||
' | Total: $' || ROUND(COALESCE(SUM(cost_usd), 0), 4) ||
' | Calls: ' || COUNT(*) ||
' | Sessions: ' || COUNT(DISTINCT session_id)
FROM usage;
"
node -e '
const fs=require("fs"),os=require("os"),path=require("path");
const f=path.join(os.homedir(),".claude","metrics","costs.jsonl");
if(!fs.existsSync(f)){console.log("cost log not found: "+f);process.exit(0);}
const rows=fs.readFileSync(f,"utf8").split(/\r?\n/).filter(Boolean).map(l=>{try{return JSON.parse(l)}catch{return null}}).filter(Boolean);
const bySession=new Map();
for(const r of rows){const k=r.session_id||r.transcript_path||r.timestamp;const p=bySession.get(k);if(!p||String(r.timestamp)>String(p.timestamp))bySession.set(k,r);}
const latest=[...bySession.values()];
const cost=r=>Number(r.estimated_cost_usd)||0, day=r=>String(r.timestamp||"").slice(0,10), sum=a=>a.reduce((s,r)=>s+cost(r),0), f4=n=>"$"+n.toFixed(4);
const today=new Date().toISOString().slice(0,10), yest=new Date(Date.now()-864e5).toISOString().slice(0,10);
console.log("today: "+f4(sum(latest.filter(r=>day(r)===today)))+" | yesterday: "+f4(sum(latest.filter(r=>day(r)===yest)))+" | total: "+f4(sum(latest))+" ("+latest.length+" sessions)");
const m=new Map();for(const r of latest){const k=r.model||"(unknown)";m.set(k,(m.get(k)||0)+cost(r));}
console.log("by model:");[...m.entries()].sort((a,b)=>b[1]-a[1]).forEach(([k,v])=>console.log(" "+f4(v)+" "+k));
'
```
### Cost By Project
```bash
sqlite3 -header -column ~/.claude-cost-tracker/usage.db "
SELECT project, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls
FROM usage
GROUP BY project
ORDER BY cost DESC;
"
```
### Cost By Tool
```bash
sqlite3 -header -column ~/.claude-cost-tracker/usage.db "
SELECT tool_name, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls
FROM usage
GROUP BY tool_name
ORDER BY cost DESC;
"
```
### Last Seven Days
```bash
sqlite3 -header -column ~/.claude-cost-tracker/usage.db "
SELECT date(timestamp) AS date, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls
FROM usage
GROUP BY date(timestamp)
ORDER BY date DESC
LIMIT 7;
"
```
### Session Drilldown
```bash
sqlite3 -header -column ~/.claude-cost-tracker/usage.db "
SELECT session_id,
MIN(timestamp) AS started,
MAX(timestamp) AS ended,
ROUND(SUM(cost_usd), 4) AS cost,
COUNT(*) AS calls
FROM usage
GROUP BY session_id
ORDER BY started DESC
LIMIT 10;
"
```
For a session drilldown or CSV export, iterate the same `latest` set (or the raw
rows for CSV) and print the fields you need.
## Reporting Guidance
When presenting cost data, include:
1. Today's spend and yesterday comparison.
2. Total spend across the tracked database.
3. Top projects ranked by cost.
4. Top tools ranked by cost.
5. Session count and average cost per session when enough data exists.
For small amounts, format currency with four decimal places. For larger amounts,
two decimals are enough.
When presenting cost data, include today's spend vs yesterday, total across all
sessions, a by-model breakdown, and session count. Format sub-dollar amounts
with four decimals, larger amounts with two.
## Anti-Patterns
- Do not estimate costs from raw token counts when `cost_usd` is present.
- Do not assume the database exists without checking.
- Do not run unbounded `SELECT *` exports on large databases.
- Do not sum every row — they are cumulative per session; reduce to the latest
row per `session_id` first.
- Do not estimate costs from raw token counts when `estimated_cost_usd` is present.
- Do not assume the log exists without checking.
- Do not hard-code current model pricing in user-facing answers.
- Do not recommend installing unreviewed hooks or plugins that execute arbitrary
code.
- Do not recommend installing unreviewed hooks or plugins that execute arbitrary code.
## Related
- `/cost-report` - Command-form report using the same database.
- `/cost-report` - Command-form report over the same metrics log.
- `cost-aware-llm-pipeline` - Model-routing and budget-design patterns.
- `token-budget-advisor` - Context and token-budget planning.
- `strategic-compact` - Context compaction to reduce repeated token spend.

View File

@ -78,47 +78,32 @@ function tracked(filePath) {
console.log('readLatestContextTokens:');
test('sums input + cache_read + cache_creation from the latest usage record', () => {
const file = tracked(writeTranscript([
usageRecord({ input: 10, cacheRead: 20, cacheCreation: 5 }),
usageRecord({ input: 100, cacheRead: 150000, cacheCreation: 7000 })
]));
const file = tracked(writeTranscript([usageRecord({ input: 10, cacheRead: 20, cacheCreation: 5 }), usageRecord({ input: 100, cacheRead: 150000, cacheCreation: 7000 })]));
const result = readLatestContextTokens(file);
assert.ok(result, 'Expected a usage result');
assert.strictEqual(result.tokens, 157100);
});
test('returns the model id alongside the token count', () => {
const file = tracked(writeTranscript([
usageRecord({ input: 1000 }, 'claude-opus-4-5[1m]')
]));
const file = tracked(writeTranscript([usageRecord({ input: 1000 }, 'claude-opus-4-5[1m]')]));
const result = readLatestContextTokens(file);
assert.strictEqual(result.model, 'claude-opus-4-5[1m]');
});
test('skips trailing records without usage (e.g. tool results)', () => {
const file = tracked(writeTranscript([
usageRecord({ input: 5000 }),
JSON.stringify({ type: 'user', message: { content: 'tool result' } }),
JSON.stringify({ type: 'system', subtype: 'info' })
]));
const file = tracked(writeTranscript([usageRecord({ input: 5000 }), JSON.stringify({ type: 'user', message: { content: 'tool result' } }), JSON.stringify({ type: 'system', subtype: 'info' })]));
const result = readLatestContextTokens(file);
assert.strictEqual(result.tokens, 5000);
});
test('skips malformed JSONL lines without throwing', () => {
const file = tracked(writeTranscript([
usageRecord({ input: 4200 }),
'{not json at all',
''
]));
const file = tracked(writeTranscript([usageRecord({ input: 4200 }), '{not json at all', '']));
const result = readLatestContextTokens(file);
assert.strictEqual(result.tokens, 4200);
});
test('returns null for a transcript with no usage records', () => {
const file = tracked(writeTranscript([
JSON.stringify({ type: 'user', message: { content: 'hello' } })
]));
const file = tracked(writeTranscript([JSON.stringify({ type: 'user', message: { content: 'hello' } })]));
assert.strictEqual(readLatestContextTokens(file), null);
});
@ -132,10 +117,7 @@ test('returns null for empty or non-string paths', () => {
});
test('ignores zero-token usage records', () => {
const file = tracked(writeTranscript([
usageRecord({ input: 999 }),
usageRecord({ input: 0 })
]));
const file = tracked(writeTranscript([usageRecord({ input: 999 }), usageRecord({ input: 0 })]));
const result = readLatestContextTokens(file);
assert.strictEqual(result.tokens, 999);
});
@ -154,10 +136,42 @@ test('only scans the transcript tail (latest records win on large files)', () =>
// ── resolveContextWindowTokens ──
console.log('\nresolveContextWindowTokens:');
// Isolation: an env-set window override (either knob) otherwise leaks into the
// default-window assertions below and fails them (#2290).
delete process.env.ECC_CONTEXT_WINDOW_TOKENS;
delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
test('defaults to the standard 200k window', () => {
assert.strictEqual(resolveContextWindowTokens(50000, 'claude-sonnet-4-6'), STANDARD_CONTEXT_WINDOW_TOKENS);
});
test('honors an explicit ECC_CONTEXT_WINDOW_TOKENS override (e.g. 400k models, #2290)', () => {
process.env.ECC_CONTEXT_WINDOW_TOKENS = '400000';
try {
assert.strictEqual(resolveContextWindowTokens(50000, 'claude-opus-4-x'), 400000);
} finally {
delete process.env.ECC_CONTEXT_WINDOW_TOKENS;
}
});
test('honors Claude Code native CLAUDE_CODE_AUTO_COMPACT_WINDOW override', () => {
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '400000';
try {
assert.strictEqual(resolveContextWindowTokens(50000, 'claude-opus-4-x'), 400000);
} finally {
delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
}
});
test('ignores a non-positive / invalid window override', () => {
process.env.ECC_CONTEXT_WINDOW_TOKENS = 'not-a-number';
try {
assert.strictEqual(resolveContextWindowTokens(50000, 'claude-sonnet-4-6'), STANDARD_CONTEXT_WINDOW_TOKENS);
} finally {
delete process.env.ECC_CONTEXT_WINDOW_TOKENS;
}
});
test('detects a 1M window from the [1m] model marker', () => {
assert.strictEqual(resolveContextWindowTokens(50000, 'claude-opus-4-5[1m]'), LARGE_CONTEXT_WINDOW_TOKENS);
});
@ -191,11 +205,7 @@ test('COMPACT_CONTEXT_THRESHOLD=0 disables the signal', () => {
test('invalid COMPACT_CONTEXT_THRESHOLD falls back to the default', () => {
for (const bad of ['-5', 'abc', '99999999999']) {
assert.strictEqual(
resolveContextThreshold({ COMPACT_CONTEXT_THRESHOLD: bad }, STANDARD_CONTEXT_WINDOW_TOKENS),
DEFAULT_CONTEXT_THRESHOLD_STANDARD,
`Expected fallback for ${bad}`
);
assert.strictEqual(resolveContextThreshold({ COMPACT_CONTEXT_THRESHOLD: bad }, STANDARD_CONTEXT_WINDOW_TOKENS), DEFAULT_CONTEXT_THRESHOLD_STANDARD, `Expected fallback for ${bad}`);
}
});