@pjmendonca/devflow 1.13.2 → 1.19.0

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 (236) hide show
  1. package/.claude/commands/agent.md +1 -1
  2. package/.claude/commands/brainstorm.md +28 -0
  3. package/.claude/commands/bugfix.md +21 -0
  4. package/.claude/commands/checkpoint.md +0 -1
  5. package/.claude/commands/collab.md +0 -1
  6. package/.claude/commands/costs.md +88 -18
  7. package/.claude/commands/devflow.md +26 -0
  8. package/.claude/commands/handoff.md +0 -1
  9. package/.claude/commands/init.md +383 -0
  10. package/.claude/commands/memory.md +0 -1
  11. package/.claude/commands/pair.md +0 -1
  12. package/.claude/commands/review.md +27 -0
  13. package/.claude/commands/route.md +0 -1
  14. package/.claude/commands/swarm.md +0 -1
  15. package/.claude/commands/validate.md +55 -0
  16. package/.claude/hooks/session-notification.sh +44 -0
  17. package/.claude/hooks/session-startup.sh +427 -0
  18. package/.claude/hooks/session-stop.sh +38 -0
  19. package/.claude/hooks/session_tracker.py +272 -0
  20. package/.claude/settings.json +38 -0
  21. package/.claude/skills/brainstorm/SKILL.md +531 -0
  22. package/.claude/skills/costs/SKILL.md +156 -0
  23. package/.claude/skills/validate/SKILL.md +101 -0
  24. package/CHANGELOG.md +284 -0
  25. package/README.md +207 -10
  26. package/bin/devflow-install.js +2 -1
  27. package/bin/devflow.js +4 -0
  28. package/lib/constants.js +0 -1
  29. package/lib/exec-python.js +1 -1
  30. package/package.json +1 -1
  31. package/tooling/.automation/.checkpoint_lock +1 -0
  32. package/tooling/.automation/agents/architect.md +19 -0
  33. package/tooling/.automation/agents/ba.md +19 -0
  34. package/tooling/.automation/agents/maintainer.md +19 -0
  35. package/tooling/.automation/agents/pm.md +19 -0
  36. package/tooling/.automation/agents/reviewer.md +1 -1
  37. package/tooling/.automation/agents/writer.md +19 -0
  38. package/tooling/.automation/benchmarks/benchmark_20251230_100119.json +314 -0
  39. package/tooling/.automation/benchmarks/benchmark_20251230_100216.json +314 -0
  40. package/tooling/.automation/costs/config.json +31 -0
  41. package/tooling/.automation/costs/sessions/2025-12-29_20251229_164128.json +22 -0
  42. package/tooling/.automation/memory/knowledge/kg_integration-test.json +738 -1
  43. package/tooling/.automation/memory/knowledge/kg_test-story.json +3381 -2
  44. package/tooling/.automation/memory/shared/shared_integration-test.json +193 -1
  45. package/tooling/.automation/memory/shared/shared_test-story.json +757 -1
  46. package/tooling/.automation/memory/shared/shared_test.json +1332 -0
  47. package/tooling/.automation/memory/shared/shared_validation-check.json +240 -0
  48. package/tooling/.automation/overrides/templates/architect/cloud-native.yaml +5 -5
  49. package/tooling/.automation/overrides/templates/architect/enterprise-architect.yaml +23 -5
  50. package/tooling/.automation/overrides/templates/architect/pragmatic-minimalist.yaml +24 -6
  51. package/tooling/.automation/overrides/templates/ba/agile-storyteller.yaml +4 -4
  52. package/tooling/.automation/overrides/templates/ba/domain-expert.yaml +4 -4
  53. package/tooling/.automation/overrides/templates/ba/requirements-engineer.yaml +4 -4
  54. package/tooling/.automation/overrides/templates/dev/performance-engineer.yaml +18 -0
  55. package/tooling/.automation/overrides/templates/dev/rapid-prototyper.yaml +19 -1
  56. package/tooling/.automation/overrides/templates/dev/security-focused.yaml +18 -0
  57. package/tooling/.automation/overrides/templates/dev/user-advocate.yaml +54 -0
  58. package/tooling/.automation/overrides/templates/maintainer/devops-maintainer.yaml +4 -4
  59. package/tooling/.automation/overrides/templates/maintainer/legacy-steward.yaml +4 -4
  60. package/tooling/.automation/overrides/templates/maintainer/oss-maintainer.yaml +4 -4
  61. package/tooling/.automation/overrides/templates/maintainer/reliability-engineer.yaml +55 -0
  62. package/tooling/.automation/overrides/templates/pm/agile-pm.yaml +4 -4
  63. package/tooling/.automation/overrides/templates/pm/hybrid-delivery.yaml +3 -3
  64. package/tooling/.automation/overrides/templates/pm/traditional-pm.yaml +4 -4
  65. package/tooling/.automation/overrides/templates/reviewer/quick-sanity.yaml +18 -0
  66. package/tooling/.automation/overrides/templates/reviewer/thorough-critic.yaml +18 -0
  67. package/tooling/.automation/overrides/templates/sm/agile-coach.yaml +2 -2
  68. package/tooling/.automation/overrides/templates/sm/startup-pm.yaml +3 -3
  69. package/tooling/.automation/overrides/templates/writer/api-documentarian.yaml +5 -5
  70. package/tooling/.automation/overrides/templates/writer/docs-as-code.yaml +4 -4
  71. package/tooling/.automation/overrides/templates/writer/user-guide-author.yaml +5 -5
  72. package/tooling/.automation/validation/history/2025-12-29_val_002a28c1.json +32 -0
  73. package/tooling/.automation/validation/history/2025-12-29_val_01273bb1.json +32 -0
  74. package/tooling/.automation/validation/history/2025-12-29_val_03369914.json +41 -0
  75. package/tooling/.automation/validation/history/2025-12-29_val_07a449ba.json +32 -0
  76. package/tooling/.automation/validation/history/2025-12-29_val_0df1f0a2.json +41 -0
  77. package/tooling/.automation/validation/history/2025-12-29_val_10ff3d34.json +41 -0
  78. package/tooling/.automation/validation/history/2025-12-29_val_110771d7.json +32 -0
  79. package/tooling/.automation/validation/history/2025-12-29_val_13f3a7f9.json +32 -0
  80. package/tooling/.automation/validation/history/2025-12-29_val_17ba9d21.json +41 -0
  81. package/tooling/.automation/validation/history/2025-12-29_val_22247089.json +32 -0
  82. package/tooling/.automation/validation/history/2025-12-29_val_227ea6a4.json +32 -0
  83. package/tooling/.automation/validation/history/2025-12-29_val_2335d5ae.json +32 -0
  84. package/tooling/.automation/validation/history/2025-12-29_val_246824bb.json +41 -0
  85. package/tooling/.automation/validation/history/2025-12-29_val_28b4b9cd.json +32 -0
  86. package/tooling/.automation/validation/history/2025-12-29_val_2abd12cc.json +32 -0
  87. package/tooling/.automation/validation/history/2025-12-29_val_2c801b2f.json +59 -0
  88. package/tooling/.automation/validation/history/2025-12-29_val_2c8cfa8e.json +32 -0
  89. package/tooling/.automation/validation/history/2025-12-29_val_2ce76eb0.json +32 -0
  90. package/tooling/.automation/validation/history/2025-12-29_val_30351948.json +41 -0
  91. package/tooling/.automation/validation/history/2025-12-29_val_30eb7229.json +41 -0
  92. package/tooling/.automation/validation/history/2025-12-29_val_34df0e77.json +41 -0
  93. package/tooling/.automation/validation/history/2025-12-29_val_376e4d6a.json +32 -0
  94. package/tooling/.automation/validation/history/2025-12-29_val_3a4e8a1a.json +59 -0
  95. package/tooling/.automation/validation/history/2025-12-29_val_3b77a628.json +32 -0
  96. package/tooling/.automation/validation/history/2025-12-29_val_3ea4e1cf.json +59 -0
  97. package/tooling/.automation/validation/history/2025-12-29_val_44aacdb4.json +59 -0
  98. package/tooling/.automation/validation/history/2025-12-29_val_457ddfa8.json +32 -0
  99. package/tooling/.automation/validation/history/2025-12-29_val_45af6238.json +41 -0
  100. package/tooling/.automation/validation/history/2025-12-29_val_4735dba1.json +41 -0
  101. package/tooling/.automation/validation/history/2025-12-29_val_486b203c.json +41 -0
  102. package/tooling/.automation/validation/history/2025-12-29_val_49dc56cd.json +59 -0
  103. package/tooling/.automation/validation/history/2025-12-29_val_4d863d6d.json +32 -0
  104. package/tooling/.automation/validation/history/2025-12-29_val_5149a808.json +59 -0
  105. package/tooling/.automation/validation/history/2025-12-29_val_52e0bb43.json +32 -0
  106. package/tooling/.automation/validation/history/2025-12-29_val_585d6319.json +59 -0
  107. package/tooling/.automation/validation/history/2025-12-29_val_5b2d859a.json +32 -0
  108. package/tooling/.automation/validation/history/2025-12-29_val_635a7081.json +41 -0
  109. package/tooling/.automation/validation/history/2025-12-29_val_64df4905.json +32 -0
  110. package/tooling/.automation/validation/history/2025-12-29_val_70634cee.json +41 -0
  111. package/tooling/.automation/validation/history/2025-12-29_val_714553f9.json +32 -0
  112. package/tooling/.automation/validation/history/2025-12-29_val_7f7bfdbf.json +41 -0
  113. package/tooling/.automation/validation/history/2025-12-29_val_7faad91d.json +32 -0
  114. package/tooling/.automation/validation/history/2025-12-29_val_81821f8f.json +41 -0
  115. package/tooling/.automation/validation/history/2025-12-29_val_8249f3c9.json +32 -0
  116. package/tooling/.automation/validation/history/2025-12-29_val_8422b50f.json +41 -0
  117. package/tooling/.automation/validation/history/2025-12-29_val_8446c134.json +32 -0
  118. package/tooling/.automation/validation/history/2025-12-29_val_879f4e26.json +59 -0
  119. package/tooling/.automation/validation/history/2025-12-29_val_8b6d5bd7.json +32 -0
  120. package/tooling/.automation/validation/history/2025-12-29_val_8c5cd787.json +32 -0
  121. package/tooling/.automation/validation/history/2025-12-29_val_91d20bc7.json +32 -0
  122. package/tooling/.automation/validation/history/2025-12-29_val_958a12b7.json +41 -0
  123. package/tooling/.automation/validation/history/2025-12-29_val_95d91108.json +41 -0
  124. package/tooling/.automation/validation/history/2025-12-29_val_980dbb74.json +32 -0
  125. package/tooling/.automation/validation/history/2025-12-29_val_9e40c79b.json +32 -0
  126. package/tooling/.automation/validation/history/2025-12-29_val_9f499b7c.json +32 -0
  127. package/tooling/.automation/validation/history/2025-12-29_val_9f7c3b57.json +32 -0
  128. package/tooling/.automation/validation/history/2025-12-29_val_a30d5bd4.json +32 -0
  129. package/tooling/.automation/validation/history/2025-12-29_val_a6eb09c7.json +32 -0
  130. package/tooling/.automation/validation/history/2025-12-29_val_a86f7b83.json +41 -0
  131. package/tooling/.automation/validation/history/2025-12-29_val_ad5347e1.json +41 -0
  132. package/tooling/.automation/validation/history/2025-12-29_val_b0a5a993.json +32 -0
  133. package/tooling/.automation/validation/history/2025-12-29_val_bcb0192e.json +32 -0
  134. package/tooling/.automation/validation/history/2025-12-29_val_bf3c9aaa.json +32 -0
  135. package/tooling/.automation/validation/history/2025-12-29_val_c461ff88.json +32 -0
  136. package/tooling/.automation/validation/history/2025-12-29_val_c4f4e258.json +41 -0
  137. package/tooling/.automation/validation/history/2025-12-29_val_c7f0fa6d.json +41 -0
  138. package/tooling/.automation/validation/history/2025-12-29_val_c911b0e6.json +32 -0
  139. package/tooling/.automation/validation/history/2025-12-29_val_cc581964.json +32 -0
  140. package/tooling/.automation/validation/history/2025-12-29_val_cdd5a33b.json +32 -0
  141. package/tooling/.automation/validation/history/2025-12-29_val_cfd42495.json +32 -0
  142. package/tooling/.automation/validation/history/2025-12-29_val_d1c7a4ee.json +41 -0
  143. package/tooling/.automation/validation/history/2025-12-29_val_d2280d0e.json +32 -0
  144. package/tooling/.automation/validation/history/2025-12-29_val_d2a6ff69.json +32 -0
  145. package/tooling/.automation/validation/history/2025-12-29_val_d8c53ab2.json +59 -0
  146. package/tooling/.automation/validation/history/2025-12-29_val_d9c1247a.json +41 -0
  147. package/tooling/.automation/validation/history/2025-12-29_val_d9d58569.json +32 -0
  148. package/tooling/.automation/validation/history/2025-12-29_val_dabb4fd9.json +32 -0
  149. package/tooling/.automation/validation/history/2025-12-29_val_dd8fe359.json +32 -0
  150. package/tooling/.automation/validation/history/2025-12-29_val_decdffc9.json +32 -0
  151. package/tooling/.automation/validation/history/2025-12-29_val_e3a95476.json +59 -0
  152. package/tooling/.automation/validation/history/2025-12-29_val_e776dfca.json +32 -0
  153. package/tooling/.automation/validation/history/2025-12-29_val_ea70969f.json +59 -0
  154. package/tooling/.automation/validation/history/2025-12-29_val_ef41ea95.json +32 -0
  155. package/tooling/.automation/validation/history/2025-12-29_val_f384f9b1.json +32 -0
  156. package/tooling/.automation/validation/history/2025-12-29_val_f8adc38c.json +41 -0
  157. package/tooling/.automation/validation/history/2025-12-29_val_fa40b69e.json +32 -0
  158. package/tooling/.automation/validation/history/2025-12-29_val_fc538d54.json +41 -0
  159. package/tooling/.automation/validation/history/2025-12-29_val_fe814665.json +32 -0
  160. package/tooling/.automation/validation/history/2025-12-29_val_ffea4b12.json +32 -0
  161. package/tooling/.automation/validation/history/2025-12-30_val_02d001e5.json +59 -0
  162. package/tooling/.automation/validation/history/2025-12-30_val_0b8966dc.json +32 -0
  163. package/tooling/.automation/validation/history/2025-12-30_val_15455fbf.json +59 -0
  164. package/tooling/.automation/validation/history/2025-12-30_val_157e34b9.json +32 -0
  165. package/tooling/.automation/validation/history/2025-12-30_val_28d1d933.json +32 -0
  166. package/tooling/.automation/validation/history/2025-12-30_val_3442a52c.json +32 -0
  167. package/tooling/.automation/validation/history/2025-12-30_val_37f1ce1e.json +32 -0
  168. package/tooling/.automation/validation/history/2025-12-30_val_4f1d8a93.json +32 -0
  169. package/tooling/.automation/validation/history/2025-12-30_val_56ff1de3.json +32 -0
  170. package/tooling/.automation/validation/history/2025-12-30_val_664fd4e2.json +41 -0
  171. package/tooling/.automation/validation/history/2025-12-30_val_66afb0a7.json +32 -0
  172. package/tooling/.automation/validation/history/2025-12-30_val_7634663c.json +41 -0
  173. package/tooling/.automation/validation/history/2025-12-30_val_8ea830c3.json +41 -0
  174. package/tooling/.automation/validation/history/2025-12-30_val_998957c2.json +32 -0
  175. package/tooling/.automation/validation/history/2025-12-30_val_a52177db.json +32 -0
  176. package/tooling/.automation/validation/history/2025-12-30_val_a5b65a63.json +32 -0
  177. package/tooling/.automation/validation/history/2025-12-30_val_ae391d0e.json +32 -0
  178. package/tooling/.automation/validation/history/2025-12-30_val_c7895339.json +41 -0
  179. package/tooling/.automation/validation/history/2025-12-30_val_ca416593.json +41 -0
  180. package/tooling/.automation/validation/history/2025-12-30_val_cee19422.json +32 -0
  181. package/tooling/.automation/validation/history/2025-12-30_val_ddd4f4e6.json +32 -0
  182. package/tooling/.automation/validation/history/2025-12-30_val_f2e1394b.json +32 -0
  183. package/tooling/.automation/validation/history/2025-12-30_val_f4a7fa06.json +41 -0
  184. package/tooling/.automation/validation/history/2025-12-30_val_ffea3369.json +32 -0
  185. package/tooling/.automation/validation/history/2026-01-03_val_1287a74c.json +41 -0
  186. package/tooling/.automation/validation/history/2026-01-03_val_3b24071f.json +32 -0
  187. package/tooling/.automation/validation/history/2026-01-03_val_44d77573.json +32 -0
  188. package/tooling/.automation/validation/history/2026-01-03_val_5b31dc51.json +32 -0
  189. package/tooling/.automation/validation/history/2026-01-03_val_74267244.json +32 -0
  190. package/tooling/.automation/validation/history/2026-01-03_val_8b2d95c7.json +59 -0
  191. package/tooling/.automation/validation/history/2026-01-03_val_d875b297.json +41 -0
  192. package/tooling/.automation/validation-config.yaml +103 -0
  193. package/tooling/completions/DevflowCompletion.ps1 +21 -21
  194. package/tooling/completions/_run-story +3 -3
  195. package/tooling/completions/run-story-completion.bash +8 -8
  196. package/tooling/docs/DOC-STANDARD.md +14 -14
  197. package/tooling/docs/stories/.gitkeep +0 -0
  198. package/tooling/docs/templates/brainstorm-guide.md +314 -0
  199. package/tooling/docs/templates/migration-spec.md +4 -4
  200. package/tooling/docs/templates/story.md +66 -0
  201. package/tooling/scripts/context_checkpoint.py +5 -15
  202. package/tooling/scripts/cost_dashboard.py +610 -13
  203. package/tooling/scripts/create-persona.py +1 -12
  204. package/tooling/scripts/create-persona.sh +44 -44
  205. package/tooling/scripts/lib/__init__.py +12 -1
  206. package/tooling/scripts/lib/agent_handoff.py +11 -2
  207. package/tooling/scripts/lib/agent_router.py +31 -10
  208. package/tooling/scripts/lib/colors.py +106 -0
  209. package/tooling/scripts/lib/context_monitor.py +766 -0
  210. package/tooling/scripts/lib/cost_config.py +229 -10
  211. package/tooling/scripts/lib/cost_display.py +20 -45
  212. package/tooling/scripts/lib/cost_tracker.py +462 -15
  213. package/tooling/scripts/lib/currency_converter.py +28 -5
  214. package/tooling/scripts/lib/pair_programming.py +102 -3
  215. package/tooling/scripts/lib/personality_system.py +949 -0
  216. package/tooling/scripts/lib/platform.py +55 -0
  217. package/tooling/scripts/lib/shared_memory.py +9 -3
  218. package/tooling/scripts/lib/swarm_orchestrator.py +514 -75
  219. package/tooling/scripts/lib/validation_loop.py +1014 -0
  220. package/tooling/scripts/memory_summarize.py +9 -2
  221. package/tooling/scripts/new-doc.py +2 -9
  222. package/tooling/scripts/personalize_agent.py +1 -12
  223. package/tooling/scripts/rollback-migration.sh +60 -60
  224. package/tooling/scripts/run-collab.ps1 +16 -16
  225. package/tooling/scripts/run-collab.py +88 -53
  226. package/tooling/scripts/run-collab.sh +4 -4
  227. package/tooling/scripts/run-story.py +278 -20
  228. package/tooling/scripts/run-story.sh +3 -3
  229. package/tooling/scripts/setup-checkpoint-service.py +2 -9
  230. package/tooling/scripts/tech-debt-tracker.py +1 -12
  231. package/tooling/scripts/test_adversarial_swarm.py +452 -0
  232. package/tooling/scripts/validate-overrides.py +1 -10
  233. package/tooling/scripts/validate-overrides.sh +40 -40
  234. package/tooling/scripts/validate_loop.py +162 -0
  235. package/tooling/scripts/validate_setup.py +2 -30
  236. package/.claude/skills/init/SKILL.md +0 -496
