@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.
- package/CHANGELOG.md +526 -0
- package/LICENSE +21 -0
- package/README.md +620 -0
- package/bin/devflow-checkpoint.js +10 -0
- package/bin/devflow-collab.js +10 -0
- package/bin/devflow-cost.js +10 -0
- package/bin/devflow-create-persona.js +10 -0
- package/bin/devflow-init.js +10 -0
- package/bin/devflow-memory.js +10 -0
- package/bin/devflow-new-doc.js +10 -0
- package/bin/devflow-personalize.js +10 -0
- package/bin/devflow-setup-checkpoint.js +10 -0
- package/bin/devflow-story.js +10 -0
- package/bin/devflow-tech-debt.js +10 -0
- package/bin/devflow-validate-overrides.js +10 -0
- package/bin/devflow-validate.js +10 -0
- package/bin/devflow-version.js +10 -0
- package/lib/constants.js +30 -0
- package/lib/exec-python.js +78 -0
- package/lib/python-check.js +178 -0
- package/package.json +64 -0
- package/tooling/.automation/agents/architect.md +135 -0
- package/tooling/.automation/agents/ba.md +70 -0
- package/tooling/.automation/agents/dev.md +79 -0
- package/tooling/.automation/agents/maintainer.md +97 -0
- package/tooling/.automation/agents/pm.md +116 -0
- package/tooling/.automation/agents/reviewer.md +141 -0
- package/tooling/.automation/agents/sm.md +61 -0
- package/tooling/.automation/agents/writer.md +193 -0
- package/tooling/.automation/config.ps1.template +61 -0
- package/tooling/.automation/config.sh.template +48 -0
- package/tooling/.automation/memory/.gitkeep +6 -0
- package/tooling/.automation/memory/knowledge/kg_integration-test.json +94 -0
- package/tooling/.automation/memory/knowledge/kg_test-story.json +300 -0
- package/tooling/.automation/memory/shared/shared_integration-test.json +30 -0
- package/tooling/.automation/memory/shared/shared_test-story.json +78 -0
- package/tooling/.automation/overrides/templates/README.md +113 -0
- package/tooling/.automation/overrides/templates/architect/README.md +27 -0
- package/tooling/.automation/overrides/templates/architect/cloud-native.yaml +92 -0
- package/tooling/.automation/overrides/templates/architect/enterprise-architect.yaml +85 -0
- package/tooling/.automation/overrides/templates/architect/pragmatic-minimalist.yaml +88 -0
- package/tooling/.automation/overrides/templates/ba/README.md +27 -0
- package/tooling/.automation/overrides/templates/ba/agile-storyteller.yaml +86 -0
- package/tooling/.automation/overrides/templates/ba/domain-expert.yaml +91 -0
- package/tooling/.automation/overrides/templates/ba/requirements-engineer.yaml +89 -0
- package/tooling/.automation/overrides/templates/dev/README.md +32 -0
- package/tooling/.automation/overrides/templates/dev/junior-mentored.yaml +39 -0
- package/tooling/.automation/overrides/templates/dev/performance-engineer.yaml +43 -0
- package/tooling/.automation/overrides/templates/dev/rapid-prototyper.yaml +52 -0
- package/tooling/.automation/overrides/templates/dev/security-focused.yaml +43 -0
- package/tooling/.automation/overrides/templates/dev/senior-fullstack.yaml +39 -0
- package/tooling/.automation/overrides/templates/maintainer/README.md +27 -0
- package/tooling/.automation/overrides/templates/maintainer/devops-maintainer.yaml +113 -0
- package/tooling/.automation/overrides/templates/maintainer/legacy-steward.yaml +94 -0
- package/tooling/.automation/overrides/templates/maintainer/oss-maintainer.yaml +94 -0
- package/tooling/.automation/overrides/templates/pm/README.md +27 -0
- package/tooling/.automation/overrides/templates/pm/agile-pm.yaml +91 -0
- package/tooling/.automation/overrides/templates/pm/hybrid-delivery.yaml +87 -0
- package/tooling/.automation/overrides/templates/pm/traditional-pm.yaml +91 -0
- package/tooling/.automation/overrides/templates/reviewer/README.md +11 -0
- package/tooling/.automation/overrides/templates/reviewer/mentoring-reviewer.yaml +45 -0
- package/tooling/.automation/overrides/templates/reviewer/quick-sanity.yaml +50 -0
- package/tooling/.automation/overrides/templates/reviewer/thorough-critic.yaml +48 -0
- package/tooling/.automation/overrides/templates/sm/README.md +11 -0
- package/tooling/.automation/overrides/templates/sm/agile-coach.yaml +52 -0
- package/tooling/.automation/overrides/templates/sm/startup-pm.yaml +50 -0
- package/tooling/.automation/overrides/templates/sm/technical-lead.yaml +47 -0
- package/tooling/.automation/overrides/templates/user-profile.template.yaml +62 -0
- package/tooling/.automation/overrides/templates/writer/README.md +27 -0
- package/tooling/.automation/overrides/templates/writer/api-documentarian.yaml +99 -0
- package/tooling/.automation/overrides/templates/writer/docs-as-code.yaml +108 -0
- package/tooling/.automation/overrides/templates/writer/user-guide-author.yaml +100 -0
- package/tooling/completions/DevflowCompletion.ps1 +213 -0
- package/tooling/completions/_run-story +116 -0
- package/tooling/completions/run-story-completion.bash +136 -0
- package/tooling/docs/DOC-STANDARD.md +717 -0
- package/tooling/docs/sprint-status.yaml.template +24 -0
- package/tooling/docs/templates/bug-report.md +234 -0
- package/tooling/docs/templates/migration-spec.md +274 -0
- package/tooling/docs/templates/refactor-spec.md +86 -0
- package/tooling/docs/templates/tech-debt.md +86 -0
- package/tooling/scripts/context_checkpoint.py +556 -0
- package/tooling/scripts/cost_dashboard.py +617 -0
- package/tooling/scripts/create-persona.py +690 -0
- package/tooling/scripts/create-persona.sh +435 -0
- package/tooling/scripts/init-project-workflow.ps1 +651 -0
- package/tooling/scripts/init-project-workflow.py +70 -0
- package/tooling/scripts/init-project-workflow.sh +746 -0
- package/tooling/scripts/lib/__init__.py +35 -0
- package/tooling/scripts/lib/agent_handoff.py +526 -0
- package/tooling/scripts/lib/agent_router.py +698 -0
- package/tooling/scripts/lib/checkpoint-integration.ps1 +245 -0
- package/tooling/scripts/lib/checkpoint-integration.sh +191 -0
- package/tooling/scripts/lib/claude-cli.ps1 +952 -0
- package/tooling/scripts/lib/claude-cli.sh +1293 -0
- package/tooling/scripts/lib/cost_config.py +222 -0
- package/tooling/scripts/lib/cost_display.py +443 -0
- package/tooling/scripts/lib/cost_tracker.py +710 -0
- package/tooling/scripts/lib/currency_converter.py +328 -0
- package/tooling/scripts/lib/errors.py +438 -0
- package/tooling/scripts/lib/override-loader.sh +286 -0
- package/tooling/scripts/lib/pair_programming.py +589 -0
- package/tooling/scripts/lib/shared_memory.py +637 -0
- package/tooling/scripts/lib/swarm_orchestrator.py +689 -0
- package/tooling/scripts/memory_summarize.py +324 -0
- package/tooling/scripts/new-doc.ps1 +405 -0
- package/tooling/scripts/new-doc.py +93 -0
- package/tooling/scripts/new-doc.sh +534 -0
- package/tooling/scripts/personalize_agent.py +385 -0
- package/tooling/scripts/rollback-migration.sh +540 -0
- package/tooling/scripts/run-collab.ps1 +251 -0
- package/tooling/scripts/run-collab.py +605 -0
- package/tooling/scripts/run-collab.sh +110 -0
- package/tooling/scripts/run-story.ps1 +490 -0
- package/tooling/scripts/run-story.py +387 -0
- package/tooling/scripts/run-story.sh +467 -0
- package/tooling/scripts/setup-checkpoint-service.ps1 +219 -0
- package/tooling/scripts/setup-checkpoint-service.py +87 -0
- package/tooling/scripts/setup-checkpoint-service.sh +236 -0
- package/tooling/scripts/tech-debt-tracker.py +608 -0
- package/tooling/scripts/update_version.py +244 -0
- package/tooling/scripts/validate-overrides.py +511 -0
- package/tooling/scripts/validate-overrides.sh +432 -0
- 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}")
|