Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to `@relayburn/cli`.

## [Unreleased]

### Added

- `burn summary` now accepts `--tag k=v` to filter turns by stamped enrichment values (multiple `--tag` flags AND together) and `--by-tag <key>` to group rows by an enrichment value (turns missing the key bucket as `(unset)`). JSON output emits `byTag: { key, rows: [{ value, turns, usage, cost }] }` and the per-cell fidelity block uses `groupBy: 'tag'`.

## [1.10.0] - 2026-05-03

### Changed
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ function getVersion(): string {
const HELP = `burn — token usage & cost attribution for agent CLIs

Usage:
burn summary [--since 7d] [--project <path>] [--session <id>] [--workflow <id>] [--agent <id>] [--provider <p>] [--quality]
[--by-provider | --by-tool | --by-subagent-type | --by-relationship[=subagent] | --subagent-tree <session-id>] [--no-archive]
(mode flags are mutually exclusive; --by-tool emits tool | calls | attributedCost)
burn summary [--since 7d] [--project <path>] [--session <id>] [--workflow <id>] [--agent <id>] [--provider <p>] [--tag k=v ...] [--quality]
[--by-provider | --by-tool | --by-subagent-type | --by-relationship[=subagent] | --by-tag <key> | --subagent-tree <session-id>] [--no-archive]
(mode flags are mutually exclusive; --by-tool emits tool | calls | attributedCost; --by-tag groups by enrichment value)
burn hotspots [--since 7d] [--project <path>] [--workflow <id>] [--provider <p>] [--all] [--json]
[--session [id]] [--explain-drift]
[--patterns[=retries,failures,compaction,reverts]] [--findings]
Expand All @@ -41,6 +41,8 @@ Examples:
burn summary --by-subagent-type --since 7d
burn summary --by-relationship --since 7d
burn summary --by-tool --since 7d
burn summary --tag workflow=refactor --since 7d
burn summary --by-tag workflow --since 30d
burn hotspots --since 7d
burn hotspots --patterns --since 7d
burn hotspots --session --explain-drift
Expand Down
88 changes: 87 additions & 1 deletion packages/cli/src/commands/summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ interface CapturedOutput {

async function captureSummary(
flags: Record<string, string | true> = {},
tags: Record<string, string> = {},
): Promise<CapturedOutput> {
const origStdout = process.stdout.write.bind(process.stdout);
const origStderr = process.stderr.write.bind(process.stderr);
Expand All @@ -94,7 +95,7 @@ async function captureSummary(
}) as typeof process.stderr.write;
let code: number;
try {
code = await runSummary({ flags, tags: {}, positional: [], passthrough: [] });
code = await runSummary({ flags, tags, positional: [], passthrough: [] });
} finally {
process.stdout.write = origStdout;
process.stderr.write = origStderr;
Expand Down Expand Up @@ -1349,4 +1350,89 @@ describe('burn summary replacement-tool savings (#219)', () => {
assert.equal(bash!.savings, undefined);
assert.ok(parsed.replacementSavings!.estimatedTokensSaved > 0);
});

it('--tag k=v filters turns by stamped enrichment value', async () => {
await appendTurns([
fakeTurn({ sessionId: 'tag-A', messageId: 'tag-a-1' }),
fakeTurn({
sessionId: 'tag-B',
messageId: 'tag-b-1',
ts: '2026-04-20T00:01:00.000Z',
}),
]);
await stamp({ sessionId: 'tag-A' }, { team: 'platform' });
await stamp({ sessionId: 'tag-B' }, { team: 'data' });

const out = await captureSummary({ json: true }, { team: 'platform' });
assert.equal(out.code, 0);
const payload = JSON.parse(out.stdout) as {
turns: number;
byModel: Array<{ model: string; turns: number }>;
};
assert.equal(payload.turns, 1);
assert.equal(payload.byModel.reduce((s, r) => s + r.turns, 0), 1);
});

it('--by-tag groups turns by enrichment value and surfaces (unset)', async () => {
await appendTurns([
fakeTurn({ sessionId: 'bg-A', messageId: 'bg-a-1' }),
fakeTurn({
sessionId: 'bg-A',
messageId: 'bg-a-2',
turnIndex: 1,
ts: '2026-04-20T00:01:00.000Z',
}),
fakeTurn({
sessionId: 'bg-B',
messageId: 'bg-b-1',
ts: '2026-04-20T00:02:00.000Z',
}),
fakeTurn({
sessionId: 'bg-C',
messageId: 'bg-c-1',
ts: '2026-04-20T00:03:00.000Z',
}),
]);
await stamp({ sessionId: 'bg-A' }, { workflow: 'refactor' });
await stamp({ sessionId: 'bg-B' }, { workflow: 'review' });
// bg-C left unstamped → bucket as `(unset)`.

const out = await captureSummary({ 'by-tag': 'workflow', json: true });
assert.equal(out.code, 0);
const payload = JSON.parse(out.stdout) as {
turns: number;
byTag: { key: string; rows: Array<{ value: string; turns: number }> };
fidelity: { perCell: { groupBy: string } };
};
assert.equal(payload.turns, 4);
assert.equal(payload.byTag.key, 'workflow');
assert.equal(payload.fidelity.perCell.groupBy, 'tag');
const byValue = new Map(payload.byTag.rows.map((r) => [r.value, r.turns]));
assert.equal(byValue.get('refactor'), 2);
assert.equal(byValue.get('review'), 1);
assert.equal(byValue.get('(unset)'), 1);
});

it('--by-tag without a key exits non-zero', async () => {
const out = await captureSummary({ 'by-tag': true });
assert.equal(out.code, 2);
assert.match(out.stderr, /--by-tag requires a tag key/);
});

it('--by-tag combined with --by-tool exits non-zero', async () => {
const out = await captureSummary({ 'by-tag': 'workflow', 'by-tool': true });
assert.equal(out.code, 2);
assert.match(out.stderr, /--by-tag cannot be combined with/);
});

it('--by-tag text mode emits a tag:<key> column header', async () => {
await appendTurns([
fakeTurn({ sessionId: 'btt-A', messageId: 'btt-a-1' }),
]);
await stamp({ sessionId: 'btt-A' }, { team: 'platform' });
const out = await captureSummary({ 'by-tag': 'team' });
assert.equal(out.code, 0);
assert.match(out.stdout, /tag:team/);
assert.match(out.stdout, /platform/);
});
});
85 changes: 58 additions & 27 deletions packages/cli/src/commands/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ export async function runSummary(args: ParsedArgs): Promise<number> {
if (typeof args.flags['since'] === 'string') q.since = parseSinceArg(args.flags['since']);
if (typeof args.flags['project'] === 'string') q.project = args.flags['project'];
if (typeof args.flags['session'] === 'string') q.sessionId = args.flags['session'];
if (typeof args.flags['workflow'] === 'string') q.enrichment = { workflowId: args.flags['workflow'] };
// `--workflow` is a sugar alias over the workflowId tag; --tag k=v entries
// ride alongside it. Explicit --workflow wins on conflict.
const enrichmentFilter: Record<string, string> = { ...args.tags };
if (typeof args.flags['workflow'] === 'string') enrichmentFilter['workflowId'] = args.flags['workflow'];
if (Object.keys(enrichmentFilter).length > 0) q.enrichment = enrichmentFilter;
const agentFilter = typeof args.flags['agent'] === 'string' ? args.flags['agent'] : undefined;
const providerFilter = parseProviderFilter(args.flags['provider']);
if (providerFilter instanceof Error) {
Expand All @@ -66,6 +70,12 @@ export async function runSummary(args: ParsedArgs): Promise<number> {
const byRelationship = relationshipFlag !== undefined;
const byProvider = args.flags['by-provider'] === true;
const byTool = args.flags['by-tool'] === true;
const byTagFlag = args.flags['by-tag'];
const byTagKey = typeof byTagFlag === 'string' ? byTagFlag : undefined;
if (byTagFlag === true) {
process.stderr.write('burn: --by-tag requires a tag key (e.g. --by-tag workflow)\n');
return 2;
}
Comment on lines +73 to +78
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject empty --by-tag keys, not just bare boolean form.

Line 75 only catches --by-tag (boolean). --by-tag= (empty string) can slip through and should also return code 2 per the command contract.

🔧 Suggested fix
   const byTagFlag = args.flags['by-tag'];
-  const byTagKey = typeof byTagFlag === 'string' ? byTagFlag : undefined;
-  if (byTagFlag === true) {
+  const byTagKey = typeof byTagFlag === 'string' ? byTagFlag.trim() : undefined;
+  if (byTagFlag === true || (typeof byTagFlag === 'string' && byTagKey.length === 0)) {
     process.stderr.write('burn: --by-tag requires a tag key (e.g. --by-tag workflow)\n');
     return 2;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const byTagFlag = args.flags['by-tag'];
const byTagKey = typeof byTagFlag === 'string' ? byTagFlag : undefined;
if (byTagFlag === true) {
process.stderr.write('burn: --by-tag requires a tag key (e.g. --by-tag workflow)\n');
return 2;
}
const byTagFlag = args.flags['by-tag'];
const byTagKey = typeof byTagFlag === 'string' ? byTagFlag.trim() : undefined;
if (byTagFlag === true || (typeof byTagFlag === 'string' && byTagKey.length === 0)) {
process.stderr.write('burn: --by-tag requires a tag key (e.g. --by-tag workflow)\n');
return 2;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/summary.ts` around lines 73 - 78, The check only
rejects the boolean form of --by-tag but allows an empty string (--by-tag=) to
pass; update the handling around byTagFlag/byTagKey so that byTagKey is set only
when byTagFlag is a non-empty string, and treat an empty string the same as the
boolean true: write the same error to stderr and return 2. Specifically, change
the logic that assigns byTagKey and the subsequent if (byTagFlag === true)
branch to detect typeof byTagFlag === 'string' && byTagFlag.length > 0 for a
valid key, and otherwise print "burn: --by-tag requires a tag key..." and return
2 when byTagFlag is true or an empty string.

if (
relationshipFlag !== undefined &&
relationshipFlag !== true &&
Expand All @@ -79,6 +89,15 @@ export async function runSummary(args: ParsedArgs): Promise<number> {
// them silently would surprise the caller (we'd pick one and drop the rest).
// Subagent flags already implicitly assume one-axis-at-a-time; --by-tool
// makes that explicit.
if (
byTagKey !== undefined &&
(byTool || byProvider || subagentTypeFlag || byRelationship || subagentTreeFlag !== undefined)
) {
process.stderr.write(
'burn: --by-tag cannot be combined with --by-tool/--by-provider/--by-subagent-type/--by-relationship/--subagent-tree\n',
);
return 2;
}
if (
byTool &&
(byProvider || subagentTypeFlag || byRelationship || subagentTreeFlag !== undefined)
Expand Down Expand Up @@ -155,9 +174,16 @@ export async function runSummary(args: ParsedArgs): Promise<number> {
return renderByToolMode(args, ingestReport, turns, pricing);
}

const rows = byProvider
? aggregateByProvider(turns, { pricing })
: aggregateByModel(turns, pricing);
const rows = byTagKey !== undefined
? aggregateByTag(turns, pricing, byTagKey)
: byProvider
? aggregateByProvider(turns, { pricing })
: aggregateByModel(turns, pricing);
const groupAxis: 'provider' | 'model' | 'tag' = byTagKey !== undefined
? 'tag'
: byProvider
? 'provider'
: 'model';
const totalCost = sumCosts(rows.map((r) => r.cost));
const fidelity = summarizeFidelity(turns);
const replacementSavings = summarizeReplacementSavings(turns);
Expand All @@ -167,33 +193,27 @@ export async function runSummary(args: ParsedArgs): Promise<number> {
// companion `fidelity` block is the only honest answer to "are these
// zeros real?". Programmatic consumers should consult `summary` (the
// slice-wide rollup, same shape compare/hotspots emit) and
// `perCell` (per-(model|provider) per-field known/missing counts) before
// trusting any aggregate.
const perCell = buildPerCellFidelity(rows, byProvider ? 'provider' : 'model');
// `perCell` (per-(model|provider|tag) per-field known/missing counts)
// before trusting any aggregate.
const perCell = buildPerCellFidelity(rows, groupAxis);
const groupedRows = rows.map((r) => ({
...(groupAxis === 'tag' ? { value: r.label } : groupAxis === 'provider' ? { provider: r.label } : { model: r.label }),
turns: r.turns,
usage: r.usage,
cost: r.cost,
}));
const payload = {
ingest: {
ingestedSessions: ingestReport.ingestedSessions,
appendedTurns: ingestReport.appendedTurns,
},
turns: turns.length,
totalCost,
...(byProvider
? {
byProvider: rows.map((r) => ({
provider: r.label,
turns: r.turns,
usage: r.usage,
cost: r.cost,
})),
}
: {
byModel: rows.map((r) => ({
model: r.label,
turns: r.turns,
usage: r.usage,
cost: r.cost,
})),
}),
...(groupAxis === 'tag'
? { byTag: { key: byTagKey!, rows: groupedRows } }
: groupAxis === 'provider'
? { byProvider: groupedRows }
: { byModel: groupedRows }),
fidelity: { summary: fidelity, perCell },
...(replacementSavings.calls > 0
? { replacementSavings: replacementSavingsToJson(replacementSavings) }
Expand All @@ -218,7 +238,8 @@ export async function runSummary(args: ParsedArgs): Promise<number> {
return 0;
}

const header = [byProvider ? 'provider' : 'model', 'turns', 'input', 'output', 'reasoning', 'cacheRead', 'cacheCreate', 'cost'];
const groupHeader = groupAxis === 'tag' ? `tag:${byTagKey}` : groupAxis;
const header = [groupHeader, 'turns', 'input', 'output', 'reasoning', 'cacheRead', 'cacheCreate', 'cost'];
const dataRows: string[][] = [header];
let anyPartialCell = false;
for (const r of rows) {
Expand Down Expand Up @@ -1046,7 +1067,7 @@ interface PerCellFidelityEntry {
}

interface PerCellFidelityBlock {
groupBy: 'model' | 'provider';
groupBy: 'model' | 'provider' | 'tag';
cells: PerCellFidelityEntry[];
}

Expand All @@ -1057,7 +1078,7 @@ interface PerCellFidelityBlock {
// payload — callers should treat it the same as "every cell is full".
function buildPerCellFidelity(
rows: ReadonlyArray<ModelRow>,
groupBy: 'model' | 'provider',
groupBy: 'model' | 'provider' | 'tag',
): PerCellFidelityBlock {
const cells: PerCellFidelityEntry[] = rows.map((r) => {
const cacheCreate = mergeCoverage(r.coverage.cacheCreate);
Expand Down Expand Up @@ -1214,6 +1235,16 @@ function aggregateByModel(turns: EnrichedTurn[], pricing: Parameters<typeof cost
return aggregateTurns(turns, pricing, (t) => t.model || 'unknown');
}

const TAG_UNSET_LABEL = '(unset)';

function aggregateByTag(
turns: EnrichedTurn[],
pricing: Parameters<typeof costForTurn>[1],
key: string,
): ModelRow[] {
return aggregateTurns(turns, pricing, (t) => t.enrichment[key] ?? TAG_UNSET_LABEL);
}

function aggregateTurns(
turns: EnrichedTurn[],
pricing: Parameters<typeof costForTurn>[1],
Expand Down
Loading