@@ -13,6 +13,7 @@ Usage:
13
13
  """
14
14
 
15
15
  import json
16
+ import re
16
17
  import sys
17
18
  import threading
18
19
  import uuid
@@ -89,6 +90,37 @@ DEFAULT_THRESHOLDS = {
89
90
  "stop": 1.00, # 100% - Auto-stop
90
91
  }
91
92
 
93
+ # Cache for model pricing lookups (model_lower -> pricing dict)
94
+ _pricing_cache: dict[str, dict[str, float]] = {}
95
+
96
+
97
+ def _get_pricing(model: str) -> tuple[dict[str, float], bool]:
98
+ """Get pricing for a model with caching.
99
+
100
+ Args:
101
+ model: Model name
102
+
103
+ Returns:
104
+ Tuple of (pricing dict, is_default) where is_default indicates if
105
+ sonnet default pricing was used for an unknown model.
106
+ """
107
+ model_lower = model.lower()
108
+
109
+ # Check cache first
110
+ if model_lower in _pricing_cache:
111
+ return _pricing_cache[model_lower], False
112
+
113
+ # Search for matching pricing
114
+ for key, price in PRICING.items():
115
+ if key in model_lower or model_lower in key:
116
+ _pricing_cache[model_lower] = price
117
+ return price, False
118
+
119
+ # Cache and return default
120
+ _pricing_cache[model_lower] = PRICING["sonnet"]
121
+ return PRICING["sonnet"], True
122
+
123
+
92
124
  # Storage paths
93
125
  PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
94
126
  COSTS_DIR = PROJECT_ROOT / "tooling" / ".automation" / "costs"
@@ -100,11 +132,11 @@ class CostEntry:
100
132
  """Single cost entry for a Claude API call."""
101
133
 
102
134
  timestamp: str
103
- agent: str
104
135
  model: str
105
136
  input_tokens: int
106
137
  output_tokens: int
107
138
  cost_usd: float
139
+ agent: str = "unknown" # Optional for backwards compatibility
108
140
 
109
141
  def to_dict(self) -> dict:
110
142
  return asdict(self)
@@ -245,8 +277,22 @@ class CostTracker:
245
277
  Calculated cost in USD
246
278
 
247
279
  Raises:
248
- CalculationError: If token counts are invalid
280
+ CalculationError: If token counts are invalid or non-numeric
249
281
  """
