@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.
Files changed (66) hide show
  1. package/.claude/commands/relentless.convert.md +25 -0
  2. package/.claude/skills/analyze/SKILL.md +113 -40
  3. package/.claude/skills/analyze/templates/analysis-report.md +138 -0
  4. package/.claude/skills/checklist/SKILL.md +144 -51
  5. package/.claude/skills/checklist/templates/checklist.md +43 -11
  6. package/.claude/skills/clarify/SKILL.md +70 -11
  7. package/.claude/skills/constitution/SKILL.md +61 -3
  8. package/.claude/skills/constitution/templates/constitution.md +241 -160
  9. package/.claude/skills/constitution/templates/prompt.md +150 -20
  10. package/.claude/skills/convert/SKILL.md +248 -0
  11. package/.claude/skills/implement/SKILL.md +82 -34
  12. package/.claude/skills/plan/SKILL.md +139 -27
  13. package/.claude/skills/plan/templates/plan.md +92 -9
  14. package/.claude/skills/specify/SKILL.md +112 -20
  15. package/.claude/skills/specify/templates/spec.md +40 -5
  16. package/.claude/skills/tasks/SKILL.md +76 -1
  17. package/.claude/skills/tasks/templates/tasks.md +5 -4
  18. package/CHANGELOG.md +84 -1
  19. package/MANUAL.md +40 -0
  20. package/README.md +268 -13
  21. package/bin/relentless.ts +292 -5
  22. package/package.json +2 -2
  23. package/relentless/config.json +45 -1
  24. package/relentless/constitution.md +41 -19
  25. package/relentless/prompt.md +142 -72
  26. package/src/agents/amp.ts +53 -13
  27. package/src/agents/claude.ts +70 -15
  28. package/src/agents/codex.ts +73 -14
  29. package/src/agents/droid.ts +68 -14
  30. package/src/agents/exec.ts +96 -0
  31. package/src/agents/gemini.ts +59 -16
  32. package/src/agents/opencode.ts +188 -9
  33. package/src/cli/fallback-order.ts +210 -0
  34. package/src/cli/index.ts +63 -0
  35. package/src/cli/mode-flag.ts +198 -0
  36. package/src/cli/review-flags.ts +192 -0
  37. package/src/config/loader.ts +16 -1
  38. package/src/config/schema.ts +157 -2
  39. package/src/execution/runner.ts +144 -21
  40. package/src/init/scaffolder.ts +285 -25
  41. package/src/prd/parser.ts +111 -6
  42. package/src/prd/types.ts +136 -0
  43. package/src/review/index.ts +92 -0
  44. package/src/review/prompt.ts +293 -0
  45. package/src/review/runner.ts +337 -0
  46. package/src/review/tasks/docs.ts +529 -0
  47. package/src/review/tasks/index.ts +80 -0
  48. package/src/review/tasks/lint.ts +436 -0
  49. package/src/review/tasks/quality.ts +760 -0
  50. package/src/review/tasks/security.ts +452 -0
  51. package/src/review/tasks/test.ts +456 -0
  52. package/src/review/tasks/typecheck.ts +323 -0
  53. package/src/review/types.ts +139 -0
  54. package/src/routing/cascade.ts +310 -0
  55. package/src/routing/classifier.ts +338 -0
  56. package/src/routing/estimate.ts +270 -0
  57. package/src/routing/fallback.ts +512 -0
  58. package/src/routing/index.ts +124 -0
  59. package/src/routing/registry.ts +501 -0
  60. package/src/routing/report.ts +570 -0
  61. package/src/routing/router.ts +287 -0
  62. package/src/tui/App.tsx +2 -0
  63. package/src/tui/TUIRunner.tsx +103 -8
  64. package/src/tui/components/CurrentStory.tsx +23 -1
  65. package/src/tui/hooks/useTUI.ts +1 -0
  66. 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
+ }