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
26 changes: 22 additions & 4 deletions apps/cli/src/commands/gain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface GainOptions {
sessionLimit?: string;
inputCostPer1m?: string;
outputCostPer1m?: string;
cost?: boolean;
reference?: boolean;
honest?: boolean;
recentHours?: string;
Expand Down Expand Up @@ -105,6 +106,7 @@ export function registerGainCommand(program: Command): void {
'--output-cost-per-1m <usd>',
'USD rate per 1M output tokens; env COLONY_MCP_OUTPUT_USD_PER_1M',
)
.option('--no-cost', 'suppress USD cost output even when cost rates are configured')
.action(async (opts: GainOptions) => {
const settings = loadSettings();
const hoursArg = opts.hours ? Number(opts.hours) : undefined;
Expand Down Expand Up @@ -133,20 +135,21 @@ export function registerGainCommand(program: Command): void {
settings,
(storage) => {
const sessionLimit = parseSessionLimit(opts.sessionLimit);
const cost = costOptionsFromCli(opts);
const fullAgg = storage.aggregateMcpMetrics({
since,
until: now,
...(opts.operation !== undefined ? { operation: opts.operation } : {}),
...(sessionLimit !== undefined ? { sessionLimit } : {}),
cost: costOptionsFromCli(opts),
cost,
});
const recentAgg =
moversEnabled && recentSince !== null && recentSince > since
? storage.aggregateMcpMetrics({
since: recentSince,
until: now,
...(opts.operation !== undefined ? { operation: opts.operation } : {}),
cost: costOptionsFromCli(opts),
cost,
})
: null;
const dailyRows = summaryRequested
Expand Down Expand Up @@ -222,6 +225,7 @@ export function registerGainCommand(program: Command): void {
comparison,
windowHours,
operationFilter: opts.operation,
costBasis: live.cost_basis,
days: dailyDays,
topOps: topOpsLimit,
showGraph: opts.daily !== true || opts.graph === true,
Expand Down Expand Up @@ -1016,6 +1020,7 @@ function costOptionsFromCli(opts: GainOptions): {
input_usd_per_1m_tokens?: number;
output_usd_per_1m_tokens?: number;
} {
if (opts.cost === false) return {};
const inputRate = parseCostRate(opts.inputCostPer1m, process.env.COLONY_MCP_INPUT_USD_PER_1M);
const outputRate = parseCostRate(opts.outputCostPer1m, process.env.COLONY_MCP_OUTPUT_USD_PER_1M);
return {
Expand Down Expand Up @@ -1244,6 +1249,7 @@ export interface SummaryReportInput {
comparison: SavingsLiveComparison;
windowHours: number;
operationFilter: string | undefined;
costBasis: McpMetricsCostBasis;
days: number;
topOps: number;
showGraph: boolean;
Expand Down Expand Up @@ -1275,6 +1281,7 @@ export function writeSummaryReport(input: SummaryReportInput): void {
comparison,
windowHours,
operationFilter,
costBasis,
days,
topOps,
showGraph,
Expand All @@ -1286,7 +1293,7 @@ export function writeSummaryReport(input: SummaryReportInput): void {
const filter = operationFilter ? ` (op=${operationFilter})` : '';
w.write(`${kleur.bold(`Colony Token Savings (last ${formatHoursLabel(windowHours)}${filter})`)}\n`);
w.write(`${HEAVY_RULE}\n`);
writeSummaryHeadline(totals, comparison);
writeSummaryHeadline(totals, comparison, costBasis);

if (totals.calls === 0) {
w.write(
Expand All @@ -1304,7 +1311,7 @@ export function writeSummaryReport(input: SummaryReportInput): void {
);
}
w.write('\n');
writeSummaryByOperation(operations, comparison, topOps);
writeSummaryByOperation(operations, comparison, costBasis, topOps);
}

if (showGraph) {
Expand All @@ -1321,6 +1328,7 @@ export function writeSummaryReport(input: SummaryReportInput): void {
function writeSummaryHeadline(
totals: McpMetricsAggregateRow,
comparison: SavingsLiveComparison,
costBasis: McpMetricsCostBasis,
): void {
const w = process.stdout;
const calls = totals.calls;
Expand All @@ -1343,6 +1351,12 @@ function writeSummaryHeadline(
['Output tokens:', formatTokens(outputTokens)],
['Total tokens:', formatTokens(totalTokens)],
];
if (costBasis.configured) {
lines.push(
['Total cost:', formatUsdConfigured(totals.total_cost_usd)],
['Avg cost/call:', formatUsdConfigured(totals.avg_cost_usd)],
);
}
if (savingsPct !== null) {
const savedLabel = savedTokens >= 0
? `${formatTokens(savedTokens)} (${formatPctSigned(savingsPct)})`
Expand Down Expand Up @@ -1381,6 +1395,7 @@ function writeSummaryHeadline(
function writeSummaryByOperation(
operations: ReadonlyArray<McpMetricsAggregateRow>,
comparison: SavingsLiveComparison,
costBasis: McpMetricsCostBasis,
topOps: number,
): void {
const w = process.stdout;
Expand Down Expand Up @@ -1426,13 +1441,15 @@ function writeSummaryByOperation(

w.write(`${kleur.bold('By Operation')}\n`);
w.write(`${kleur.dim('-'.repeat(SUMMARY_TABLE_WIDTH))}\n`);
const showCost = costBasis.configured;
const header = [
padVisible(' #', 3),
padVisible('Operation', 26),
padVisibleRight('Calls', 6),
padVisibleRight('Saved', 8),
padVisibleRight('Share', 6),
padVisibleRight('Avg ms', 7),
...(showCost ? [padVisibleRight('Cost', 11)] : []),
padVisible('Impact', SUMMARY_IMPACT_WIDTH),
].join(' ');
w.write(`${kleur.dim(header)}\n`);
Expand All @@ -1456,6 +1473,7 @@ function writeSummaryByOperation(
padVisibleRight(savedCell, 8),
padVisibleRight(formatPctValue(sharePct), 6),
padVisibleRight(String(row.avg_duration_ms), 7),
...(showCost ? [padVisibleRight(formatUsdConfigured(row.total_cost_usd), 11)] : []),
renderImpactBar(weight, maxImpact, SUMMARY_IMPACT_WIDTH),
];
w.write(`${cells.join(' ')}\n`);
Expand Down
41 changes: 40 additions & 1 deletion apps/cli/test/gain.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { SAVINGS_REFERENCE_ROWS, savingsLiveComparison, savingsReferenceTotals } from '@colony/core';
import type {
McpMetricsAggregateRow,
McpMetricsCostBasis,
McpMetricsDailyRow,
McpMetricsSessionAggregateRow,
McpMetricsSessionSummary,
} from '@colony/storage';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
buildMoversReport,
fillDailyWindow,
formatDurationMs,
registerGainCommand,
renderImpactBar,
writeGainReport,
writeLiveSection,
Expand All @@ -29,7 +32,13 @@ const COST_BASIS = {
input_usd_per_1m_tokens: 1,
output_usd_per_1m_tokens: 2,
configured: true,
};
} satisfies McpMetricsCostBasis;

const DISABLED_COST_BASIS = {
input_usd_per_1m_tokens: 0,
output_usd_per_1m_tokens: 0,
configured: false,
} satisfies McpMetricsCostBasis;

const SESSION_SUMMARY: McpMetricsSessionSummary = {
session_count: 1,
Expand Down Expand Up @@ -540,6 +549,15 @@ describe('gain command output', () => {
expect(output).not.toContain('Cuts: re-reading PR threads + scrollback');
});

it('registers the --no-cost escape hatch', () => {
const program = new Command();

registerGainCommand(program);

const gain = program.commands.find((command) => command.name() === 'gain');
expect(gain?.helpInformation()).toContain('--no-cost');
});

it('includes the expanded reference operation catalog', () => {
const operations = SAVINGS_REFERENCE_ROWS.map((row) => row.operation);
expect(operations).toEqual(
Expand Down Expand Up @@ -873,6 +891,10 @@ describe('rtk-style summary helpers', () => {
input_tokens: 80,
output_tokens: 400,
total_tokens: 480,
input_cost_usd: 0.00008,
output_cost_usd: 0.0008,
total_cost_usd: 0.00088,
avg_cost_usd: 0.00022,
avg_input_tokens: 20,
avg_output_tokens: 100,
total_duration_ms: 800,
Expand All @@ -887,6 +909,10 @@ describe('rtk-style summary helpers', () => {
input_tokens: 60,
output_tokens: 60,
total_tokens: 120,
input_cost_usd: 0.00006,
output_cost_usd: 0.00012,
total_cost_usd: 0.00018,
avg_cost_usd: 0.00009,
avg_input_tokens: 30,
avg_output_tokens: 30,
total_duration_ms: 400,
Expand All @@ -902,6 +928,10 @@ describe('rtk-style summary helpers', () => {
input_tokens: 140,
output_tokens: 460,
total_tokens: 600,
input_cost_usd: 0.00014,
output_cost_usd: 0.00092,
total_cost_usd: 0.00106,
avg_cost_usd: 0.000177,
total_duration_ms: 1_200,
avg_duration_ms: 200,
last_ts: recentTs,
Expand All @@ -925,6 +955,7 @@ describe('rtk-style summary helpers', () => {
comparison,
windowHours: 24,
operationFilter: undefined,
costBasis: COST_BASIS,
days: 3,
topOps: 10,
showGraph: true,
Expand All @@ -937,9 +968,13 @@ describe('rtk-style summary helpers', () => {
expect(output).toContain('6');
expect(output).toContain('Input tokens:');
expect(output).toContain('Output tokens:');
expect(output).toContain('Total cost:');
expect(output).toContain('Avg cost/call:');
expect(output).toContain('$0.001060');
expect(output).toContain('Total exec time:');
expect(output).toContain('Efficiency meter:');
expect(output).toContain('By Operation');
expect(output).toContain('Cost');
expect(output).toContain('search');
expect(output).toContain('Daily Activity (last 3 days)');
expect(output).toContain('Daily Breakdown');
Expand Down Expand Up @@ -991,6 +1026,7 @@ describe('rtk-style summary helpers', () => {
comparison: savingsLiveComparison([], SAVINGS_REFERENCE_ROWS),
windowHours: 168,
operationFilter: undefined,
costBasis: DISABLED_COST_BASIS,
days: 2,
topOps: 10,
showGraph: true,
Expand Down Expand Up @@ -1048,6 +1084,7 @@ describe('rtk-style summary helpers', () => {
comparison: savingsLiveComparison([], SAVINGS_REFERENCE_ROWS),
windowHours: 24,
operationFilter: undefined,
costBasis: DISABLED_COST_BASIS,
days: 7,
topOps: 10,
showGraph: true,
Expand All @@ -1056,5 +1093,7 @@ describe('rtk-style summary helpers', () => {
});

expect(output).toContain('No mcp_metrics receipts in window');
expect(output).not.toContain('Total cost:');
expect(output).not.toContain('Avg cost/call:');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-15
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Why

Operators configure `COLONY_MCP_INPUT_USD_PER_1M` and
`COLONY_MCP_OUTPUT_USD_PER_1M` so Colony can show live spend. The regular
`colony gain` table already carries cost fields, but the compact summary view
only reported token volume and savings. This makes cost-aware runs surface USD
by default across the gain views while keeping an explicit opt-out for token-only
output.

## What Changes

- Add `colony gain --no-cost` as the escape hatch for suppressing USD cost
output even when CLI flags or environment rates are configured.
- Thread the active cost basis into `colony gain --summary`.
- Show total and average USD cost in the summary headline, plus a Cost column in
the summary By Operation table, when rates are configured.

## Impact

- Affected surface: `apps/cli/src/commands/gain.ts`.
- Existing token-only behavior is preserved when no rates are configured, or
when `--no-cost` is used.
- JSON keeps using the aggregate cost basis returned by storage; `--no-cost`
sends no cost rates, so JSON payloads report `configured: false`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## ADDED Requirements

### Requirement: `colony gain` renders configured USD costs by default
The system SHALL render USD cost information in `colony gain` default live
table and compact summary output when input/output USD-per-1M-token rates are
configured through CLI flags or environment variables, without requiring an
additional flag.

#### Scenario: Summary view includes configured USD cost
- **WHEN** `colony gain --summary` runs with configured input and output cost rates
- **THEN** the headline includes total and average USD cost
- **AND** the By Operation table includes per-operation USD cost.

#### Scenario: No-cost escape hatch suppresses configured USD cost
- **WHEN** `colony gain --no-cost` runs while cost rates are configured
- **THEN** the command does not pass cost rates into the metrics aggregate
- **AND** rendered output follows the token-only, unconfigured-cost path.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## Definition of Done

This change is complete only when **all** of the following are true:

- Every checkbox below is checked.
- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff.
- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline.

## Handoff

- Handoff: change=`agent-codex-usd-by-default-in-colony-gain-2026-05-15-16-02`; branch=`agent/codex/usd-by-default-in-colony-gain-2026-05-15-16-02`; scope=`apps/cli/src/commands/gain.ts`; action=`finish PR handoff after verification`.
- Copy prompt: Continue `agent-codex-usd-by-default-in-colony-gain-2026-05-15-16-02` on branch `agent/codex/usd-by-default-in-colony-gain-2026-05-15-16-02`. Work inside the existing sandbox, review `openspec/changes/agent-codex-usd-by-default-in-colony-gain-2026-05-15-16-02/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/usd-by-default-in-colony-gain-2026-05-15-16-02 --base main --via-pr --cleanup`.

## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-usd-by-default-in-colony-gain-2026-05-15-16-02`.
- [x] 1.2 Define normative requirements in `specs/usd-by-default-in-colony-gain/spec.md`.

## 2. Implementation

- [x] 2.1 Implement scoped behavior changes.
- [x] 2.2 Add/update focused regression coverage.

## 3. Verification

- [x] 3.1 Run targeted project verification commands.
- `pnpm --filter colonyq test -- gain.test.ts`
- `pnpm --filter colonyq typecheck`
- [x] 3.2 Run `openspec validate agent-codex-usd-by-default-in-colony-gain-2026-05-15-16-02 --type change --strict`.
- [x] 3.3 Run `openspec validate --specs`.

## 4. Cleanup (mandatory; run before claiming completion)

- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation.
- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff.
- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch).
Loading