282
+ # Validate input types
283
+ try:
284
+ input_tokens = int(input_tokens)
285
+ output_tokens = int(output_tokens)
286
+ except (TypeError, ValueError) as e:
287
+ error_msg = f"Token counts must be numeric: {e}"
288
+ if ENHANCED_ERRORS:
289
+ raise create_error(
290
+ ErrorCode.INVALID_TOKENS,
291
+ context=ErrorContext(operation="calculating cost", model=model),
292
+ custom_message=error_msg,
293
+ ) from e
294
+ raise CalculationError(error_msg) from e
295
+
250
296
  # Validate inputs
251
297
  if input_tokens < 0:
252
298
  error_msg = f"Invalid input_tokens: {input_tokens}. Token count cannot be negative."
@@ -268,18 +314,11 @@ class CostTracker:
268
314
  )
269
315
  raise CalculationError(error_msg)
270
316
 
271
- model_lower = model.lower()
272
-
273
- # Find pricing
274
- pricing = None
275
- for key, price in PRICING.items():
276
- if key in model_lower or model_lower in key:
277
- pricing = price
278
- break
317
+ # Get pricing with caching
318
+ pricing, is_default = _get_pricing(model)
279
319
 
280
- if not pricing:
281
- # Default to sonnet pricing if unknown, but warn user
282
- pricing = PRICING["sonnet"]
320
+ if is_default:
321
+ # Warn about using default pricing for unknown model
283
322
  warning_msg = (
284
323
  f"Unknown model '{model}'. Using sonnet pricing as default. "
285
324
  f"Supported models: {', '.join(k for k in PRICING.keys() if not k.startswith('claude-'))}"
@@ -592,6 +631,416 @@ class CostTracker:
592
631
  "by_model": by_model,
593
632
  }
