@pjmendonca/devflow 1.9.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 (124) hide show
  1. package/CHANGELOG.md +526 -0
  2. package/LICENSE +21 -0
  3. package/README.md +620 -0
  4. package/bin/devflow-checkpoint.js +10 -0
  5. package/bin/devflow-collab.js +10 -0
  6. package/bin/devflow-cost.js +10 -0
  7. package/bin/devflow-create-persona.js +10 -0
  8. package/bin/devflow-init.js +10 -0
  9. package/bin/devflow-memory.js +10 -0
  10. package/bin/devflow-new-doc.js +10 -0
  11. package/bin/devflow-personalize.js +10 -0
  12. package/bin/devflow-setup-checkpoint.js +10 -0
  13. package/bin/devflow-story.js +10 -0
  14. package/bin/devflow-tech-debt.js +10 -0
  15. package/bin/devflow-validate-overrides.js +10 -0
  16. package/bin/devflow-validate.js +10 -0
  17. package/bin/devflow-version.js +10 -0
  18. package/lib/constants.js +30 -0
  19. package/lib/exec-python.js +78 -0
  20. package/lib/python-check.js +178 -0
  21. package/package.json +64 -0
  22. package/tooling/.automation/agents/architect.md +135 -0
  23. package/tooling/.automation/agents/ba.md +70 -0
  24. package/tooling/.automation/agents/dev.md +79 -0
  25. package/tooling/.automation/agents/maintainer.md +97 -0
  26. package/tooling/.automation/agents/pm.md +116 -0
  27. package/tooling/.automation/agents/reviewer.md +141 -0
  28. package/tooling/.automation/agents/sm.md +61 -0
  29. package/tooling/.automation/agents/writer.md +193 -0
  30. package/tooling/.automation/config.ps1.template +61 -0
  31. package/tooling/.automation/config.sh.template +48 -0
  32. package/tooling/.automation/memory/.gitkeep +6 -0
  33. package/tooling/.automation/memory/knowledge/kg_integration-test.json +94 -0
  34. package/tooling/.automation/memory/knowledge/kg_test-story.json +300 -0
  35. package/tooling/.automation/memory/shared/shared_integration-test.json +30 -0
  36. package/tooling/.automation/memory/shared/shared_test-story.json +78 -0
  37. package/tooling/.automation/overrides/templates/README.md +113 -0
  38. package/tooling/.automation/overrides/templates/architect/README.md +27 -0
  39. package/tooling/.automation/overrides/templates/architect/cloud-native.yaml +92 -0
  40. package/tooling/.automation/overrides/templates/architect/enterprise-architect.yaml +85 -0
  41. package/tooling/.automation/overrides/templates/architect/pragmatic-minimalist.yaml +88 -0
  42. package/tooling/.automation/overrides/templates/ba/README.md +27 -0
  43. package/tooling/.automation/overrides/templates/ba/agile-storyteller.yaml +86 -0
  44. package/tooling/.automation/overrides/templates/ba/domain-expert.yaml +91 -0
  45. package/tooling/.automation/overrides/templates/ba/requirements-engineer.yaml +89 -0
  46. package/tooling/.automation/overrides/templates/dev/README.md +32 -0
  47. package/tooling/.automation/overrides/templates/dev/junior-mentored.yaml +39 -0
  48. package/tooling/.automation/overrides/templates/dev/performance-engineer.yaml +43 -0
  49. package/tooling/.automation/overrides/templates/dev/rapid-prototyper.yaml +52 -0
  50. package/tooling/.automation/overrides/templates/dev/security-focused.yaml +43 -0
  51. package/tooling/.automation/overrides/templates/dev/senior-fullstack.yaml +39 -0
  52. package/tooling/.automation/overrides/templates/maintainer/README.md +27 -0
  53. package/tooling/.automation/overrides/templates/maintainer/devops-maintainer.yaml +113 -0
  54. package/tooling/.automation/overrides/templates/maintainer/legacy-steward.yaml +94 -0
  55. package/tooling/.automation/overrides/templates/maintainer/oss-maintainer.yaml +94 -0
  56. package/tooling/.automation/overrides/templates/pm/README.md +27 -0
  57. package/tooling/.automation/overrides/templates/pm/agile-pm.yaml +91 -0
  58. package/tooling/.automation/overrides/templates/pm/hybrid-delivery.yaml +87 -0
  59. package/tooling/.automation/overrides/templates/pm/traditional-pm.yaml +91 -0
  60. package/tooling/.automation/overrides/templates/reviewer/README.md +11 -0
  61. package/tooling/.automation/overrides/templates/reviewer/mentoring-reviewer.yaml +45 -0
  62. package/tooling/.automation/overrides/templates/reviewer/quick-sanity.yaml +50 -0
  63. package/tooling/.automation/overrides/templates/reviewer/thorough-critic.yaml +48 -0
  64. package/tooling/.automation/overrides/templates/sm/README.md +11 -0
  65. package/tooling/.automation/overrides/templates/sm/agile-coach.yaml +52 -0
  66. package/tooling/.automation/overrides/templates/sm/startup-pm.yaml +50 -0
  67. package/tooling/.automation/overrides/templates/sm/technical-lead.yaml +47 -0
  68. package/tooling/.automation/overrides/templates/user-profile.template.yaml +62 -0
  69. package/tooling/.automation/overrides/templates/writer/README.md +27 -0
  70. package/tooling/.automation/overrides/templates/writer/api-documentarian.yaml +99 -0
  71. package/tooling/.automation/overrides/templates/writer/docs-as-code.yaml +108 -0
  72. package/tooling/.automation/overrides/templates/writer/user-guide-author.yaml +100 -0
  73. package/tooling/completions/DevflowCompletion.ps1 +213 -0
  74. package/tooling/completions/_run-story +116 -0
  75. package/tooling/completions/run-story-completion.bash +136 -0
  76. package/tooling/docs/DOC-STANDARD.md +717 -0
  77. package/tooling/docs/sprint-status.yaml.template +24 -0
  78. package/tooling/docs/templates/bug-report.md +234 -0
  79. package/tooling/docs/templates/migration-spec.md +274 -0
  80. package/tooling/docs/templates/refactor-spec.md +86 -0
  81. package/tooling/docs/templates/tech-debt.md +86 -0
  82. package/tooling/scripts/context_checkpoint.py +556 -0
  83. package/tooling/scripts/cost_dashboard.py +617 -0
  84. package/tooling/scripts/create-persona.py +690 -0
  85. package/tooling/scripts/create-persona.sh +435 -0
  86. package/tooling/scripts/init-project-workflow.ps1 +651 -0
  87. package/tooling/scripts/init-project-workflow.py +70 -0
  88. package/tooling/scripts/init-project-workflow.sh +746 -0
  89. package/tooling/scripts/lib/__init__.py +35 -0
  90. package/tooling/scripts/lib/agent_handoff.py +526 -0
  91. package/tooling/scripts/lib/agent_router.py +698 -0
  92. package/tooling/scripts/lib/checkpoint-integration.ps1 +245 -0
  93. package/tooling/scripts/lib/checkpoint-integration.sh +191 -0
  94. package/tooling/scripts/lib/claude-cli.ps1 +952 -0
  95. package/tooling/scripts/lib/claude-cli.sh +1293 -0
  96. package/tooling/scripts/lib/cost_config.py +222 -0
  97. package/tooling/scripts/lib/cost_display.py +443 -0
  98. package/tooling/scripts/lib/cost_tracker.py +710 -0
  99. package/tooling/scripts/lib/currency_converter.py +328 -0
  100. package/tooling/scripts/lib/errors.py +438 -0
  101. package/tooling/scripts/lib/override-loader.sh +286 -0
  102. package/tooling/scripts/lib/pair_programming.py +589 -0
  103. package/tooling/scripts/lib/shared_memory.py +637 -0
  104. package/tooling/scripts/lib/swarm_orchestrator.py +689 -0
  105. package/tooling/scripts/memory_summarize.py +324 -0
  106. package/tooling/scripts/new-doc.ps1 +405 -0
  107. package/tooling/scripts/new-doc.py +93 -0
  108. package/tooling/scripts/new-doc.sh +534 -0
  109. package/tooling/scripts/personalize_agent.py +385 -0
  110. package/tooling/scripts/rollback-migration.sh +540 -0
  111. package/tooling/scripts/run-collab.ps1 +251 -0
  112. package/tooling/scripts/run-collab.py +605 -0
  113. package/tooling/scripts/run-collab.sh +110 -0
  114. package/tooling/scripts/run-story.ps1 +490 -0
  115. package/tooling/scripts/run-story.py +387 -0
  116. package/tooling/scripts/run-story.sh +467 -0
  117. package/tooling/scripts/setup-checkpoint-service.ps1 +219 -0
  118. package/tooling/scripts/setup-checkpoint-service.py +87 -0
  119. package/tooling/scripts/setup-checkpoint-service.sh +236 -0
  120. package/tooling/scripts/tech-debt-tracker.py +608 -0
  121. package/tooling/scripts/update_version.py +244 -0
  122. package/tooling/scripts/validate-overrides.py +511 -0
  123. package/tooling/scripts/validate-overrides.sh +432 -0
  124. package/tooling/scripts/validate_setup.py +539 -0
