@arvorco/relentless 0.3.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/relentless.convert.md +25 -0
- package/.claude/skills/analyze/SKILL.md +113 -40
- package/.claude/skills/analyze/templates/analysis-report.md +138 -0
- package/.claude/skills/checklist/SKILL.md +144 -51
- package/.claude/skills/checklist/templates/checklist.md +43 -11
- package/.claude/skills/clarify/SKILL.md +70 -11
- package/.claude/skills/constitution/SKILL.md +61 -3
- package/.claude/skills/constitution/templates/constitution.md +241 -160
- package/.claude/skills/constitution/templates/prompt.md +150 -20
- package/.claude/skills/convert/SKILL.md +248 -0
- package/.claude/skills/implement/SKILL.md +82 -34
- package/.claude/skills/plan/SKILL.md +139 -27
- package/.claude/skills/plan/templates/plan.md +92 -9
- package/.claude/skills/specify/SKILL.md +112 -20
- package/.claude/skills/specify/templates/spec.md +40 -5
- package/.claude/skills/tasks/SKILL.md +76 -1
- package/.claude/skills/tasks/templates/tasks.md +5 -4
- package/CHANGELOG.md +84 -1
- package/MANUAL.md +40 -0
- package/README.md +268 -13
- package/bin/relentless.ts +292 -5
- package/package.json +2 -2
- package/relentless/config.json +45 -1
- package/relentless/constitution.md +41 -19
- package/relentless/prompt.md +142 -72
- package/src/agents/amp.ts +53 -13
- package/src/agents/claude.ts +70 -15
- package/src/agents/codex.ts +73 -14
- package/src/agents/droid.ts +68 -14
- package/src/agents/exec.ts +96 -0
- package/src/agents/gemini.ts +59 -16
- package/src/agents/opencode.ts +188 -9
- package/src/cli/fallback-order.ts +210 -0
- package/src/cli/index.ts +63 -0
- package/src/cli/mode-flag.ts +198 -0
- package/src/cli/review-flags.ts +192 -0
- package/src/config/loader.ts +16 -1
- package/src/config/schema.ts +157 -2
- package/src/execution/runner.ts +144 -21
- package/src/init/scaffolder.ts +285 -25
- package/src/prd/parser.ts +111 -6
- package/src/prd/types.ts +136 -0
- package/src/review/index.ts +92 -0
- package/src/review/prompt.ts +293 -0
- package/src/review/runner.ts +337 -0
- package/src/review/tasks/docs.ts +529 -0
- package/src/review/tasks/index.ts +80 -0
- package/src/review/tasks/lint.ts +436 -0
- package/src/review/tasks/quality.ts +760 -0
- package/src/review/tasks/security.ts +452 -0
- package/src/review/tasks/test.ts +456 -0
- package/src/review/tasks/typecheck.ts +323 -0
- package/src/review/types.ts +139 -0
- package/src/routing/cascade.ts +310 -0
- package/src/routing/classifier.ts +338 -0
- package/src/routing/estimate.ts +270 -0
- package/src/routing/fallback.ts +512 -0
- package/src/routing/index.ts +124 -0
- package/src/routing/registry.ts +501 -0
- package/src/routing/report.ts +570 -0
- package/src/routing/router.ts +287 -0
- package/src/tui/App.tsx +2 -0
- package/src/tui/TUIRunner.tsx +103 -8
- package/src/tui/components/CurrentStory.tsx +23 -1
- package/src/tui/hooks/useTUI.ts +1 -0
- package/src/tui/types.ts +9 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Cost Reporting Module (US-025)
|
|
3
|
+
*
|
|
4
|
+
* Provides functionality to track, analyze, and report actual execution costs
|
|
5
|
+
* after feature completion. Includes savings calculations, escalation tracking,
|
|
6
|
+
* model utilization statistics, and comparison with estimates.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { generateCostReport, formatCostReport, saveCostReport } from "./report";
|
|
11
|
+
*
|
|
12
|
+
* const report = generateCostReport("my-feature", "good", executions, startTime, endTime);
|
|
13
|
+
* console.log(formatCostReport(report));
|
|
14
|
+
* await saveCostReport(report, "./progress.txt");
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { ModeSchema, ComplexitySchema, HarnessNameSchema } from "../config/schema";
|
|
20
|
+
import type { Mode, HarnessName } from "../config/schema";
|
|
21
|
+
import type { RoutingDecision } from "./router";
|
|
22
|
+
import type { EscalationResult } from "./cascade";
|
|
23
|
+
import { getModelById } from "./registry";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Represents a single escalation event within a story execution
|
|
27
|
+
*/
|
|
28
|
+
export const EscalationEventSchema = z.object({
|
|
29
|
+
/** Model that failed */
|
|
30
|
+
fromModel: z.string(),
|
|
31
|
+
/** Model escalated to */
|
|
32
|
+
toModel: z.string(),
|
|
33
|
+
/** Reason for escalation */
|
|
34
|
+
reason: z.string(),
|
|
35
|
+
/** Additional cost incurred from this escalation */
|
|
36
|
+
additionalCost: z.number().nonnegative(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export type EscalationEvent = z.infer<typeof EscalationEventSchema>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Tracks the execution details and costs for a single story
|
|
43
|
+
*/
|
|
44
|
+
export const StoryExecutionSchema = z.object({
|
|
45
|
+
/** Story identifier (e.g., "US-001") */
|
|
46
|
+
storyId: z.string(),
|
|
47
|
+
/** Story title */
|
|
48
|
+
title: z.string(),
|
|
49
|
+
/** Classified complexity level */
|
|
50
|
+
complexity: ComplexitySchema,
|
|
51
|
+
/** Initial harness assigned */
|
|
52
|
+
initialHarness: HarnessNameSchema,
|
|
53
|
+
/** Initial model assigned */
|
|
54
|
+
initialModel: z.string(),
|
|
55
|
+
/** Final harness after any escalations */
|
|
56
|
+
finalHarness: HarnessNameSchema,
|
|
57
|
+
/** Final model after any escalations */
|
|
58
|
+
finalModel: z.string(),
|
|
59
|
+
/** Estimated cost before execution */
|
|
60
|
+
estimatedCost: z.number().nonnegative(),
|
|
61
|
+
/** Actual cost after execution */
|
|
62
|
+
actualCost: z.number().nonnegative(),
|
|
63
|
+
/** Input tokens used */
|
|
64
|
+
inputTokens: z.number().int().nonnegative(),
|
|
65
|
+
/** Output tokens used */
|
|
66
|
+
outputTokens: z.number().int().nonnegative(),
|
|
67
|
+
/** Whether escalation occurred */
|
|
68
|
+
escalated: z.boolean(),
|
|
69
|
+
/** Escalation events if any */
|
|
70
|
+
escalations: z.array(EscalationEventSchema),
|
|
71
|
+
/** Execution duration in milliseconds */
|
|
72
|
+
duration: z.number().nonnegative(),
|
|
73
|
+
/** Whether execution was successful */
|
|
74
|
+
success: z.boolean(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export type StoryExecution = z.infer<typeof StoryExecutionSchema>;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Model tier utilization percentages
|
|
81
|
+
*/
|
|
82
|
+
export const ModelUtilizationSchema = z.object({
|
|
83
|
+
/** Percentage of stories using free tier models */
|
|
84
|
+
free: z.number().min(0).max(100),
|
|
85
|
+
/** Percentage of stories using cheap tier models */
|
|
86
|
+
cheap: z.number().min(0).max(100),
|
|
87
|
+
/** Percentage of stories using standard tier models */
|
|
88
|
+
standard: z.number().min(0).max(100),
|
|
89
|
+
/** Percentage of stories using premium tier models */
|
|
90
|
+
premium: z.number().min(0).max(100),
|
|
91
|
+
/** Percentage of stories using SOTA tier models */
|
|
92
|
+
sota: z.number().min(0).max(100),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export type ModelUtilization = z.infer<typeof ModelUtilizationSchema>;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Cost comparison between estimated and actual
|
|
99
|
+
*/
|
|
100
|
+
export const CostComparisonSchema = z.object({
|
|
101
|
+
/** Estimated cost */
|
|
102
|
+
estimated: z.number().nonnegative(),
|
|
103
|
+
/** Actual cost */
|
|
104
|
+
actual: z.number().nonnegative(),
|
|
105
|
+
/** Difference (actual - estimated) */
|
|
106
|
+
difference: z.number(),
|
|
107
|
+
/** Difference as percentage of estimated */
|
|
108
|
+
differencePercent: z.number(),
|
|
109
|
+
/** Whether actual exceeded estimate */
|
|
110
|
+
overBudget: z.boolean(),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export type CostComparison = z.infer<typeof CostComparisonSchema>;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Complete cost report for a feature execution
|
|
117
|
+
*/
|
|
118
|
+
export const FeatureCostReportSchema = z.object({
|
|
119
|
+
/** Feature name */
|
|
120
|
+
featureName: z.string(),
|
|
121
|
+
/** Execution mode used */
|
|
122
|
+
mode: ModeSchema,
|
|
123
|
+
/** Execution start timestamp (ISO string) */
|
|
124
|
+
startTime: z.string(),
|
|
125
|
+
/** Execution end timestamp (ISO string) */
|
|
126
|
+
endTime: z.string(),
|
|
127
|
+
/** Individual story execution records */
|
|
128
|
+
storyExecutions: z.array(StoryExecutionSchema),
|
|
129
|
+
/** Total estimated cost */
|
|
130
|
+
totalEstimatedCost: z.number().nonnegative(),
|
|
131
|
+
/** Total actual cost */
|
|
132
|
+
totalActualCost: z.number().nonnegative(),
|
|
133
|
+
/** Baseline cost (using SOTA for all stories) */
|
|
134
|
+
baselineCost: z.number().nonnegative(),
|
|
135
|
+
/** Savings percentage vs baseline */
|
|
136
|
+
savingsPercent: z.number(),
|
|
137
|
+
/** Estimate accuracy percentage */
|
|
138
|
+
estimateAccuracy: z.number(),
|
|
139
|
+
/** Total input tokens across all stories */
|
|
140
|
+
totalInputTokens: z.number().int().nonnegative(),
|
|
141
|
+
/** Total output tokens across all stories */
|
|
142
|
+
totalOutputTokens: z.number().int().nonnegative(),
|
|
143
|
+
/** Number of stories that required escalation */
|
|
144
|
+
escalationCount: z.number().int().nonnegative(),
|
|
145
|
+
/** Escalation overhead as percentage of total cost */
|
|
146
|
+
escalationOverheadPercent: z.number(),
|
|
147
|
+
/** Model tier utilization breakdown */
|
|
148
|
+
modelUtilization: ModelUtilizationSchema,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export type FeatureCostReport = z.infer<typeof FeatureCostReportSchema>;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Historical cost entry parsed from progress.txt
|
|
155
|
+
*/
|
|
156
|
+
export interface HistoricalCostEntry {
|
|
157
|
+
timestamp: string;
|
|
158
|
+
mode: Mode;
|
|
159
|
+
actualCost: number;
|
|
160
|
+
savingsPercent: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* File system interface for dependency injection in tests
|
|
165
|
+
*/
|
|
166
|
+
export interface FileSystemInterface {
|
|
167
|
+
writeFile: (path: string, content: string) => Promise<void>;
|
|
168
|
+
readFile: (path: string) => Promise<string>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const BASELINE_MODEL_ID = "opus-4.5";
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Creates a story execution record from routing decision and escalation result
|
|
175
|
+
*
|
|
176
|
+
* @param story - The story being executed
|
|
177
|
+
* @param routingDecision - The routing decision from the router
|
|
178
|
+
* @param escalationResult - The result from executeWithCascade
|
|
179
|
+
* @param tokens - Token counts from execution
|
|
180
|
+
* @param duration - Execution duration in milliseconds
|
|
181
|
+
* @returns A complete story execution record
|
|
182
|
+
*/
|
|
183
|
+
export function createStoryExecution(
|
|
184
|
+
story: { id: string; title: string },
|
|
185
|
+
routingDecision: RoutingDecision,
|
|
186
|
+
escalationResult: EscalationResult,
|
|
187
|
+
tokens: { input: number; output: number },
|
|
188
|
+
duration: number
|
|
189
|
+
): StoryExecution {
|
|
190
|
+
// Build escalation events from escalation steps
|
|
191
|
+
const escalations: EscalationEvent[] = [];
|
|
192
|
+
|
|
193
|
+
// Track escalations from the escalation result
|
|
194
|
+
if (escalationResult.escalations && escalationResult.escalations.length > 1) {
|
|
195
|
+
for (let i = 0; i < escalationResult.escalations.length - 1; i++) {
|
|
196
|
+
const current = escalationResult.escalations[i]!;
|
|
197
|
+
const next = escalationResult.escalations[i + 1]!;
|
|
198
|
+
if (current.result === "failure") {
|
|
199
|
+
escalations.push({
|
|
200
|
+
fromModel: current.model,
|
|
201
|
+
toModel: next.model,
|
|
202
|
+
reason: current.error || "Task failed",
|
|
203
|
+
additionalCost: next.cost ?? 0,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const escalated = escalationResult.finalModel !== routingDecision.model;
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
storyId: story.id,
|
|
213
|
+
title: story.title,
|
|
214
|
+
complexity: routingDecision.complexity,
|
|
215
|
+
initialHarness: routingDecision.harness,
|
|
216
|
+
initialModel: routingDecision.model,
|
|
217
|
+
finalHarness: (escalationResult.finalHarness as HarnessName) ?? routingDecision.harness,
|
|
218
|
+
finalModel: escalationResult.finalModel ?? routingDecision.model,
|
|
219
|
+
estimatedCost: routingDecision.estimatedCost,
|
|
220
|
+
actualCost: escalationResult.actualCost ?? 0,
|
|
221
|
+
inputTokens: tokens.input,
|
|
222
|
+
outputTokens: tokens.output,
|
|
223
|
+
escalated,
|
|
224
|
+
escalations,
|
|
225
|
+
duration,
|
|
226
|
+
success: escalationResult.success,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Calculates the baseline cost using SOTA (Opus 4.5) pricing
|
|
232
|
+
*
|
|
233
|
+
* @param inputTokens - Total input tokens
|
|
234
|
+
* @param outputTokens - Total output tokens
|
|
235
|
+
* @returns Baseline cost in dollars
|
|
236
|
+
*/
|
|
237
|
+
export function getBaselineCost(inputTokens: number, outputTokens: number): number {
|
|
238
|
+
const baselineModel = getModelById(BASELINE_MODEL_ID);
|
|
239
|
+
const inputRate = baselineModel?.inputCost ?? 0;
|
|
240
|
+
const outputRate = baselineModel?.outputCost ?? 0;
|
|
241
|
+
const inputCost = (inputTokens / 1_000_000) * inputRate;
|
|
242
|
+
const outputCost = (outputTokens / 1_000_000) * outputRate;
|
|
243
|
+
return inputCost + outputCost;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Calculates model tier utilization percentages
|
|
248
|
+
*
|
|
249
|
+
* @param executions - Array of story executions
|
|
250
|
+
* @returns Model utilization breakdown
|
|
251
|
+
*/
|
|
252
|
+
export function calculateModelUtilization(executions: StoryExecution[]): ModelUtilization {
|
|
253
|
+
if (executions.length === 0) {
|
|
254
|
+
return { free: 0, cheap: 0, standard: 0, premium: 0, sota: 0 };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const tierCounts: Record<keyof ModelUtilization, number> = {
|
|
258
|
+
free: 0,
|
|
259
|
+
cheap: 0,
|
|
260
|
+
standard: 0,
|
|
261
|
+
premium: 0,
|
|
262
|
+
sota: 0,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
for (const execution of executions) {
|
|
266
|
+
const modelTier =
|
|
267
|
+
getModelById(execution.finalModel)?.tier ?? "standard";
|
|
268
|
+
const tier = modelTier as keyof ModelUtilization;
|
|
269
|
+
tierCounts[tier]++;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const total = executions.length;
|
|
273
|
+
return {
|
|
274
|
+
free: (tierCounts.free / total) * 100,
|
|
275
|
+
cheap: (tierCounts.cheap / total) * 100,
|
|
276
|
+
standard: (tierCounts.standard / total) * 100,
|
|
277
|
+
premium: (tierCounts.premium / total) * 100,
|
|
278
|
+
sota: (tierCounts.sota / total) * 100,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Calculates the escalation overhead as a percentage of total cost
|
|
284
|
+
*
|
|
285
|
+
* @param executions - Array of story executions
|
|
286
|
+
* @returns Escalation overhead percentage
|
|
287
|
+
*/
|
|
288
|
+
export function calculateEscalationOverhead(executions: StoryExecution[]): number {
|
|
289
|
+
let totalCost = 0;
|
|
290
|
+
let escalationCost = 0;
|
|
291
|
+
|
|
292
|
+
for (const execution of executions) {
|
|
293
|
+
totalCost += execution.actualCost;
|
|
294
|
+
if (execution.escalated) {
|
|
295
|
+
// Escalation cost is the difference between actual and estimated
|
|
296
|
+
const overhead = execution.actualCost - execution.estimatedCost;
|
|
297
|
+
if (overhead > 0) {
|
|
298
|
+
escalationCost += overhead;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (totalCost === 0) return 0;
|
|
304
|
+
return (escalationCost / totalCost) * 100;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Generates a complete cost report from story executions
|
|
309
|
+
*
|
|
310
|
+
* @param featureName - Name of the feature
|
|
311
|
+
* @param mode - Execution mode used
|
|
312
|
+
* @param executions - Array of story executions
|
|
313
|
+
* @param startTime - Execution start timestamp
|
|
314
|
+
* @param endTime - Execution end timestamp
|
|
315
|
+
* @returns Complete feature cost report
|
|
316
|
+
*/
|
|
317
|
+
export function generateCostReport(
|
|
318
|
+
featureName: string,
|
|
319
|
+
mode: Mode,
|
|
320
|
+
executions: StoryExecution[],
|
|
321
|
+
startTime: string,
|
|
322
|
+
endTime: string
|
|
323
|
+
): FeatureCostReport {
|
|
324
|
+
// Calculate totals
|
|
325
|
+
const totalEstimatedCost = executions.reduce((sum, e) => sum + e.estimatedCost, 0);
|
|
326
|
+
const totalActualCost = executions.reduce((sum, e) => sum + e.actualCost, 0);
|
|
327
|
+
const totalInputTokens = executions.reduce((sum, e) => sum + e.inputTokens, 0);
|
|
328
|
+
const totalOutputTokens = executions.reduce((sum, e) => sum + e.outputTokens, 0);
|
|
329
|
+
const escalationCount = executions.filter((e) => e.escalated).length;
|
|
330
|
+
|
|
331
|
+
// Calculate baseline (SOTA) cost
|
|
332
|
+
const baselineCost = getBaselineCost(totalInputTokens, totalOutputTokens);
|
|
333
|
+
|
|
334
|
+
// Calculate savings
|
|
335
|
+
const savingsPercent = baselineCost > 0
|
|
336
|
+
? ((baselineCost - totalActualCost) / baselineCost) * 100
|
|
337
|
+
: 0;
|
|
338
|
+
|
|
339
|
+
// Calculate estimate accuracy
|
|
340
|
+
// Accuracy = 100 - (|actual - estimated| / estimated * 100)
|
|
341
|
+
const estimateAccuracy = totalEstimatedCost > 0
|
|
342
|
+
? 100 - (Math.abs(totalActualCost - totalEstimatedCost) / totalEstimatedCost) * 100
|
|
343
|
+
: 100;
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
featureName,
|
|
347
|
+
mode,
|
|
348
|
+
startTime,
|
|
349
|
+
endTime,
|
|
350
|
+
storyExecutions: executions,
|
|
351
|
+
totalEstimatedCost,
|
|
352
|
+
totalActualCost,
|
|
353
|
+
baselineCost,
|
|
354
|
+
savingsPercent,
|
|
355
|
+
estimateAccuracy,
|
|
356
|
+
totalInputTokens,
|
|
357
|
+
totalOutputTokens,
|
|
358
|
+
escalationCount,
|
|
359
|
+
escalationOverheadPercent: calculateEscalationOverhead(executions),
|
|
360
|
+
modelUtilization: calculateModelUtilization(executions),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Formats a single story execution line
|
|
366
|
+
*
|
|
367
|
+
* @param execution - Story execution to format
|
|
368
|
+
* @returns Formatted line string
|
|
369
|
+
*/
|
|
370
|
+
export function formatStoryLine(execution: StoryExecution): string {
|
|
371
|
+
const modelInfo = execution.escalated
|
|
372
|
+
? `${execution.initialModel} -> ${execution.finalModel}`
|
|
373
|
+
: execution.finalModel;
|
|
374
|
+
|
|
375
|
+
return `${execution.storyId}: ${execution.complexity} complexity -> ${execution.finalHarness}/${modelInfo} ($${execution.actualCost.toFixed(2)})`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Formats escalation details for a story
|
|
380
|
+
*
|
|
381
|
+
* @param execution - Story execution with escalation
|
|
382
|
+
* @returns Formatted escalation line
|
|
383
|
+
*/
|
|
384
|
+
export function formatEscalationLine(execution: StoryExecution): string {
|
|
385
|
+
if (!execution.escalated || execution.escalations.length === 0) {
|
|
386
|
+
return "";
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const escalation = execution.escalations[0]!;
|
|
390
|
+
return `${execution.storyId}: escalated ${escalation.fromModel} -> ${escalation.toModel} (+$${escalation.additionalCost.toFixed(2)})`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Formats the estimated vs actual comparison line
|
|
395
|
+
*
|
|
396
|
+
* @param estimated - Estimated cost
|
|
397
|
+
* @param actual - Actual cost
|
|
398
|
+
* @returns Formatted comparison line
|
|
399
|
+
*/
|
|
400
|
+
export function formatComparisonLine(estimated: number, actual: number): string {
|
|
401
|
+
const difference = actual - estimated;
|
|
402
|
+
const percentDiff = estimated > 0 ? (difference / estimated) * 100 : 0;
|
|
403
|
+
const sign = percentDiff >= 0 ? "+" : "";
|
|
404
|
+
return `Estimated: $${estimated.toFixed(2)}, Actual: $${actual.toFixed(2)} (${sign}${percentDiff.toFixed(1)}%)`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Formats model utilization statistics
|
|
409
|
+
*
|
|
410
|
+
* @param utilization - Model utilization percentages
|
|
411
|
+
* @returns Formatted utilization string
|
|
412
|
+
*/
|
|
413
|
+
export function formatUtilizationStats(utilization: ModelUtilization): string {
|
|
414
|
+
const parts: string[] = [];
|
|
415
|
+
|
|
416
|
+
if (utilization.free > 0) {
|
|
417
|
+
parts.push(`Free models: ${Math.round(utilization.free)}%`);
|
|
418
|
+
}
|
|
419
|
+
if (utilization.cheap > 0) {
|
|
420
|
+
parts.push(`Cheap: ${Math.round(utilization.cheap)}%`);
|
|
421
|
+
}
|
|
422
|
+
if (utilization.standard > 0) {
|
|
423
|
+
parts.push(`Standard: ${Math.round(utilization.standard)}%`);
|
|
424
|
+
}
|
|
425
|
+
if (utilization.premium > 0) {
|
|
426
|
+
parts.push(`Premium: ${Math.round(utilization.premium)}%`);
|
|
427
|
+
}
|
|
428
|
+
if (utilization.sota > 0) {
|
|
429
|
+
parts.push(`SOTA: ${Math.round(utilization.sota)}%`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return parts.join(", ") || "No model usage recorded";
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Formats a complete cost report for display
|
|
437
|
+
*
|
|
438
|
+
* @param report - Feature cost report to format
|
|
439
|
+
* @returns Formatted multi-line report string
|
|
440
|
+
*/
|
|
441
|
+
export function formatCostReport(report: FeatureCostReport): string {
|
|
442
|
+
const lines: string[] = [];
|
|
443
|
+
|
|
444
|
+
// Header
|
|
445
|
+
lines.push(`## Cost Report - ${report.endTime}`);
|
|
446
|
+
lines.push(`Feature: ${report.featureName}`);
|
|
447
|
+
lines.push(`Mode: ${report.mode}`);
|
|
448
|
+
lines.push("");
|
|
449
|
+
|
|
450
|
+
// Summary
|
|
451
|
+
lines.push(
|
|
452
|
+
`Actual cost: $${report.totalActualCost.toFixed(2)} (saved ${report.savingsPercent.toFixed(1)}% vs single-model execution)`
|
|
453
|
+
);
|
|
454
|
+
lines.push(formatComparisonLine(report.totalEstimatedCost, report.totalActualCost));
|
|
455
|
+
lines.push("");
|
|
456
|
+
|
|
457
|
+
// Tokens
|
|
458
|
+
lines.push(`Total tokens: ${report.totalInputTokens.toLocaleString()} input, ${report.totalOutputTokens.toLocaleString()} output`);
|
|
459
|
+
lines.push("");
|
|
460
|
+
|
|
461
|
+
// Per-story breakdown
|
|
462
|
+
lines.push("### Per-Story Breakdown");
|
|
463
|
+
for (const execution of report.storyExecutions) {
|
|
464
|
+
lines.push(formatStoryLine(execution));
|
|
465
|
+
}
|
|
466
|
+
lines.push("");
|
|
467
|
+
|
|
468
|
+
// Escalations
|
|
469
|
+
if (report.escalationCount > 0) {
|
|
470
|
+
lines.push("### Escalations");
|
|
471
|
+
for (const execution of report.storyExecutions) {
|
|
472
|
+
if (execution.escalated) {
|
|
473
|
+
lines.push(formatEscalationLine(execution));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
lines.push(`Escalation overhead: ${report.escalationOverheadPercent.toFixed(1)}%`);
|
|
477
|
+
lines.push("");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Model utilization
|
|
481
|
+
lines.push("### Model utilization");
|
|
482
|
+
lines.push(formatUtilizationStats(report.modelUtilization));
|
|
483
|
+
|
|
484
|
+
return lines.join("\n");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Saves a cost report to progress.txt, appending to existing content
|
|
489
|
+
*
|
|
490
|
+
* @param report - Feature cost report to save
|
|
491
|
+
* @param progressPath - Path to progress.txt
|
|
492
|
+
* @param fs - File system interface (for testing)
|
|
493
|
+
*/
|
|
494
|
+
export async function saveCostReport(
|
|
495
|
+
report: FeatureCostReport,
|
|
496
|
+
progressPath: string,
|
|
497
|
+
fs?: FileSystemInterface
|
|
498
|
+
): Promise<void> {
|
|
499
|
+
const fileSystem = fs ?? {
|
|
500
|
+
writeFile: async (path: string, content: string) => {
|
|
501
|
+
await Bun.write(path, content);
|
|
502
|
+
},
|
|
503
|
+
readFile: async (path: string) => {
|
|
504
|
+
const file = Bun.file(path);
|
|
505
|
+
return await file.text();
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
// Read existing content
|
|
510
|
+
let existingContent = "";
|
|
511
|
+
try {
|
|
512
|
+
existingContent = await fileSystem.readFile(progressPath);
|
|
513
|
+
} catch {
|
|
514
|
+
// File doesn't exist, start fresh
|
|
515
|
+
existingContent = "";
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Format and append the report
|
|
519
|
+
const formattedReport = formatCostReport(report);
|
|
520
|
+
const separator = "\n---\n\n";
|
|
521
|
+
const newContent = existingContent + separator + formattedReport + "\n";
|
|
522
|
+
|
|
523
|
+
await fileSystem.writeFile(progressPath, newContent);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Loads historical cost entries from progress.txt
|
|
528
|
+
*
|
|
529
|
+
* @param progressPath - Path to progress.txt
|
|
530
|
+
* @param fs - File system interface (for testing)
|
|
531
|
+
* @returns Array of historical cost entries
|
|
532
|
+
*/
|
|
533
|
+
export async function loadHistoricalCosts(
|
|
534
|
+
progressPath: string,
|
|
535
|
+
fs?: FileSystemInterface
|
|
536
|
+
): Promise<HistoricalCostEntry[]> {
|
|
537
|
+
const fileSystem = fs ?? {
|
|
538
|
+
writeFile: async (path: string, content: string) => {
|
|
539
|
+
await Bun.write(path, content);
|
|
540
|
+
},
|
|
541
|
+
readFile: async (path: string) => {
|
|
542
|
+
const file = Bun.file(path);
|
|
543
|
+
return await file.text();
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
let content: string;
|
|
548
|
+
try {
|
|
549
|
+
content = await fileSystem.readFile(progressPath);
|
|
550
|
+
} catch {
|
|
551
|
+
return [];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const entries: HistoricalCostEntry[] = [];
|
|
555
|
+
|
|
556
|
+
// Pattern to match cost report sections
|
|
557
|
+
const reportPattern = /## Cost Report - (\S+)\nFeature: .+\nMode: (\w+)[\s\S]*?Actual cost: \$(-?[0-9]+(?:\.[0-9]+)?) \(saved (-?\d+(?:\.\d+)?)%/g;
|
|
558
|
+
|
|
559
|
+
let match: RegExpExecArray | null;
|
|
560
|
+
while ((match = reportPattern.exec(content)) !== null) {
|
|
561
|
+
entries.push({
|
|
562
|
+
timestamp: match[1]!,
|
|
563
|
+
mode: match[2] as Mode,
|
|
564
|
+
actualCost: parseFloat(match[3]!),
|
|
565
|
+
savingsPercent: parseFloat(match[4]!),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return entries;
|
|
570
|
+
}
|