594
633
 
634
+ @staticmethod
635
+ def get_subscription_usage(billing_period_days: int = 30) -> dict:
636
+ """
637
+ Get subscription usage statistics for the billing period.
638
+
639
+ Args:
640
+ billing_period_days: Number of days in the billing period (default: 30)
641
+
642
+ Returns:
643
+ Dictionary with:
644
+ - total_tokens: Total tokens used in the billing period
645
+ - total_input_tokens: Input tokens used
646
+ - total_output_tokens: Output tokens used
647
+ - total_sessions: Number of sessions
648
+ - total_cost_usd: Total cost in USD
649
+ """
650
+ sessions = CostTracker.get_historical_sessions(days=billing_period_days)
651
+
652
+ total_input = sum(s.total_input_tokens for s in sessions)
653
+ total_output = sum(s.total_output_tokens for s in sessions)
654
+
655
+ return {
656
+ "total_tokens": total_input + total_output,
657
+ "total_input_tokens": total_input,
658
+ "total_output_tokens": total_output,
659
+ "total_sessions": len(sessions),
660
+ "total_cost_usd": round(sum(s.total_cost_usd for s in sessions), 2),
661
+ }
662
+
663
+ @staticmethod
664
+ def get_subscription_percentage(token_limit: int, billing_period_days: int = 30) -> dict:
665
+ """
666
+ Calculate subscription usage percentage.
667
+
668
+ Args:
669
+ token_limit: Total tokens allowed in the subscription billing period
670
+ billing_period_days: Number of days in the billing period
671
+
672
+ Returns:
673
+ Dictionary with:
674
+ - percentage: Usage percentage (0-100+, can exceed 100 if over limit)
675
+ - used_tokens: Total tokens used
676
+ - remaining_tokens: Tokens remaining (can be negative if over)
677
+ - limit_tokens: The configured limit
678
+ - status: "ok", "warning", "critical", or "exceeded"
679
+ """
680
+ if token_limit <= 0:
681
+ return {
682
+ "percentage": 0,
683
+ "used_tokens": 0,
684
+ "remaining_tokens": 0,
685
+ "limit_tokens": 0,
686
+ "status": "not_configured",
687
+ }
688
+
689
+ usage = CostTracker.get_subscription_usage(billing_period_days)
690
+ used_tokens = usage["total_tokens"]
691
+ percentage = (used_tokens / token_limit) * 100
692
+ remaining = token_limit - used_tokens
693
+
694
+ # Determine status
695
+ if percentage >= 100:
696
+ status = "exceeded"
697
+ elif percentage >= 90:
698
+ status = "critical"
699
+ elif percentage >= 75:
700
+ status = "warning"
701
+ else:
702
+ status = "ok"
703
+
704
+ return {
705
+ "percentage": round(percentage, 2),
706
+ "used_tokens": used_tokens,
707
+ "remaining_tokens": remaining,
708
+ "limit_tokens": token_limit,
709
+ "status": status,
710
+ "billing_period_days": billing_period_days,
711
+ "total_sessions": usage["total_sessions"],
712
+ "total_cost_usd": usage["total_cost_usd"],
713
+ }
714
+
715
+ @staticmethod
716
+ def get_usage_projection(token_limit: int, billing_period_days: int = 30) -> dict:
717
+ """
718
+ Calculate usage projection and forecast when limit will be reached.
719
+
720
+ Args:
721
+ token_limit: Total tokens allowed in the subscription billing period
722
+ billing_period_days: Number of days in the billing period
723
+
724
+ Returns:
725
+ Dictionary with projection data including days until limit reached.
726
+ """
727
+ if token_limit <= 0:
728
+ return {
729
+ "daily_average": 0,
730
+ "days_until_limit": None,
731
+ "projected_end_usage": 0,
732
+ "on_track": True,
733
+ "message": "Subscription tracking not configured",
734
+ }
735
+
736
+ sessions = CostTracker.get_historical_sessions(days=billing_period_days)
737
+ if not sessions:
738
+ return {
739
+ "daily_average": 0,
740
+ "days_until_limit": None,
741
+ "projected_end_usage": 0,
742
+ "on_track": True,
743
+ "message": "No usage data available",
744
+ }
745
+
746
+ # Calculate tokens used and days elapsed
747
+ total_tokens = sum(s.total_tokens for s in sessions)
748
+
749
+ # Find the earliest session to calculate actual days of usage
750
+ earliest = min(datetime.fromisoformat(s.start_time) for s in sessions)
751
+ days_elapsed = max(1, (datetime.now() - earliest).days + 1)
752
+
753
+ # Daily average based on actual usage period
754
+ daily_average = total_tokens / days_elapsed
755
+
756
+ # Project to end of billing period
757
+ projected_end_usage = daily_average * billing_period_days
758
+
759
+ # Calculate days until limit reached
760
+ if daily_average > 0:
761
+ remaining_tokens = token_limit - total_tokens
762
+ days_until_limit = remaining_tokens / daily_average if remaining_tokens > 0 else 0
763
+ else:
764
+ days_until_limit = None
765
+
766
+ # Determine if on track to stay within limit
767
+ on_track = projected_end_usage <= token_limit
768
+
769
+ # Generate message
770
+ if days_until_limit is not None and days_until_limit <= 0:
771
+ message = "Limit already exceeded"
772
+ elif days_until_limit is not None and days_until_limit < 7:
773
+ message = f"At current rate, limit reached in {days_until_limit:.0f} days"
774
+ elif not on_track:
775
+ overage_pct = ((projected_end_usage / token_limit) - 1) * 100
776
+ message = f"Projected to exceed limit by {overage_pct:.0f}%"
777
+ else:
778
+ message = "On track to stay within limit"
779
+
780
+ return {
781
+ "daily_average": round(daily_average),
782
+ "days_elapsed": days_elapsed,
783
+ "days_until_limit": round(days_until_limit, 1)
784
+ if days_until_limit is not None
785
+ else None,
786
+ "projected_end_usage": round(projected_end_usage),
787
+ "on_track": on_track,
788
+ "message": message,
789
+ "total_tokens": total_tokens,
790
+ "token_limit": token_limit,
791
+ }
792
+
793
+ @staticmethod
794
+ def get_model_efficiency() -> dict:
795
+ """
796
+ Calculate model efficiency metrics (cost per output token).
797
+
798
+ Returns:
799
+ Dictionary with efficiency data per model.
800
+ """
801
+ sessions = CostTracker.get_historical_sessions(days=30)
802
+
803
+ model_stats = {}
804
+ for session in sessions:
805
+ for entry in session.entries:
806
+ model = entry.model
807
+ if model not in model_stats:
808
+ model_stats[model] = {
809
+ "input_tokens": 0,
810
+ "output_tokens": 0,
811
+ "total_cost": 0,
812
+ "calls": 0,
813
+ }
814
+ model_stats[model]["input_tokens"] += entry.input_tokens
815
+ model_stats[model]["output_tokens"] += entry.output_tokens
816
+ model_stats[model]["total_cost"] += entry.cost_usd
817
+ model_stats[model]["calls"] += 1
818
+
819
+ # Calculate efficiency metrics
820
+ efficiency = {}
821
+ for model, stats in model_stats.items():
822
+ output_tokens = stats["output_tokens"]
823
+ total_cost = stats["total_cost"]
824
+
825
+ # Cost per 1K output tokens (the "value" metric)
826
+ cost_per_1k_output = (total_cost / output_tokens * 1000) if output_tokens > 0 else 0
827
+
828
+ # Output/Input ratio (how much output per input)
829
+ output_input_ratio = (
830
+ stats["output_tokens"] / stats["input_tokens"] if stats["input_tokens"] > 0 else 0
831
+ )
832
+
833
+ efficiency[model] = {
834
+ "cost_per_1k_output": round(cost_per_1k_output, 4),
835
+ "output_input_ratio": round(output_input_ratio, 2),
836
+ "total_cost": round(total_cost, 4),
837
+ "total_output_tokens": output_tokens,
838
+ "total_input_tokens": stats["input_tokens"],
839
+ "total_calls": stats["calls"],
840
+ "avg_output_per_call": round(output_tokens / stats["calls"])
841
+ if stats["calls"] > 0
842
+ else 0,
843
+ }
844
+
845
+ # Sort by cost efficiency (lowest cost per output token first)
846
+ sorted_efficiency = dict(
847
+ sorted(efficiency.items(), key=lambda x: x[1]["cost_per_1k_output"])
848
+ )
849
+
850
+ return sorted_efficiency
851
+
852
+ @staticmethod
853
+ def get_daily_usage(days: int = 30) -> list[dict]:
854
+ """
855
+ Get daily token usage breakdown for trends.
856
+
857
+ Args:
858
+ days: Number of days to retrieve
859
+
860
+ Returns:
861
+ List of daily usage dictionaries sorted by date.
862
+ """
863
+ sessions = CostTracker.get_historical_sessions(days=days)
864
+
865
+ daily = {}
866
+ for session in sessions:
867
+ date_str = session.start_time[:10]
868
+ if date_str not in daily:
869
+ daily[date_str] = {
870
+ "date": date_str,
871
+ "tokens": 0,
872
+ "input_tokens": 0,
873
+ "output_tokens": 0,
874
+ "cost_usd": 0,
875
+ "sessions": 0,
876
+ }
877
+ daily[date_str]["tokens"] += session.total_tokens
878
+ daily[date_str]["input_tokens"] += session.total_input_tokens
879
+ daily[date_str]["output_tokens"] += session.total_output_tokens
880
+ daily[date_str]["cost_usd"] += session.total_cost_usd
881
+ daily[date_str]["sessions"] += 1
882
+
883
+ # Sort by date
884
+ return sorted(daily.values(), key=lambda x: x["date"])
885
+
886
+ @staticmethod
887
+ def get_story_rankings(days: int = 30, limit: int = 10) -> list[dict]:
888
+ """
889
+ Get stories ranked by token consumption.
890
+
891
+ Args:
892
+ days: Number of days to look back
893
+ limit: Maximum number of stories to return
894
+
895
+ Returns:
896
+ List of stories sorted by total tokens (descending).
897
+ """
898
+ sessions = CostTracker.get_historical_sessions(days=days)
899
+
900
+ stories = {}
901
+ for session in sessions:
902
+ story = session.story_key
903
+ if story not in stories:
904
+ stories[story] = {
905
+ "story_key": story,
906
+ "total_tokens": 0,
907
+ "total_cost_usd": 0,
908
+ "sessions": 0,
909
+ }
910
+ stories[story]["total_tokens"] += session.total_tokens
911
+ stories[story]["total_cost_usd"] += session.total_cost_usd
912
+ stories[story]["sessions"] += 1
913
+
914
+ # Sort by tokens and limit
915
+ ranked = sorted(stories.values(), key=lambda x: -x["total_tokens"])
916
+ return ranked[:limit]
917
+
918
+ @staticmethod
919
+ def get_api_rate_stats(days: int = 7) -> dict:
920
+ """
921
+ Get API call rate statistics.
922
+
923
+ Args:
924
+ days: Number of days to analyze
925
+
926
+ Returns:
927
+ Dictionary with call rate statistics.
928
+ """
929
+ sessions = CostTracker.get_historical_sessions(days=days)
930
+
931
+ if not sessions:
932
+ return {
933
+ "total_calls": 0,
934
+ "calls_per_day": 0,
935
+ "calls_per_hour": 0,
936
+ "peak_hour": None,
937
+ "peak_day": None,
938
+ "hourly_distribution": {},
939
+ "daily_distribution": {},
940
+ }
941
+
942
+ # Collect all call timestamps
943
+ hourly = {} # hour -> count
944
+ daily = {} # date -> count
945
+ total_calls = 0
946
+
947
+ for session in sessions:
948
+ for entry in session.entries:
949
+ total_calls += 1
950
+ try:
951
+ ts = datetime.fromisoformat(entry.timestamp)
952
+ hour = ts.hour
953
+ date_str = ts.strftime("%Y-%m-%d")
954
+
955
+ hourly[hour] = hourly.get(hour, 0) + 1
956
+ daily[date_str] = daily.get(date_str, 0) + 1
957
+ except (ValueError, TypeError):
958
+ continue
959
+
960
+ # Calculate averages
961
+ actual_days = len(daily) if daily else 1
962
+ calls_per_day = total_calls / actual_days
963
+ calls_per_hour = total_calls / (actual_days * 24)
964
+
965
+ # Find peaks
966
+ peak_hour = max(hourly, key=hourly.get) if hourly else None
967
+ peak_day = max(daily, key=daily.get) if daily else None
968
+
969
+ return {
970
+ "total_calls": total_calls,
971
+ "calls_per_day": round(calls_per_day, 1),
972
+ "calls_per_hour": round(calls_per_hour, 2),
973
+ "peak_hour": peak_hour,
974
+ "peak_hour_calls": hourly.get(peak_hour, 0) if peak_hour is not None else 0,
975
+ "peak_day": peak_day,
976
+ "peak_day_calls": daily.get(peak_day, 0) if peak_day else 0,
977
+ "hourly_distribution": hourly,
978
+ "daily_distribution": daily,
979
+ "days_analyzed": actual_days,
980
+ }
981
+
982
+ @staticmethod
983
+ def get_period_comparison(current_days: int = 30) -> dict:
984
+ """
985
+ Compare current period vs previous period.
986
+
987
+ Args:
988
+ current_days: Number of days in current period
989
+
990
+ Returns:
991
+ Dictionary with current and previous period stats and deltas.
992
+ """
993
+ # Current period
994
+ current_sessions = CostTracker.get_historical_sessions(days=current_days)
995
+ current_tokens = sum(s.total_tokens for s in current_sessions)
996
+ current_cost = sum(s.total_cost_usd for s in current_sessions)
997
+
998
+ # Previous period (load sessions from current_days to 2*current_days ago)
999
+ all_sessions = CostTracker.get_historical_sessions(days=current_days * 2)
1000
+ cutoff = datetime.now().timestamp() - (current_days * 24 * 60 * 60)
1001
+
1002
+ previous_sessions = [
1003
+ s for s in all_sessions if datetime.fromisoformat(s.start_time).timestamp() < cutoff
1004
+ ]
1005
+ previous_tokens = sum(s.total_tokens for s in previous_sessions)
1006
+ previous_cost = sum(s.total_cost_usd for s in previous_sessions)
1007
+
1008
+ # Calculate deltas
1009
+ token_delta = current_tokens - previous_tokens
1010
+ cost_delta = current_cost - previous_cost
1011
+
1012
+ token_delta_pct = (
1013
+ ((current_tokens / previous_tokens) - 1) * 100
1014
+ if previous_tokens > 0
1015
+ else (100 if current_tokens > 0 else 0)
1016
+ )
1017
+ cost_delta_pct = (
1018
+ ((current_cost / previous_cost) - 1) * 100
1019
+ if previous_cost > 0
1020
+ else (100 if current_cost > 0 else 0)
1021
+ )
1022
+
1023
+ return {
1024
+ "current_period": {
1025
+ "days": current_days,
1026
+ "tokens": current_tokens,
1027
+ "cost_usd": round(current_cost, 2),
1028
+ "sessions": len(current_sessions),
1029
+ },
1030
+ "previous_period": {
1031
+ "days": current_days,
1032
+ "tokens": previous_tokens,
1033
+ "cost_usd": round(previous_cost, 2),
1034
+ "sessions": len(previous_sessions),
1035
+ },
1036
+ "delta": {
1037
+ "tokens": token_delta,
1038
+ "tokens_pct": round(token_delta_pct, 1),
1039
+ "cost_usd": round(cost_delta, 2),
1040
+ "cost_pct": round(cost_delta_pct, 1),
1041
+ },
1042
+ }
1043
+
595
1044
 
