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
10 changes: 9 additions & 1 deletion src/cli/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export async function sessionCommand(sessionId: string, options: { format?: stri
if (branchName === "HEAD") {
branchName = await git.getCurrentBranch(root) || branchName;
}
const bw = await engine.correlateBranch(branchName, root, allSessions);
const projectSessions = allSessions.filter(s => s.projectName === session.projectName);
const bw = await engine.correlateBranch(branchName, root, projectSessions);
if (bw) commits = bw.commits;
}
}
Expand All @@ -61,8 +62,15 @@ export async function sessionCommand(sessionId: string, options: { format?: stri
linesRemoved: session.linesRemoved,
filesModified: session.filesModified,
tokens: totalTokens(session.tokenUsage),
tokenBreakdown: {
input: session.tokenUsage.inputTokens,
output: session.tokenUsage.outputTokens,
cacheCreation: session.tokenUsage.cacheCreationTokens,
cacheRead: session.tokenUsage.cacheReadTokens,
},
wasteSignals: wasteSignals.map((w) => ({
type: w.type,
category: w.category,
cost: Math.round(w.estimatedWastedCostUSD * 100) / 100,
description: w.description,
})),
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/waste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export async function wasteCommand(options: {
const data = signals.map((s) => ({
sessionId: s.sessionId.slice(0, 8),
type: s.type,
category: s.category,
wastedCost: Math.round(s.estimatedWastedCostUSD * 100) / 100,
description: s.description,
suggestion: s.suggestion,
Expand Down
12 changes: 9 additions & 3 deletions src/cli/formatters/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,13 +687,19 @@ export function renderWasteReport(signals: WasteSignal[], sessions: Session[], p

const topType = [...byType.entries()].sort((a, b) => b[1].cost - a[1].cost)[0];

const avoidableWaste = signals.filter(s => s.category === "avoidable").reduce((s, x) => s + x.estimatedWastedCostUSD, 0);
const overheadWaste = signals.filter(s => s.category === "platform_overhead").reduce((s, x) => s + x.estimatedWastedCostUSD, 0);
const avoidablePct = totalSpend > 0 ? ((avoidableWaste / totalSpend) * 100).toFixed(1) : "0.0";

console.log();
console.log(chalk.bold(`Burnlog Waste Report (${periodLabel})`));
console.log(chalk.dim("═".repeat(60)));
console.log(` Total Spend: ${chalk.bold.green(formatCurrency(totalSpend))}`);
console.log(` Estimated Waste: ${chalk.bold.red(formatCurrency(totalWaste))} (${wastePct}%) ${chalk.red(renderBar(totalSpend > 0 ? totalWaste / totalSpend : 0, 15))}`);
console.log(` Total Spend: ${chalk.bold.green(formatCurrency(totalSpend))}`);
console.log(` Avoidable Waste: ${chalk.bold.red(formatCurrency(avoidableWaste))} (${avoidablePct}%) ${chalk.red(renderBar(totalSpend > 0 ? avoidableWaste / totalSpend : 0, 15))}`);
console.log(` Platform Overhead: ${chalk.dim(formatCurrency(overheadWaste))} (context rebuilds)`);
console.log(` Total: ${chalk.bold.red(formatCurrency(totalWaste))} (${wastePct}%)`);
if (topType) {
console.log(` Top Waste Type: ${humanizeWasteType(topType[0])} (${formatCurrency(topType[1].cost)})`);
console.log(` Top Waste Type: ${humanizeWasteType(topType[0])} (${formatCurrency(topType[1].cost)})`);
}
console.log();

Expand Down
31 changes: 28 additions & 3 deletions src/core/insights-engine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
import type { Session, WasteSignal } from "../data/models.js";
import type { Session, WasteSignal, WasteCategory } from "../data/models.js";
import { getModelPricing } from "../utils/pricing-tables.js";

const WASTE_CATEGORIES: Record<string, WasteCategory> = {
context_rebuild: "platform_overhead",
retry_loop: "avoidable",
debugging_loop: "avoidable",
wrong_approach: "avoidable",
abandoned_session: "avoidable",
excessive_exploration: "avoidable",
stalled_exploration: "avoidable",
error_cascade: "avoidable",
high_cost_per_line: "avoidable",
};

export class InsightsEngine {
analyze(sessions: Session[]): WasteSignal[] {
Expand Down Expand Up @@ -57,6 +70,7 @@ function detectRetryLoops(session: Session): WasteSignal[] {
if (streak >= 3) {
signals.push({
type: "retry_loop",
category: "avoidable",
sessionId: session.id,
estimatedWastedCostUSD: streakCost,
description: `${streak} consecutive retries on ${streakFiles.join(", ")}`,
Expand All @@ -72,6 +86,7 @@ function detectRetryLoops(session: Session): WasteSignal[] {
if (streak >= 3) {
signals.push({
type: "retry_loop",
category: "avoidable",
sessionId: session.id,
estimatedWastedCostUSD: streakCost,
description: `${streak} consecutive retries on ${streakFiles.join(", ")}`,
Expand All @@ -92,6 +107,7 @@ function detectAbandonedSession(session: Session): WasteSignal | null {

return {
type: "abandoned_session",
category: "avoidable",
sessionId: session.id,
estimatedWastedCostUSD: session.estimatedCostUSD,
description: `Session ended with no commits and outcome: not_achieved`,
Expand All @@ -113,10 +129,12 @@ function detectContextRebuilds(session: Session): WasteSignal[] {
: 0;

if (lastHadCacheRead && cacheWriteRatio > 0.3 && ex.tokenUsage.cacheCreationTokens > 50_000) {
// Estimate rebuild cost using average cache write pricing ($3.75/M for Sonnet)
const rebuildCost = (ex.tokenUsage.cacheCreationTokens / 1_000_000) * 3.75;
// Use actual model pricing for cache write cost
const pricing = getModelPricing(ex.model);
const rebuildCost = (ex.tokenUsage.cacheCreationTokens / 1_000_000) * pricing.cacheWritePerMillion;
signals.push({
type: "context_rebuild",
category: "platform_overhead",
sessionId: session.id,
estimatedWastedCostUSD: rebuildCost,
description: `Cache rebuilt at exchange #${ex.sequenceNumber} (${formatK(ex.tokenUsage.cacheCreationTokens)} cache write tokens)`,
Expand All @@ -143,6 +161,7 @@ function detectExcessiveExploration(session: Session): WasteSignal | null {
if (ratio > 0.70 && implementation === 0) {
return {
type: "excessive_exploration",
category: "avoidable",
sessionId: session.id,
estimatedWastedCostUSD: session.estimatedCostUSD * 0.3,
description: `${Math.round(ratio * 100)}% of exchanges were read-only with no edits (${exploration}/${session.exchanges.length})`,
Expand Down Expand Up @@ -185,6 +204,7 @@ function detectErrorCascade(session: Session): WasteSignal | null {
if (maxStreak >= 3) {
return {
type: "error_cascade",
category: "avoidable",
sessionId: session.id,
estimatedWastedCostUSD: maxStreakCost * 0.5,
description: `${maxStreak} consecutive debugging exchanges with ${session.toolErrors} total tool errors`,
Expand All @@ -210,6 +230,7 @@ function detectKnownFrictions(session: Session): WasteSignal[] {
if (wastedCost < 0.10) continue;
signals.push({
type: "wrong_approach",
category: "avoidable",
sessionId: session.id,
estimatedWastedCostUSD: wastedCost,
description: `${friction.count}x ${friction.type}: ${friction.detail}`,
Expand Down Expand Up @@ -253,6 +274,7 @@ function detectDebuggingLoops(session: Session): WasteSignal[] {
if (streak >= 4) {
signals.push({
type: "debugging_loop",
category: "avoidable",
sessionId: session.id,
estimatedWastedCostUSD: streakCost * 0.5,
description: `${streak} consecutive fix attempts on ${streakFiles.slice(0, 3).map(f => f.split("/").pop()).join(", ")}`,
Expand All @@ -268,6 +290,7 @@ function detectDebuggingLoops(session: Session): WasteSignal[] {
if (streak >= 4) {
signals.push({
type: "debugging_loop",
category: "avoidable",
sessionId: session.id,
estimatedWastedCostUSD: streakCost * 0.5,
description: `${streak} consecutive fix attempts on ${streakFiles.slice(0, 3).map(f => f.split("/").pop()).join(", ")}`,
Expand All @@ -293,6 +316,7 @@ function detectHighCostPerLine(session: Session): WasteSignal | null {
const excessCost = session.estimatedCostUSD - (totalLines * 0.10); // $0.10/line is "normal"
return {
type: "high_cost_per_line",
category: "avoidable",
sessionId: session.id,
estimatedWastedCostUSD: Math.max(excessCost * 0.3, 0), // 30% of excess is waste
description: `$${costPerLine.toFixed(2)}/line (${totalLines} lines changed for $${session.estimatedCostUSD.toFixed(2)})`,
Expand Down Expand Up @@ -357,6 +381,7 @@ function detectStalledExploration(session: Session): WasteSignal | null {
if (maxStreak >= 3 && maxStreakCost > 10) {
return {
type: "stalled_exploration",
category: "avoidable",
sessionId: session.id,
estimatedWastedCostUSD: maxStreakCost * 0.4,
description: `${maxStreak} consecutive exploration/minimal-prompt exchanges costing $${maxStreakCost.toFixed(2)}`,
Expand Down
3 changes: 3 additions & 0 deletions src/data/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,11 @@ export interface Project {
sessions: Session[];
}

export type WasteCategory = "platform_overhead" | "avoidable";

export interface WasteSignal {
type: WasteType;
category: WasteCategory;
sessionId: string;
estimatedWastedCostUSD: number;
description: string;
Expand Down
23 changes: 12 additions & 11 deletions src/providers/claude-code/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,15 +397,16 @@ export class ClaudeCodeProvider implements AIToolProvider {

const filePath = input.file_path || input.path || "";
if (!filePath) continue;
const fileName = typeof filePath === "string" ? filePath.split("/").pop() || filePath : "";
const filePathStr = typeof filePath === "string" ? filePath : "";
const fileName = filePathStr ? filePathStr.split("/").pop() || filePathStr : "";

switch (block.name) {
case "Read":
activity.filesRead.add(fileName);
activity.filesRead.add(filePathStr);
filesRead.push(fileName);
break;
case "Edit": {
activity.filesModified.add(fileName);
activity.filesModified.add(filePathStr);
activity.editCount++;
filesModified.push(fileName);
// Calculate lines from old_string/new_string
Expand All @@ -418,7 +419,7 @@ export class ClaudeCodeProvider implements AIToolProvider {
break;
}
case "Write": {
activity.filesModified.add(fileName);
activity.filesModified.add(filePathStr);
activity.writeCount++;
filesModified.push(fileName);
const writeContent = typeof input.content === "string" ? input.content : "";
Expand Down Expand Up @@ -619,17 +620,17 @@ export class ClaudeCodeProvider implements AIToolProvider {

const hasImpl = exchanges.some((e) => e.category === "implementation");
const producedChanges = activity.linesAdded > 0 || activity.filesModified.size > 0;
const hasMeta = !!meta;
const errors = meta?.tool_errors ?? 0;
const interruptions = meta?.user_interruptions ?? 0;

if (hasImpl && producedChanges && errors === 0 && interruptions === 0) {
return "fully_achieved";
}
if (hasImpl && producedChanges && errors === 0) {
return "mostly_achieved";
}
if (hasImpl && producedChanges) {
return "partially_achieved";
// With meta: use error/interruption data for fine-grained inference
if (hasMeta && errors === 0 && interruptions === 0) return "fully_achieved";
if (hasMeta && errors === 0) return "mostly_achieved";
if (hasMeta) return "partially_achieved";
// Without meta: can't distinguish — assume mostly (conservative)
return "mostly_achieved";
}
const totalCost = exchanges.reduce((s, e) => s + e.estimatedCostUSD, 0);
if (exchanges.length > 3 && !hasImpl && totalCost > 0.5) {
Expand Down
1 change: 1 addition & 0 deletions test/unit/efficiency-score.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { SessionOutcome, WasteSignal, WasteType } from "../../src/data/mode
function createWasteSignal(overrides?: Partial<WasteSignal>): WasteSignal {
return {
type: "retry_loop" as WasteType,
category: "avoidable",
sessionId: "test",
estimatedWastedCostUSD: 1,
description: "test waste",
Expand Down
5 changes: 3 additions & 2 deletions test/unit/table-renderers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ describe("renderSessionDetail", () => {
const session = createSession();
const signals: WasteSignal[] = [{
type: "abandoned_session",
category: "avoidable",
sessionId: session.id,
estimatedWastedCostUSD: 5,
description: "Test waste",
Expand Down Expand Up @@ -279,8 +280,8 @@ describe("renderBranchComparison", () => {
describe("renderWasteReport", () => {
it("renders waste summary and signals", () => {
const signals: WasteSignal[] = [
{ type: "abandoned_session", sessionId: "s1", estimatedWastedCostUSD: 5, description: "No commits", suggestion: "Plan better" },
{ type: "retry_loop", sessionId: "s2", estimatedWastedCostUSD: 3, description: "3 retries", suggestion: "Give context" },
{ type: "abandoned_session", category: "avoidable", sessionId: "s1", estimatedWastedCostUSD: 5, description: "No commits", suggestion: "Plan better" },
{ type: "retry_loop", category: "avoidable", sessionId: "s2", estimatedWastedCostUSD: 3, description: "3 retries", suggestion: "Give context" },
];
const output = captureConsole(() => renderWasteReport(signals, sessions, "Last 30 days"));
expect(output).toContain("Waste Report");
Expand Down
Loading