@@ -0,0 +1,710 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Cost Tracker - Core cost tracking engine for Claude Code automation.
4
+
5
+ Tracks token usage, calculates costs, monitors budgets, and stores session data.
6
+
7
+ Usage:
8
+ from lib.cost_tracker import CostTracker
9
+
10
+ tracker = CostTracker(story_key="3-5", budget_limit_usd=15.00)
11
+ tracker.log_usage(agent="DEV", model="opus", input_tokens=1000, output_tokens=500)
12
+ print(tracker.get_session_summary())
13
+ """
14
+
15
+ import json
16
+ import sys
17
+ import threading
18
+ import uuid
19
+ import warnings
20
+ from dataclasses import asdict, dataclass, field
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ # Add parent directory for imports
26
+ sys.path.insert(0, str(Path(__file__).parent))
27
+
28
+ # Try to import enhanced error handling
29
+ try:
30
+ from errors import (
31
+ BudgetError,
32
+ CalculationError,
33
+ CostTrackingError,
34
+ ErrorCode,
35
+ ErrorContext,
36
+ SessionError,
37
+ create_error,
38
+ log_warning,
39
+ )
40
+
41
+ ENHANCED_ERRORS = True
42
+ except ImportError:
43
+ ENHANCED_ERRORS = False
44
+ # Warn user about degraded functionality (only once)
45
+ warnings.warn(
46
+ "Enhanced error handling not available (errors.py not found). "
47
+ "Using basic error classes. For better error messages, ensure "
48
+ "errors.py is in the lib directory.",
49
+ ImportWarning,
50
+ stacklevel=2,
51
+ )
52
+
53
+ # Fallback error classes
54
+ class CostTrackingError(Exception):
55
+ pass
56
+
57
+ class SessionError(Exception):
58
+ pass
59
+
60
+ class BudgetError(Exception):
61
+ pass
62
+
63
+ class CalculationError(Exception):
64
+ pass
65
+
66
+
67
+ # Token pricing per 1M tokens (USD)
68
+ # Last updated: December 2025
69
+ # Source: https://www.anthropic.com/pricing
70
+ # NOTE: Verify pricing at source before production use - rates may change
71
+ PRICING = {
72
+ # Claude 3.5 models (current generation)
73
+ "claude-3-5-sonnet-20241022": {"input": 3.00, "output": 15.00},
74
+ "claude-3-5-haiku-20241022": {"input": 0.80, "output": 4.00},
75
+ # Claude 3 models
76
+ "claude-3-opus-20240229": {"input": 15.00, "output": 75.00},
77
+ "claude-3-sonnet-20240229": {"input": 3.00, "output": 15.00},
78
+ "claude-3-haiku-20240307": {"input": 0.25, "output": 1.25},
79
+ # Short aliases (for convenience)
80
+ "opus": {"input": 15.00, "output": 75.00},
81
+ "sonnet": {"input": 3.00, "output": 15.00},
82
+ "haiku": {"input": 0.80, "output": 4.00},
83
+ }
84
+
85
+ # Default budget thresholds
86
+ DEFAULT_THRESHOLDS = {
87
+ "warning": 0.75, # 75% - Yellow warning
88
+ "critical": 0.90, # 90% - Red warning
89
+ "stop": 1.00, # 100% - Auto-stop
90
+ }
91
+
92
+ # Storage paths
93
+ PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
94
+ COSTS_DIR = PROJECT_ROOT / "tooling" / ".automation" / "costs"
95
+ SESSIONS_DIR = COSTS_DIR / "sessions"
96
+
97
+
98
+ @dataclass
99
+ class CostEntry:
100
+ """Single cost entry for a Claude API call."""
101
+
102
+ timestamp: str
103
+ agent: str
104
+ model: str
105
+ input_tokens: int
106
+ output_tokens: int
107
+ cost_usd: float
108
+
109
+ def to_dict(self) -> dict:
110
+ return asdict(self)
111
+
112
+
113
+ @dataclass
114
+ class SessionCost:
115
+ """Complete cost data for a session."""
116
+
117
+ session_id: str
118
+ story_key: str
119
+ start_time: str
120
+ end_time: Optional[str] = None
121
+ budget_limit_usd: float = 15.00
122
+ entries: list[CostEntry] = field(default_factory=list)
123
+
124
+ @property
125
+ def total_input_tokens(self) -> int:
126
+ return sum(e.input_tokens for e in self.entries)
127
+
128
+ @property
129
+ def total_output_tokens(self) -> int:
130
+ return sum(e.output_tokens for e in self.entries)
131
+
132
+ @property
133
+ def total_tokens(self) -> int:
134
+ return self.total_input_tokens + self.total_output_tokens
135
+
136
+ @property
137
+ def total_cost_usd(self) -> float:
138
+ return sum(e.cost_usd for e in self.entries)
139
+
140
+ @property
141
+ def budget_remaining(self) -> float:
142
+ return max(0, self.budget_limit_usd - self.total_cost_usd)
143
+
144
+ @property
145
+ def budget_used_percent(self) -> float:
146
+ if self.budget_limit_usd <= 0:
147
+ return 0
148
+ return min(100, (self.total_cost_usd / self.budget_limit_usd) * 100)
149
+
150
+ def to_dict(self) -> dict:
151
+ return {
152
+ "session_id": self.session_id,
153
+ "story_key": self.story_key,
154
+ "start_time": self.start_time,
155
+ "end_time": self.end_time,
156
+ "budget_limit_usd": self.budget_limit_usd,
157
+ "entries": [e.to_dict() for e in self.entries],
158
+ "totals": {
159
+ "input_tokens": self.total_input_tokens,
160
+ "output_tokens": self.total_output_tokens,
161
+ "total_tokens": self.total_tokens,
162
+ "cost_usd": round(self.total_cost_usd, 4),
163
+ "budget_remaining": round(self.budget_remaining, 4),
164
+ "budget_used_percent": round(self.budget_used_percent, 2),
165
+ },
166
+ }
167
+
168
+ def get_cost_by_agent(self) -> dict[str, float]:
169
+ """Get cost breakdown by agent."""
170
+ costs = {}
171
+ for entry in self.entries:
172
+ if entry.agent not in costs:
173
+ costs[entry.agent] = 0
174
+ costs[entry.agent] += entry.cost_usd
175
+ return costs
176
+
177
+ def get_cost_by_model(self) -> dict[str, float]:
178
+ """Get cost breakdown by model."""
179
+ costs = {}
180
+ for entry in self.entries:
181
+ if entry.model not in costs:
182
+ costs[entry.model] = 0
183
+ costs[entry.model] += entry.cost_usd
184
+ return costs
185
+
186
+ def get_tokens_by_agent(self) -> dict[str, dict[str, int]]:
187
+ """Get token breakdown by agent."""
188
+ tokens = {}
189
+ for entry in self.entries:
190
+ if entry.agent not in tokens:
191
+ tokens[entry.agent] = {"input": 0, "output": 0}
192
+ tokens[entry.agent]["input"] += entry.input_tokens
193
+ tokens[entry.agent]["output"] += entry.output_tokens
194
+ return tokens
195
+
196
+
197
+ class CostTracker:
198
+ """
199
+ Main cost tracking class.
200
+
201
+ Tracks token usage, calculates costs, monitors budgets.
202
+ """
203
+
204
+ def __init__(
205
+ self,
206
+ story_key: str = "unknown",
207
+ budget_limit_usd: float = 15.00,
208
+ thresholds: Optional[dict[str, float]] = None,
209
+ auto_save: bool = True,
210
+ ):
211
+ self.story_key = story_key
212
+ self.budget_limit_usd = budget_limit_usd
213
+ self.thresholds = thresholds or DEFAULT_THRESHOLDS.copy()
214
+ self.auto_save = auto_save
215
+
216
+ # Generate session ID
217
+ self.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
218
+
219
+ # Initialize session
220
+ self.session = SessionCost(
221
+ session_id=self.session_id,
222
+ story_key=story_key,
223
+ start_time=datetime.now().isoformat(),
224
+ budget_limit_usd=budget_limit_usd,
225
+ )
226
+
227
+ # Ensure directories exist
228
+ COSTS_DIR.mkdir(parents=True, exist_ok=True)
229
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
230
+
231
+ # Current agent/model tracking
232
+ self.current_agent = None
233
+ self.current_model = None
234
+
235
+ def calculate_cost(self, model: str, input_tokens: int, output_tokens: int) -> float:
236
+ """
237
+ Calculate cost for a given usage.
238
+
239
+ Args:
240
+ model: Model name (opus, sonnet, haiku, or full Claude model name)
241
+ input_tokens: Number of input tokens (must be >= 0)
242
+ output_tokens: Number of output tokens (must be >= 0)
243
+
244
+ Returns:
245
+ Calculated cost in USD
246
+
247
+ Raises:
248
+ CalculationError: If token counts are invalid
249
+ """
250
+ # Validate inputs
251
+ if input_tokens < 0:
252
+ error_msg = f"Invalid input_tokens: {input_tokens}. Token count cannot be negative."
253
+ if ENHANCED_ERRORS:
254
+ raise create_error(
255
+ ErrorCode.INVALID_TOKENS,
256
+ context=ErrorContext(operation="calculating cost", model=model),
257
+ custom_message=error_msg,
258
+ )
259
+ raise CalculationError(error_msg)
260
+
261
+ if output_tokens < 0:
262
+ error_msg = f"Invalid output_tokens: {output_tokens}. Token count cannot be negative."
263
+ if ENHANCED_ERRORS:
264
+ raise create_error(
265
+ ErrorCode.INVALID_TOKENS,
266
+ context=ErrorContext(operation="calculating cost", model=model),
267
+ custom_message=error_msg,
268
+ )
269
+ raise CalculationError(error_msg)
270
+
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
279
+
280
+ if not pricing:
281
+ # Default to sonnet pricing if unknown, but warn user
282
+ pricing = PRICING["sonnet"]
283
+ warning_msg = (
284
+ f"Unknown model '{model}'. Using sonnet pricing as default. "
285
+ f"Supported models: {', '.join(k for k in PRICING.keys() if not k.startswith('claude-'))}"
286
+ )
287
+ warnings.warn(warning_msg, UserWarning, stacklevel=2)
288
+ if ENHANCED_ERRORS:
289
+ log_warning(warning_msg)
290
+
291
+ input_cost = (input_tokens / 1_000_000) * pricing["input"]
292
+ output_cost = (output_tokens / 1_000_000) * pricing["output"]
293
+
294
+ return round(input_cost + output_cost, 6)
295
+
296
+ def log_usage(self, agent: str, model: str, input_tokens: int, output_tokens: int) -> CostEntry:
297
+ """
298
+ Log a usage entry.
299
+
300
+ Args:
301
+ agent: Agent name (SM, DEV, BA, etc.)
302
+ model: Model name (opus, sonnet, haiku)
303
+ input_tokens: Number of input tokens
304
+ output_tokens: Number of output tokens
305
+
306
+ Returns:
307
+ The created CostEntry
308
+ """
309
+ cost = self.calculate_cost(model, input_tokens, output_tokens)
310
+
311
+ entry = CostEntry(
312
+ timestamp=datetime.now().isoformat(),
313
+ agent=agent,
314
+ model=model,
315
+ input_tokens=input_tokens,
316
+ output_tokens=output_tokens,
317
+ cost_usd=cost,
318
+ )
319
+
320
+ self.session.entries.append(entry)
321
+ self.current_agent = agent
322
+ self.current_model = model
323
+
324
+ if self.auto_save:
325
+ self.save_session()
326
+
327
+ return entry
328
+
329
+ def set_current_agent(self, agent: str, model: str):
330
+ """Set the current agent and model (for display purposes)."""
331
+ self.current_agent = agent
332
+ self.current_model = model
333
+
334
+ def check_budget(self) -> tuple[bool, str, str]:
335
+ """
336
+ Check budget status.
337
+
338
+ Returns:
339
+ Tuple of (is_ok, status_level, message)
340
+ - is_ok: True if can continue, False if should stop
341
+ - status_level: "ok", "warning", "critical", or "stop"
342
+ - message: Human-readable status message
343
+ """
344
+ if self.budget_limit_usd <= 0:
345
+ return (True, "ok", "No budget limit set - tracking costs without enforcement")
346
+
347
+ usage_pct = self.session.total_cost_usd / self.budget_limit_usd
348
+ remaining = self.session.budget_remaining
349
+ total_cost = self.session.total_cost_usd
350
+
351
+ if usage_pct >= self.thresholds["stop"]:
352
+ return (
353
+ False,
354
+ "stop",
355
+ (
356
+ f"🛑 BUDGET EXCEEDED - ${total_cost:.2f} spent of ${self.budget_limit_usd:.2f} limit. "
357
+ f"Action required: Increase budget or stop operations. "
358
+ f"Story: {self.story_key}"
359
+ ),
360
+ )
361
+ elif usage_pct >= self.thresholds["critical"]:
362
+ return (
363
+ True,
364
+ "critical",
365
+ (
366
+ f"🔴 CRITICAL: {usage_pct * 100:.0f}% of budget used (${total_cost:.2f}). "
367
+ f"Only ${remaining:.2f} remaining. Consider wrapping up soon."
368
+ ),
369
+ )
370
+ elif usage_pct >= self.thresholds["warning"]:
371
+ return (
372
+ True,
373
+ "warning",
374
+ (
375
+ f"🟡 WARNING: {usage_pct * 100:.0f}% of budget used (${total_cost:.2f}). "
376
+ f"${remaining:.2f} remaining of ${self.budget_limit_usd:.2f} budget."
377
+ ),
378
+ )
379
+
380
+ return (
381
+ True,
382
+ "ok",
383
+ f"🟢 Budget OK: {usage_pct * 100:.0f}% used (${total_cost:.2f}/${self.budget_limit_usd:.2f})",
384
+ )
385
+
386
+ def get_session_summary(self) -> dict:
387
+ """Get session summary as dictionary."""
388
+ return self.session.to_dict()
389
+
390
+ def save_session(self) -> bool:
391
+ """
392
+ Save session to disk.
393
+
394
+ Returns:
395
+ True if save was successful, False otherwise
396
+
397
+ Note:
398
+ Attempts to save session data to disk. On failure, prints an error
399
+ message but does not raise an exception to avoid disrupting workflow.
400
+ """
401
+ self.session.end_time = datetime.now().isoformat()
402
+
403
+ filename = f"{datetime.now().strftime('%Y-%m-%d')}_{self.session_id}.json"
404
+ filepath = SESSIONS_DIR / filename
405
+
406
+ try:
407
+ # Ensure directory exists
408
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
409
+
410
+ with open(filepath, "w") as f:
411
+ json.dump(self.session.to_dict(), f, indent=2)
412
+ return True
413
+
414
+ except PermissionError:
415
+ error_msg = (
416
+ f"Permission denied when saving session to {filepath}. "
417
+ f"Check directory permissions. Session data is preserved in memory."
418
+ )
419
+ if ENHANCED_ERRORS:
420
+ log_warning(error_msg)
421
+ else:
422
+ print(f"Error: {error_msg}", file=sys.stderr)
423
+ return False
424
+
425
+ except OSError as e:
426
+ error_msg = (
427
+ f"Failed to save session to {filepath}: {e}. "
428
+ f"Check disk space and file system. Session data is preserved in memory."
429
+ )
430
+ if ENHANCED_ERRORS:
431
+ log_warning(error_msg)
432
+ else:
433
+ print(f"Error: {error_msg}", file=sys.stderr)
434
+ return False
435
+
436
+ except Exception as e:
437
+ error_msg = (
438
+ f"Unexpected error saving session: {type(e).__name__}: {e}. "
439
+ f"Session data is preserved in memory."
440
+ )
441
+ if ENHANCED_ERRORS:
442
+ log_warning(error_msg)
443
+ else:
444
+ print(f"Error: {error_msg}", file=sys.stderr)
445
+ return False
446
+
447
+ def end_session(self) -> dict:
448
+ """End session and save final state."""
449
+ self.session.end_time = datetime.now().isoformat()
450
+ self.save_session()
451
+ return self.session.to_dict()
452
+
453
+ @staticmethod
454
+ def load_session(session_file: Path) -> Optional[SessionCost]:
455
+ """
456
+ Load a session from file.
457
+
458
+ Args:
459
+ session_file: Path to the session JSON file
460
+
461
+ Returns:
462
+ SessionCost object if successful, None if file cannot be loaded
463
+
464
+ Note:
465
+ Returns None instead of raising to allow graceful handling of
466
+ corrupted or missing files in bulk operations.
467
+ """
468
+ if not session_file.exists():
469
+ warning_msg = f"Session file not found: {session_file}"
470
+ if ENHANCED_ERRORS:
471
+ log_warning(warning_msg)
472
+ else:
473
+ print(f"Warning: {warning_msg}", file=sys.stderr)
474
+ return None
475
+
476
+ try:
477
+ with open(session_file) as f:
478
+ data = json.load(f)
479
+
480
+ # Validate required fields
481
+ required_fields = ["session_id", "story_key", "start_time"]
482
+ missing_fields = [f for f in required_fields if f not in data]
483
+ if missing_fields:
484
+ warning_msg = (
485
+ f"Session file {session_file.name} is missing required fields: "
486
+ f"{', '.join(missing_fields)}. File may be corrupted."
487
+ )
488
+ if ENHANCED_ERRORS:
489
+ log_warning(warning_msg)
490
+ else:
491
+ print(f"Warning: {warning_msg}", file=sys.stderr)
492
+ return None
493
+
494
+ entries = [CostEntry(**e) for e in data.get("entries", [])]
495
+
496
+ return SessionCost(
497
+ session_id=data["session_id"],
498
+ story_key=data["story_key"],
499
+ start_time=data["start_time"],
500
+ end_time=data.get("end_time"),
501
+ budget_limit_usd=data.get("budget_limit_usd", 15.00),
502
+ entries=entries,
503
+ )
504
+ except json.JSONDecodeError as e:
505
+ error_msg = (
506
+ f"Failed to parse session file {session_file.name}: Invalid JSON at "
507
+ f"line {e.lineno}, column {e.colno}. The file may be corrupted or incomplete."
508
+ )
509
+ if ENHANCED_ERRORS:
510
+ log_warning(error_msg)
511
+ else:
512
+ print(f"Error: {error_msg}", file=sys.stderr)
513
+ return None
514
+ except TypeError as e:
515
+ error_msg = (
516
+ f"Failed to load session from {session_file.name}: Data structure mismatch. "
517
+ f"Expected fields may be missing or have wrong types. Details: {e}"
518
+ )
519
+ if ENHANCED_ERRORS:
520
+ log_warning(error_msg)
521
+ else:
522
+ print(f"Error: {error_msg}", file=sys.stderr)
523
+ return None
524
+ except Exception as e:
525
+ error_msg = (
526
+ f"Unexpected error loading session {session_file.name}: {type(e).__name__}: {e}"
527
+ )
528
+ if ENHANCED_ERRORS:
529
+ log_warning(error_msg)
530
+ else:
531
+ print(f"Error: {error_msg}", file=sys.stderr)
532
+ return None
533
+
534
+ @staticmethod
535
+ def get_historical_sessions(days: int = 30) -> list[SessionCost]:
536
+ """Get historical sessions from the last N days."""
537
+ sessions = []
538
+
539
+ if not SESSIONS_DIR.exists():
540
+ return sessions
541
+
542
+ cutoff = datetime.now().timestamp() - (days * 24 * 60 * 60)
543
+
544
+ for file in sorted(SESSIONS_DIR.glob("*.json"), reverse=True):
545
+ if file.stat().st_mtime >= cutoff:
546
+ session = CostTracker.load_session(file)
547
+ if session:
548
+ sessions.append(session)
549
+
550
+ return sessions
551
+
552
+ @staticmethod
553
+ def get_aggregate_stats(days: int = 30) -> dict:
554
+ """Get aggregate statistics for the last N days."""
555
+ sessions = CostTracker.get_historical_sessions(days)
556
+
557
+ if not sessions:
558
+ return {
559
+ "total_sessions": 0,
560
+ "total_cost_usd": 0,
561
+ "total_tokens": 0,
562
+ "by_agent": {},
563
+ "by_model": {},
564
+ }
565
+
566
+ total_cost = sum(s.total_cost_usd for s in sessions)
567
+ total_tokens = sum(s.total_tokens for s in sessions)
568
+
569
+ # Aggregate by agent
570
+ by_agent = {}
571
+ for session in sessions:
572
+ for agent, cost in session.get_cost_by_agent().items():
573
+ if agent not in by_agent:
574
+ by_agent[agent] = {"cost": 0, "sessions": 0}
575
+ by_agent[agent]["cost"] += cost
576
+ by_agent[agent]["sessions"] += 1
577
+
578
+ # Aggregate by model
579
+ by_model = {}
580
+ for session in sessions:
581
+ for model, cost in session.get_cost_by_model().items():
582
+ if model not in by_model:
583
+ by_model[model] = {"cost": 0}
584
+ by_model[model]["cost"] += cost
585
+
586
+ return {
587
+ "total_sessions": len(sessions),
588
+ "total_cost_usd": round(total_cost, 2),
589
+ "total_tokens": total_tokens,
590
+ "average_per_session": round(total_cost / len(sessions), 2) if sessions else 0,
591
+ "by_agent": by_agent,
592
+ "by_model": by_model,
593
+ }
594
+
595
+
596
+ def parse_token_usage(output: str) -> Optional[tuple[int, int]]:
597
+ """
598
+ Parse token usage from Claude CLI output.
599
+
600
+ Looks for patterns like:
601
+ - "Token usage: 45000/200000"
602
+ - "Tokens: 45000 in / 12000 out"
603
+ - "Input: 30000, Output: 8000"
604
+ - "input_tokens: X, output_tokens: Y"
605
+
606
+ Returns:
607
+ Tuple of (input_tokens, output_tokens) or None if not found
608
+
609
+ Note:
610
+ When only total tokens are available (Pattern 1), we cannot determine
611
+ the exact split. Returns None in this case to avoid inaccurate estimates.
612
+ The caller should handle this appropriately.
613
+ """
614
+ import re
615
+
616
+ # Pattern 1: Explicit input/output tokens (most accurate)
617
+ input_match = re.search(r"input[_\s]*tokens?[:\s]+(\d+)", output, re.IGNORECASE)
618
+ output_match = re.search(r"output[_\s]*tokens?[:\s]+(\d+)", output, re.IGNORECASE)
619
+ if input_match and output_match:
620
+ return (int(input_match.group(1)), int(output_match.group(1)))
621
+
622
+ # Pattern 2: "X in / Y out"
623
+ match = re.search(r"(\d+)\s*in\s*/\s*(\d+)\s*out", output, re.IGNORECASE)
624
+ if match:
625
+ return (int(match.group(1)), int(match.group(2)))
626
+
627
+ # Pattern 3: "Input: X, Output: Y" or "Input: X Output: Y"
628
+ input_match = re.search(r"input[:\s]+(\d+)", output, re.IGNORECASE)
629
+ output_match = re.search(r"output[:\s]+(\d+)", output, re.IGNORECASE)
630
+ if input_match and output_match:
631
+ return (int(input_match.group(1)), int(output_match.group(1)))
632
+
633
+ # Pattern 4: "Token usage: X/Y" (total/limit) - cannot determine split
634
+ # Return None rather than guessing, let caller decide how to handle
635
+ match = re.search(r"Token usage:\s*(\d+)/(\d+)", output, re.IGNORECASE)
636
+ if match:
637
+ # Log warning that we couldn't determine the split
638
+ total = int(match.group(1))
639
+ warnings.warn(
640
+ f"Found total token count ({total}) but cannot determine input/output split. "
641
+ "Token usage will not be tracked for this call.",
642
+ UserWarning,
643
+ stacklevel=2,
644
+ )
645
+ return None
646
+
647
+ return None
648
+
649
+
650
+ # Thread-safe module-level tracker storage
651
+ _tracker_local = threading.local()
652
+
653
+
654
+ def get_tracker() -> Optional[CostTracker]:
655
+ """
656
+ Get the current thread-local tracker.
657
+
658
+ Returns:
659
+ The CostTracker for the current thread, or None if not set.
660
+
661
+ Note:
662
+ Each thread has its own tracker instance to avoid race conditions
663
+ in multi-threaded scenarios (e.g., swarm mode with parallel agents).
664
+ """
665
+ return getattr(_tracker_local, "tracker", None)
666
+
667
+
668
+ def set_tracker(tracker: CostTracker):
669
+ """
670
+ Set the current thread-local tracker.
671
+
672
+ Args:
673
+ tracker: The CostTracker instance to use for this thread.
674
+ """
675
+ _tracker_local.tracker = tracker
676
+
677
+
678
+ def start_tracking(story_key: str, budget_limit_usd: float = 15.00) -> CostTracker:
679
+ """
680
+ Start a new tracking session for the current thread.
681
+
682
+ Args:
683
+ story_key: Identifier for the story being tracked.
684
+ budget_limit_usd: Maximum budget for this session.
685
+
686
+ Returns:
687
+ A new CostTracker instance, also set as the thread-local tracker.
688
+ """
689
+ tracker = CostTracker(story_key, budget_limit_usd)
690
+ _tracker_local.tracker = tracker
691
+ return tracker
692
+
693
+
694
+ if __name__ == "__main__":
695
+ # Demo/test
696
+ tracker = CostTracker(story_key="test-story", budget_limit_usd=10.00)
697
+
698
+ # Simulate some usage
699
+ tracker.log_usage("SM", "sonnet", 15000, 3000)
700
+ tracker.log_usage("DEV", "opus", 50000, 15000)
701
+ tracker.log_usage("SM", "sonnet", 10000, 2000)
702
+
703
+ # Print summary
704
+ import pprint
705
+
706
+ pprint.pprint(tracker.get_session_summary())
707
+
708
+ # Check budget
709
+ ok, level, msg = tracker.check_budget()
710
+ print(f"\nBudget status: {level} - {msg}")