596
1045
  def parse_token_usage(output: str) -> Optional[tuple[int, int]]:
597
1046
  """
@@ -611,8 +1060,6 @@ def parse_token_usage(output: str) -> Optional[tuple[int, int]]:
611
1060
  the exact split. Returns None in this case to avoid inaccurate estimates.
612
1061
  The caller should handle this appropriately.
613
1062
  """
614
- import re
615
-
616
1063
  # Pattern 1: Explicit input/output tokens (most accurate)
617
1064
  input_match = re.search(r"input[_\s]*tokens?[:\s]+(\d+)", output, re.IGNORECASE)
618
1065
  output_match = re.search(r"output[_\s]*tokens?[:\s]+(\d+)", output, re.IGNORECASE)
@@ -28,6 +28,8 @@ Exchange Rate Notice:
28
28
  """
29
29
 
30
30
  import json
31
+ import os
32
+ import tempfile
31
33
  import threading
32
34
  from pathlib import Path
33
35
  from typing import Optional
@@ -128,8 +130,10 @@ class CurrencyConverter:
128
130
  if "display_currencies" in config:
129
131
  self.display_currencies = config["display_currencies"]
130
132
 
131
- except Exception as e:
132
- print(f"Warning: Could not load currency config: {e}")
133
+ except json.JSONDecodeError as e:
134
+ print(f"Warning: Invalid JSON in currency config: {e}")
135
+ except OSError as e:
136
+ print(f"Warning: Could not read currency config: {e}")
133
137
 
134
138
  def convert(self, amount_usd: float, currency: str) -> float:
135
139
  """
