specsmith 0.1.4.dev33__tar.gz → 0.1.4.dev34__tar.gz
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.
- {specsmith-0.1.4.dev33/src/specsmith.egg-info → specsmith-0.1.4.dev34}/PKG-INFO +1 -1
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/pyproject.toml +1 -1
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/cli.py +135 -0
- specsmith-0.1.4.dev34/src/specsmith/credit_analyzer.py +186 -0
- specsmith-0.1.4.dev34/src/specsmith/credits.py +284 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34/src/specsmith.egg-info}/PKG-INFO +1 -1
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith.egg-info/SOURCES.txt +2 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/LICENSE +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/README.md +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/setup.cfg +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/__init__.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/__main__.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/architect.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/auditor.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/commands/__init__.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/compressor.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/config.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/differ.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/doctor.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/exporter.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/importer.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/integrations/__init__.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/integrations/aider.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/integrations/base.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/integrations/claude_code.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/integrations/copilot.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/integrations/cursor.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/integrations/gemini.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/integrations/warp.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/integrations/windsurf.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/ledger.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/plugins.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/releaser.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/requirements.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/scaffolder.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/session.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/agents.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/docs/architecture.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/docs/requirements.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/docs/test-spec.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/docs/workflow.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/editorconfig.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/gitattributes.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/gitignore.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/governance/context-budget.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/governance/drift-metrics.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/governance/roles.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/governance/rules.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/governance/verification.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/governance/workflow.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/ledger.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/pyproject.toml.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/python/cli.py.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/python/init.py.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/readme.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/scripts/exec.cmd.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/scripts/exec.sh.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/scripts/run.cmd.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/scripts/run.sh.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/scripts/setup.cmd.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/scripts/setup.sh.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/tools.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/updater.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/upgrader.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/validator.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/vcs/__init__.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/vcs/base.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/vcs/bitbucket.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/vcs/github.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/vcs/gitlab.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/vcs_commands.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith.egg-info/dependency_links.txt +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith.egg-info/entry_points.txt +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith.egg-info/requires.txt +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith.egg-info/top_level.txt +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/tests/test_auditor.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/tests/test_cli.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/tests/test_compressor.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/tests/test_importer.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/tests/test_integrations.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/tests/test_scaffolder.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/tests/test_smoke.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/tests/test_tools.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/tests/test_validator.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/tests/test_vcs.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "specsmith"
|
|
7
|
-
version = "0.1.4.
|
|
7
|
+
version = "0.1.4.dev34"
|
|
8
8
|
description = "Forge governed project scaffolds from the Agentic AI Development Workflow Specification."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -1357,6 +1357,141 @@ def session_end_cmd(project_dir: str) -> None:
|
|
|
1357
1357
|
console.print("[bold green]Session clean. Ready to end.[/bold green]")
|
|
1358
1358
|
|
|
1359
1359
|
|
|
1360
|
+
# ---------------------------------------------------------------------------
|
|
1361
|
+
# Credits
|
|
1362
|
+
# ---------------------------------------------------------------------------
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
@main.group()
|
|
1366
|
+
def credits() -> None:
|
|
1367
|
+
"""AI credit/token spend tracking and analysis."""
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
@credits.command(name="summary")
|
|
1371
|
+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
|
|
1372
|
+
@click.option("--month", default="", help="Filter by month (YYYY-MM).")
|
|
1373
|
+
def credits_summary(project_dir: str, month: str) -> None:
|
|
1374
|
+
"""Show credit spend summary."""
|
|
1375
|
+
from specsmith.credits import get_summary
|
|
1376
|
+
|
|
1377
|
+
root = Path(project_dir).resolve()
|
|
1378
|
+
s = get_summary(root, month=month)
|
|
1379
|
+
console.print(f" Tokens in: {s.total_tokens_in:,}")
|
|
1380
|
+
console.print(f" Tokens out: {s.total_tokens_out:,}")
|
|
1381
|
+
console.print(f" Cost: ${s.total_cost_usd:.4f}")
|
|
1382
|
+
console.print(f" Sessions: {s.session_count}")
|
|
1383
|
+
console.print(f" Entries: {s.entry_count}")
|
|
1384
|
+
if s.by_model:
|
|
1385
|
+
console.print("\n By model:")
|
|
1386
|
+
for model, cost in sorted(s.by_model.items(), key=lambda x: -x[1]):
|
|
1387
|
+
console.print(f" {model}: ${cost:.4f}")
|
|
1388
|
+
if s.alerts:
|
|
1389
|
+
console.print()
|
|
1390
|
+
for alert in s.alerts:
|
|
1391
|
+
console.print(f" [yellow]\u26a0[/yellow] {alert}")
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
@credits.command(name="record")
|
|
1395
|
+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
|
|
1396
|
+
@click.option("--model", default="unknown", help="AI model used.")
|
|
1397
|
+
@click.option("--provider", default="unknown", help="AI provider (openai, anthropic, etc.).")
|
|
1398
|
+
@click.option("--tokens-in", type=int, default=0, help="Input tokens.")
|
|
1399
|
+
@click.option("--tokens-out", type=int, default=0, help="Output tokens.")
|
|
1400
|
+
@click.option("--task", default="", help="Task description.")
|
|
1401
|
+
@click.option("--cost", type=float, default=None, help="Actual cost in USD (overrides estimate).")
|
|
1402
|
+
def credits_record(
|
|
1403
|
+
project_dir: str,
|
|
1404
|
+
model: str,
|
|
1405
|
+
provider: str,
|
|
1406
|
+
tokens_in: int,
|
|
1407
|
+
tokens_out: int,
|
|
1408
|
+
task: str,
|
|
1409
|
+
cost: float | None,
|
|
1410
|
+
) -> None:
|
|
1411
|
+
"""Record a credit usage entry."""
|
|
1412
|
+
from specsmith.credits import record_usage
|
|
1413
|
+
|
|
1414
|
+
root = Path(project_dir).resolve()
|
|
1415
|
+
entry = record_usage(
|
|
1416
|
+
root,
|
|
1417
|
+
model=model,
|
|
1418
|
+
provider=provider,
|
|
1419
|
+
tokens_in=tokens_in,
|
|
1420
|
+
tokens_out=tokens_out,
|
|
1421
|
+
task=task,
|
|
1422
|
+
cost_usd=cost,
|
|
1423
|
+
)
|
|
1424
|
+
console.print(
|
|
1425
|
+
f"[green]\u2713[/green] Recorded: {entry.model} "
|
|
1426
|
+
f"{entry.tokens_in:,}+{entry.tokens_out:,} tokens "
|
|
1427
|
+
f"(${entry.estimated_cost_usd:.4f})"
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
@credits.command(name="report")
|
|
1432
|
+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
|
|
1433
|
+
@click.option("--output", default="", help="Write to file instead of stdout.")
|
|
1434
|
+
def credits_report(project_dir: str, output: str) -> None:
|
|
1435
|
+
"""Generate credit spend report."""
|
|
1436
|
+
from specsmith.credits import generate_report
|
|
1437
|
+
|
|
1438
|
+
root = Path(project_dir).resolve()
|
|
1439
|
+
report = generate_report(root)
|
|
1440
|
+
if output:
|
|
1441
|
+
Path(output).write_text(report, encoding="utf-8")
|
|
1442
|
+
console.print(f"[green]\u2713[/green] Report written to {output}")
|
|
1443
|
+
else:
|
|
1444
|
+
console.print(report)
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
@credits.command(name="analyze")
|
|
1448
|
+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
|
|
1449
|
+
def credits_analyze(project_dir: str) -> None:
|
|
1450
|
+
"""Analyze spend patterns and get optimization recommendations."""
|
|
1451
|
+
from specsmith.credit_analyzer import generate_analysis_report
|
|
1452
|
+
|
|
1453
|
+
root = Path(project_dir).resolve()
|
|
1454
|
+
report = generate_analysis_report(root)
|
|
1455
|
+
console.print(report)
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
@credits.command(name="budget")
|
|
1459
|
+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
|
|
1460
|
+
@click.option("--cap", type=float, default=None, help="Monthly cap in USD (0=unlimited).")
|
|
1461
|
+
@click.option("--alert-pct", type=int, default=None, help="Alert at this % of cap.")
|
|
1462
|
+
@click.option(
|
|
1463
|
+
"--watermarks", default=None, help="Comma-separated USD watermark alerts (e.g. 5,10,25,50)."
|
|
1464
|
+
)
|
|
1465
|
+
def credits_budget(
|
|
1466
|
+
project_dir: str, cap: float | None, alert_pct: int | None, watermarks: str | None,
|
|
1467
|
+
) -> None:
|
|
1468
|
+
"""View or set credit budget and alert thresholds."""
|
|
1469
|
+
from specsmith.credits import load_budget, save_budget
|
|
1470
|
+
|
|
1471
|
+
root = Path(project_dir).resolve()
|
|
1472
|
+
budget = load_budget(root)
|
|
1473
|
+
|
|
1474
|
+
if cap is not None:
|
|
1475
|
+
budget.monthly_cap_usd = cap
|
|
1476
|
+
if alert_pct is not None:
|
|
1477
|
+
budget.alert_threshold_pct = alert_pct
|
|
1478
|
+
if watermarks is not None:
|
|
1479
|
+
budget.alert_watermarks_usd = [float(w.strip()) for w in watermarks.split(",") if w.strip()]
|
|
1480
|
+
|
|
1481
|
+
if any(x is not None for x in (cap, alert_pct, watermarks)):
|
|
1482
|
+
save_budget(root, budget)
|
|
1483
|
+
console.print("[green]\u2713[/green] Budget updated.")
|
|
1484
|
+
|
|
1485
|
+
cap_note = " (unlimited)" if budget.monthly_cap_usd == 0 else ""
|
|
1486
|
+
console.print(f" Monthly cap: ${budget.monthly_cap_usd:.2f}{cap_note}")
|
|
1487
|
+
console.print(f" Alert at: {budget.alert_threshold_pct}%")
|
|
1488
|
+
console.print(f" Watermarks: {', '.join(f'${w:.2f}' for w in budget.alert_watermarks_usd)}")
|
|
1489
|
+
console.print(f" Enabled: {budget.enabled}")
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
main.add_command(credits)
|
|
1493
|
+
|
|
1494
|
+
|
|
1360
1495
|
# ---------------------------------------------------------------------------
|
|
1361
1496
|
# Plugin system
|
|
1362
1497
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
|
|
3
|
+
"""Credit analyzer — spend analysis and closed-loop optimization."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class CreditInsight:
|
|
13
|
+
"""Single optimization insight."""
|
|
14
|
+
|
|
15
|
+
category: str # "waste", "model", "governance", "batch"
|
|
16
|
+
severity: str # "info", "warn", "critical"
|
|
17
|
+
message: str
|
|
18
|
+
recommendation: str
|
|
19
|
+
estimated_savings_pct: float = 0.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class AnalysisReport:
|
|
24
|
+
"""Full credit analysis report."""
|
|
25
|
+
|
|
26
|
+
insights: list[CreditInsight] = field(default_factory=list)
|
|
27
|
+
total_cost: float = 0.0
|
|
28
|
+
estimated_optimized_cost: float = 0.0
|
|
29
|
+
cost_trend: str = "" # "increasing", "decreasing", "stable"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def analyze_spend(root: Path) -> AnalysisReport:
|
|
33
|
+
"""Analyze credit spend and generate optimization insights."""
|
|
34
|
+
from specsmith.credits import _load_entries
|
|
35
|
+
|
|
36
|
+
entries = _load_entries(root)
|
|
37
|
+
report = AnalysisReport()
|
|
38
|
+
|
|
39
|
+
if not entries:
|
|
40
|
+
report.insights.append(
|
|
41
|
+
CreditInsight(
|
|
42
|
+
category="info",
|
|
43
|
+
severity="info",
|
|
44
|
+
message="No credit data yet.",
|
|
45
|
+
recommendation=(
|
|
46
|
+
"Record usage with `specsmith credits record` "
|
|
47
|
+
"or integrate with your AI agent."
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
return report
|
|
52
|
+
|
|
53
|
+
report.total_cost = sum(e.estimated_cost_usd for e in entries)
|
|
54
|
+
|
|
55
|
+
# --- Analysis 1: Model efficiency ---
|
|
56
|
+
model_costs: dict[str, list[float]] = {}
|
|
57
|
+
for e in entries:
|
|
58
|
+
model_costs.setdefault(e.model, []).append(e.estimated_cost_usd)
|
|
59
|
+
|
|
60
|
+
if len(model_costs) > 1:
|
|
61
|
+
avg_by_model = {m: sum(c) / len(c) for m, c in model_costs.items() if c}
|
|
62
|
+
most_expensive = max(avg_by_model, key=avg_by_model.get) # type: ignore[arg-type]
|
|
63
|
+
cheapest = min(avg_by_model, key=avg_by_model.get) # type: ignore[arg-type]
|
|
64
|
+
if avg_by_model[most_expensive] > avg_by_model[cheapest] * 3:
|
|
65
|
+
report.insights.append(
|
|
66
|
+
CreditInsight(
|
|
67
|
+
category="model",
|
|
68
|
+
severity="warn",
|
|
69
|
+
message=(
|
|
70
|
+
f"{most_expensive} costs {avg_by_model[most_expensive]:.4f}/task avg "
|
|
71
|
+
f"vs {cheapest} at {avg_by_model[cheapest]:.4f}/task"
|
|
72
|
+
),
|
|
73
|
+
recommendation=(
|
|
74
|
+
f"Use {cheapest} for routine tasks (audit, lint, simple edits). "
|
|
75
|
+
f"Reserve {most_expensive} for complex architecture/design work."
|
|
76
|
+
),
|
|
77
|
+
estimated_savings_pct=30.0,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# --- Analysis 2: Token waste (high input, low output) ---
|
|
82
|
+
high_input_sessions = [e for e in entries if e.tokens_in > 0 and e.tokens_out > 0]
|
|
83
|
+
for e in high_input_sessions:
|
|
84
|
+
ratio = e.tokens_in / max(e.tokens_out, 1)
|
|
85
|
+
if ratio > 10 and e.tokens_in > 5000:
|
|
86
|
+
report.insights.append(
|
|
87
|
+
CreditInsight(
|
|
88
|
+
category="waste",
|
|
89
|
+
severity="warn",
|
|
90
|
+
message=(
|
|
91
|
+
f"Task '{e.task or 'unknown'}': {e.tokens_in:,} tokens in, "
|
|
92
|
+
f"only {e.tokens_out:,} out (ratio {ratio:.0f}:1)"
|
|
93
|
+
),
|
|
94
|
+
recommendation=(
|
|
95
|
+
"High input/output ratio suggests large file reads for small changes. "
|
|
96
|
+
"Use targeted reads (line ranges) and grep over full file reads."
|
|
97
|
+
),
|
|
98
|
+
estimated_savings_pct=20.0,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
break # One example is enough
|
|
102
|
+
|
|
103
|
+
# --- Analysis 3: Governance file size vs cost ---
|
|
104
|
+
gov_dir = root / "docs" / "governance"
|
|
105
|
+
gov_files = list(gov_dir.glob("*.md")) if gov_dir.is_dir() else []
|
|
106
|
+
total_gov_lines = 0
|
|
107
|
+
for gf in gov_files:
|
|
108
|
+
total_gov_lines += len(gf.read_text(encoding="utf-8").splitlines())
|
|
109
|
+
if total_gov_lines > 500:
|
|
110
|
+
report.insights.append(
|
|
111
|
+
CreditInsight(
|
|
112
|
+
category="governance",
|
|
113
|
+
severity="info",
|
|
114
|
+
message=(
|
|
115
|
+
f"Governance files total {total_gov_lines} lines "
|
|
116
|
+
f"across {len(gov_files)} files."
|
|
117
|
+
),
|
|
118
|
+
recommendation=(
|
|
119
|
+
"Ensure agents lazy-load governance files. Only rules.md + workflow.md "
|
|
120
|
+
"should load at session start. Others on demand."
|
|
121
|
+
),
|
|
122
|
+
estimated_savings_pct=15.0,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# --- Analysis 4: Cost trend ---
|
|
127
|
+
if len(entries) >= 5:
|
|
128
|
+
first_half = entries[: len(entries) // 2]
|
|
129
|
+
second_half = entries[len(entries) // 2 :]
|
|
130
|
+
avg_first = sum(e.estimated_cost_usd for e in first_half) / len(first_half)
|
|
131
|
+
avg_second = sum(e.estimated_cost_usd for e in second_half) / len(second_half)
|
|
132
|
+
if avg_second > avg_first * 1.2:
|
|
133
|
+
report.cost_trend = "increasing"
|
|
134
|
+
report.insights.append(
|
|
135
|
+
CreditInsight(
|
|
136
|
+
category="batch",
|
|
137
|
+
severity="warn",
|
|
138
|
+
message="Cost per task is trending upward.",
|
|
139
|
+
recommendation=(
|
|
140
|
+
"Review recent tasks for scope creep. Consider batching "
|
|
141
|
+
"related changes into fewer sessions."
|
|
142
|
+
),
|
|
143
|
+
estimated_savings_pct=10.0,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
elif avg_second < avg_first * 0.8:
|
|
147
|
+
report.cost_trend = "decreasing"
|
|
148
|
+
else:
|
|
149
|
+
report.cost_trend = "stable"
|
|
150
|
+
|
|
151
|
+
# Estimate optimized cost
|
|
152
|
+
total_savings_pct = sum(i.estimated_savings_pct for i in report.insights) / max(
|
|
153
|
+
len(report.insights), 1
|
|
154
|
+
)
|
|
155
|
+
report.estimated_optimized_cost = report.total_cost * (1 - total_savings_pct / 100)
|
|
156
|
+
|
|
157
|
+
return report
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def generate_analysis_report(root: Path) -> str:
|
|
161
|
+
"""Generate a markdown analysis report."""
|
|
162
|
+
report = analyze_spend(root)
|
|
163
|
+
|
|
164
|
+
md = "# Credit Analysis Report\n\n"
|
|
165
|
+
md += f"- **Total spend**: ${report.total_cost:.4f}\n"
|
|
166
|
+
if report.estimated_optimized_cost < report.total_cost:
|
|
167
|
+
savings = report.total_cost - report.estimated_optimized_cost
|
|
168
|
+
md += (
|
|
169
|
+
f"- **Estimated optimized**: ${report.estimated_optimized_cost:.4f}"
|
|
170
|
+
f" (save ${savings:.4f})\n"
|
|
171
|
+
)
|
|
172
|
+
if report.cost_trend:
|
|
173
|
+
md += f"- **Trend**: {report.cost_trend}\n"
|
|
174
|
+
md += "\n"
|
|
175
|
+
|
|
176
|
+
if report.insights:
|
|
177
|
+
md += "## Insights\n\n"
|
|
178
|
+
for i, insight in enumerate(report.insights, 1):
|
|
179
|
+
icon = {"info": "ℹ️", "warn": "⚠️", "critical": "🔴"}.get(insight.severity, "•")
|
|
180
|
+
md += f"### {i}. {icon} {insight.message}\n\n"
|
|
181
|
+
md += f"**Recommendation**: {insight.recommendation}\n"
|
|
182
|
+
if insight.estimated_savings_pct > 0:
|
|
183
|
+
md += f"**Estimated savings**: {insight.estimated_savings_pct:.0f}%\n"
|
|
184
|
+
md += "\n"
|
|
185
|
+
|
|
186
|
+
return md
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
|
|
3
|
+
"""Credits — AI token/cost spend tracking per project and session."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class CreditEntry:
|
|
15
|
+
"""Single credit usage record."""
|
|
16
|
+
|
|
17
|
+
timestamp: str = ""
|
|
18
|
+
session_id: str = ""
|
|
19
|
+
model: str = ""
|
|
20
|
+
provider: str = "" # openai, anthropic, google, warp, local
|
|
21
|
+
tokens_in: int = 0
|
|
22
|
+
tokens_out: int = 0
|
|
23
|
+
estimated_cost_usd: float = 0.0
|
|
24
|
+
task: str = "" # what was being done
|
|
25
|
+
duration_seconds: float = 0.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CreditBudget:
|
|
30
|
+
"""Budget/alert configuration for a project."""
|
|
31
|
+
|
|
32
|
+
monthly_cap_usd: float = 0.0 # 0 = unlimited
|
|
33
|
+
alert_threshold_pct: int = 80 # warn at this % of cap
|
|
34
|
+
alert_watermarks_usd: list[float] = field(default_factory=lambda: [5.0, 10.0, 25.0, 50.0])
|
|
35
|
+
enabled: bool = True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class CreditSummary:
|
|
40
|
+
"""Aggregate credit summary."""
|
|
41
|
+
|
|
42
|
+
total_tokens_in: int = 0
|
|
43
|
+
total_tokens_out: int = 0
|
|
44
|
+
total_cost_usd: float = 0.0
|
|
45
|
+
session_count: int = 0
|
|
46
|
+
entry_count: int = 0
|
|
47
|
+
by_model: dict[str, float] = field(default_factory=dict)
|
|
48
|
+
by_provider: dict[str, float] = field(default_factory=dict)
|
|
49
|
+
by_task: dict[str, float] = field(default_factory=dict)
|
|
50
|
+
budget: CreditBudget | None = None
|
|
51
|
+
alerts: list[str] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Cost estimation (per 1M tokens, approximate 2026 pricing)
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
_COST_PER_1M: dict[str, tuple[float, float]] = {
|
|
59
|
+
# (input $/1M, output $/1M)
|
|
60
|
+
"gpt-4o": (2.50, 10.00),
|
|
61
|
+
"gpt-4o-mini": (0.15, 0.60),
|
|
62
|
+
"gpt-4-turbo": (10.00, 30.00),
|
|
63
|
+
"claude-sonnet": (3.00, 15.00),
|
|
64
|
+
"claude-haiku": (0.25, 1.25),
|
|
65
|
+
"claude-opus": (15.00, 75.00),
|
|
66
|
+
"gemini-pro": (1.25, 5.00),
|
|
67
|
+
"gemini-flash": (0.075, 0.30),
|
|
68
|
+
"local": (0.0, 0.0),
|
|
69
|
+
"unknown": (3.00, 15.00), # conservative default
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def estimate_cost(model: str, tokens_in: int, tokens_out: int) -> float:
|
|
74
|
+
"""Estimate USD cost for a token usage."""
|
|
75
|
+
key = model.lower()
|
|
76
|
+
# Fuzzy match: find the best matching model key
|
|
77
|
+
for k in _COST_PER_1M:
|
|
78
|
+
if k in key:
|
|
79
|
+
rates = _COST_PER_1M[k]
|
|
80
|
+
return (tokens_in * rates[0] + tokens_out * rates[1]) / 1_000_000
|
|
81
|
+
rates = _COST_PER_1M["unknown"]
|
|
82
|
+
return (tokens_in * rates[0] + tokens_out * rates[1]) / 1_000_000
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Storage — JSON file at .specsmith/credits.json
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
_CREDITS_DIR = ".specsmith"
|
|
90
|
+
_CREDITS_FILE = "credits.json"
|
|
91
|
+
_BUDGET_FILE = "credit-budget.json"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _get_credits_path(root: Path) -> Path:
|
|
95
|
+
"""Get path to credits JSON file, creating dir if needed."""
|
|
96
|
+
path = root / _CREDITS_DIR / _CREDITS_FILE
|
|
97
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
return path
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _get_budget_path(root: Path) -> Path:
|
|
102
|
+
return root / _CREDITS_DIR / _BUDGET_FILE
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _load_entries(root: Path) -> list[CreditEntry]:
|
|
106
|
+
"""Load all credit entries from storage."""
|
|
107
|
+
path = _get_credits_path(root)
|
|
108
|
+
if not path.exists():
|
|
109
|
+
return []
|
|
110
|
+
try:
|
|
111
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
112
|
+
return [CreditEntry(**e) for e in data]
|
|
113
|
+
except Exception: # noqa: BLE001
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _save_entries(root: Path, entries: list[CreditEntry]) -> None:
|
|
118
|
+
"""Save credit entries to storage."""
|
|
119
|
+
path = _get_credits_path(root)
|
|
120
|
+
path.write_text(
|
|
121
|
+
json.dumps([asdict(e) for e in entries], indent=2),
|
|
122
|
+
encoding="utf-8",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_budget(root: Path) -> CreditBudget:
|
|
127
|
+
"""Load budget configuration."""
|
|
128
|
+
path = _get_budget_path(root)
|
|
129
|
+
if not path.exists():
|
|
130
|
+
return CreditBudget()
|
|
131
|
+
try:
|
|
132
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
133
|
+
return CreditBudget(**data)
|
|
134
|
+
except Exception: # noqa: BLE001
|
|
135
|
+
return CreditBudget()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def save_budget(root: Path, budget: CreditBudget) -> None:
|
|
139
|
+
"""Save budget configuration."""
|
|
140
|
+
path = _get_budget_path(root)
|
|
141
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
path.write_text(json.dumps(asdict(budget), indent=2), encoding="utf-8")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# Public API
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def record_usage(
|
|
151
|
+
root: Path,
|
|
152
|
+
*,
|
|
153
|
+
model: str = "unknown",
|
|
154
|
+
provider: str = "unknown",
|
|
155
|
+
tokens_in: int = 0,
|
|
156
|
+
tokens_out: int = 0,
|
|
157
|
+
task: str = "",
|
|
158
|
+
session_id: str = "",
|
|
159
|
+
duration_seconds: float = 0.0,
|
|
160
|
+
cost_usd: float | None = None,
|
|
161
|
+
) -> CreditEntry:
|
|
162
|
+
"""Record a credit usage entry."""
|
|
163
|
+
entry = CreditEntry(
|
|
164
|
+
timestamp=datetime.now().isoformat(),
|
|
165
|
+
session_id=session_id or datetime.now().strftime("%Y%m%d-%H%M"),
|
|
166
|
+
model=model,
|
|
167
|
+
provider=provider,
|
|
168
|
+
tokens_in=tokens_in,
|
|
169
|
+
tokens_out=tokens_out,
|
|
170
|
+
estimated_cost_usd=cost_usd if cost_usd is not None else estimate_cost(
|
|
171
|
+
model, tokens_in, tokens_out
|
|
172
|
+
),
|
|
173
|
+
task=task,
|
|
174
|
+
duration_seconds=duration_seconds,
|
|
175
|
+
)
|
|
176
|
+
entries = _load_entries(root)
|
|
177
|
+
entries.append(entry)
|
|
178
|
+
_save_entries(root, entries)
|
|
179
|
+
return entry
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_summary(
|
|
183
|
+
root: Path, *, since: str = "", month: str = "",
|
|
184
|
+
) -> CreditSummary:
|
|
185
|
+
"""Get aggregate credit summary with budget alerts."""
|
|
186
|
+
entries = _load_entries(root)
|
|
187
|
+
|
|
188
|
+
if since:
|
|
189
|
+
entries = [e for e in entries if e.timestamp >= since]
|
|
190
|
+
if month:
|
|
191
|
+
entries = [e for e in entries if e.timestamp[:7] == month]
|
|
192
|
+
|
|
193
|
+
summary = CreditSummary(entry_count=len(entries))
|
|
194
|
+
sessions: set[str] = set()
|
|
195
|
+
|
|
196
|
+
for e in entries:
|
|
197
|
+
summary.total_tokens_in += e.tokens_in
|
|
198
|
+
summary.total_tokens_out += e.tokens_out
|
|
199
|
+
summary.total_cost_usd += e.estimated_cost_usd
|
|
200
|
+
sessions.add(e.session_id)
|
|
201
|
+
|
|
202
|
+
# By model
|
|
203
|
+
summary.by_model[e.model] = summary.by_model.get(e.model, 0.0) + e.estimated_cost_usd
|
|
204
|
+
# By provider
|
|
205
|
+
summary.by_provider[e.provider] = (
|
|
206
|
+
summary.by_provider.get(e.provider, 0.0) + e.estimated_cost_usd
|
|
207
|
+
)
|
|
208
|
+
# By task
|
|
209
|
+
if e.task:
|
|
210
|
+
summary.by_task[e.task] = summary.by_task.get(e.task, 0.0) + e.estimated_cost_usd
|
|
211
|
+
|
|
212
|
+
summary.session_count = len(sessions)
|
|
213
|
+
|
|
214
|
+
# Budget alerts
|
|
215
|
+
budget = load_budget(root)
|
|
216
|
+
summary.budget = budget
|
|
217
|
+
if budget.enabled and budget.monthly_cap_usd > 0:
|
|
218
|
+
current_month = datetime.now().strftime("%Y-%m")
|
|
219
|
+
month_entries = [e for e in _load_entries(root) if e.timestamp[:7] == current_month]
|
|
220
|
+
month_cost = sum(e.estimated_cost_usd for e in month_entries)
|
|
221
|
+
|
|
222
|
+
pct = (month_cost / budget.monthly_cap_usd) * 100 if budget.monthly_cap_usd else 0
|
|
223
|
+
if pct >= 100:
|
|
224
|
+
summary.alerts.append(
|
|
225
|
+
f"BUDGET EXCEEDED: ${month_cost:.2f} / ${budget.monthly_cap_usd:.2f} "
|
|
226
|
+
f"({pct:.0f}%)"
|
|
227
|
+
)
|
|
228
|
+
elif pct >= budget.alert_threshold_pct:
|
|
229
|
+
summary.alerts.append(
|
|
230
|
+
f"Budget warning: ${month_cost:.2f} / ${budget.monthly_cap_usd:.2f} "
|
|
231
|
+
f"({pct:.0f}%) — approaching cap"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Watermark alerts
|
|
235
|
+
for watermark in sorted(budget.alert_watermarks_usd):
|
|
236
|
+
if month_cost >= watermark:
|
|
237
|
+
summary.alerts.append(f"Watermark: ${watermark:.2f} spend reached this month")
|
|
238
|
+
|
|
239
|
+
return summary
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def generate_report(root: Path, *, since: str = "") -> str:
|
|
243
|
+
"""Generate a markdown credit report."""
|
|
244
|
+
summary = get_summary(root, since=since)
|
|
245
|
+
|
|
246
|
+
report = "# AI Credit Report\n\n"
|
|
247
|
+
report += f"- **Total tokens in**: {summary.total_tokens_in:,}\n"
|
|
248
|
+
report += f"- **Total tokens out**: {summary.total_tokens_out:,}\n"
|
|
249
|
+
report += f"- **Estimated cost**: ${summary.total_cost_usd:.4f}\n"
|
|
250
|
+
report += f"- **Sessions**: {summary.session_count}\n"
|
|
251
|
+
report += f"- **Entries**: {summary.entry_count}\n\n"
|
|
252
|
+
|
|
253
|
+
if summary.alerts:
|
|
254
|
+
report += "## Alerts\n\n"
|
|
255
|
+
for alert in summary.alerts:
|
|
256
|
+
report += f"- ⚠️ {alert}\n"
|
|
257
|
+
report += "\n"
|
|
258
|
+
|
|
259
|
+
if summary.by_model:
|
|
260
|
+
report += "## Cost by Model\n\n"
|
|
261
|
+
for model, cost in sorted(summary.by_model.items(), key=lambda x: -x[1]):
|
|
262
|
+
report += f"- {model}: ${cost:.4f}\n"
|
|
263
|
+
report += "\n"
|
|
264
|
+
|
|
265
|
+
if summary.by_provider:
|
|
266
|
+
report += "## Cost by Provider\n\n"
|
|
267
|
+
for provider, cost in sorted(summary.by_provider.items(), key=lambda x: -x[1]):
|
|
268
|
+
report += f"- {provider}: ${cost:.4f}\n"
|
|
269
|
+
report += "\n"
|
|
270
|
+
|
|
271
|
+
if summary.by_task:
|
|
272
|
+
report += "## Cost by Task\n\n"
|
|
273
|
+
for task, cost in sorted(summary.by_task.items(), key=lambda x: -x[1]):
|
|
274
|
+
report += f"- {task}: ${cost:.4f}\n"
|
|
275
|
+
report += "\n"
|
|
276
|
+
|
|
277
|
+
if summary.budget and summary.budget.monthly_cap_usd > 0:
|
|
278
|
+
report += "## Budget\n\n"
|
|
279
|
+
report += f"- Monthly cap: ${summary.budget.monthly_cap_usd:.2f}\n"
|
|
280
|
+
report += f"- Alert at: {summary.budget.alert_threshold_pct}%\n"
|
|
281
|
+
wm = ", ".join(f"${w:.2f}" for w in summary.budget.alert_watermarks_usd)
|
|
282
|
+
report += f"- Watermarks: {wm}\n"
|
|
283
|
+
|
|
284
|
+
return report
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/docs/architecture.md.j2
RENAMED
|
File without changes
|
{specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/docs/requirements.md.j2
RENAMED
|
File without changes
|
{specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/docs/test-spec.md.j2
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/governance/roles.md.j2
RENAMED
|
File without changes
|
{specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/governance/rules.md.j2
RENAMED
|
File without changes
|
|
File without changes
|
{specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/governance/workflow.md.j2
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{specsmith-0.1.4.dev33 → specsmith-0.1.4.dev34}/src/specsmith/templates/scripts/setup.cmd.j2
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|