@@ -237,14 +241,33 @@ class CurrencyConverter:
237
241
  ]
238
242
 
239
243
  def save_config(self, config_path: Path):
240
- """Save current rates to config file."""
244
+ """Save current rates to config file (atomic write).
245
+
246
+ Uses a temporary file and rename to prevent corruption on write failure.
247
+ """
241
248
  config = {
242
249
  "currency_rates": self.rates,
243
250
  "display_currencies": self.display_currencies,
244
251
  }
245
252
 
246
- with open(config_path, "w") as f:
247
- json.dump(config, f, indent=2)
253
+ # Ensure parent directory exists
254
+ config_path = Path(config_path)
255
+ config_path.parent.mkdir(parents=True, exist_ok=True)
256
+
257
+ # Write to temp file first, then atomic rename
258
+ fd, tmp_path = tempfile.mkstemp(
259
+ suffix=".tmp", prefix=config_path.stem, dir=config_path.parent
260
+ )
261
+ try:
262
+ with os.fdopen(fd, "w") as f:
263
+ json.dump(config, f, indent=2)
264
+ # Atomic rename (works on POSIX, best-effort on Windows)
265
+ os.replace(tmp_path, config_path)
266
+ except Exception:
267
+ # Clean up temp file on failure
268
+ if os.path.exists(tmp_path):
269
+ os.unlink(tmp_path)
270
+ raise
248
271
 
249
272
 
250
273
  # Thread-safe global converter storage