specsmith 0.1.4.dev33__tar.gz → 0.1.4.dev35__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.
Files changed (85) hide show
  1. {specsmith-0.1.4.dev33/src/specsmith.egg-info → specsmith-0.1.4.dev35}/PKG-INFO +1 -1
  2. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/pyproject.toml +1 -1
  3. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/cli.py +135 -0
  4. specsmith-0.1.4.dev35/src/specsmith/credit_analyzer.py +186 -0
  5. specsmith-0.1.4.dev35/src/specsmith/credits.py +284 -0
  6. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/importer.py +9 -0
  7. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/claude_code.py +6 -0
  8. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/warp.py +9 -0
  9. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/scaffolder.py +6 -0
  10. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/session.py +23 -0
  11. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/gitignore.j2 +3 -0
  12. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/governance/context-budget.md.j2 +11 -0
  13. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35/src/specsmith.egg-info}/PKG-INFO +1 -1
  14. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith.egg-info/SOURCES.txt +2 -0
  15. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/LICENSE +0 -0
  16. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/README.md +0 -0
  17. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/setup.cfg +0 -0
  18. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/__init__.py +0 -0
  19. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/__main__.py +0 -0
  20. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/architect.py +0 -0
  21. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/auditor.py +0 -0
  22. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/commands/__init__.py +0 -0
  23. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/compressor.py +0 -0
  24. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/config.py +0 -0
  25. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/differ.py +0 -0
  26. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/doctor.py +0 -0
  27. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/exporter.py +0 -0
  28. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/__init__.py +0 -0
  29. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/aider.py +0 -0
  30. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/base.py +0 -0
  31. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/copilot.py +0 -0
  32. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/cursor.py +0 -0
  33. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/gemini.py +0 -0
  34. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/windsurf.py +0 -0
  35. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/ledger.py +0 -0
  36. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/plugins.py +0 -0
  37. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/releaser.py +0 -0
  38. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/requirements.py +0 -0
  39. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/agents.md.j2 +0 -0
  40. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/docs/architecture.md.j2 +0 -0
  41. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/docs/requirements.md.j2 +0 -0
  42. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/docs/test-spec.md.j2 +0 -0
  43. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/docs/workflow.md.j2 +0 -0
  44. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/editorconfig.j2 +0 -0
  45. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/gitattributes.j2 +0 -0
  46. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/governance/drift-metrics.md.j2 +0 -0
  47. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/governance/roles.md.j2 +0 -0
  48. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/governance/rules.md.j2 +0 -0
  49. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/governance/verification.md.j2 +0 -0
  50. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/governance/workflow.md.j2 +0 -0
  51. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/ledger.md.j2 +0 -0
  52. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/pyproject.toml.j2 +0 -0
  53. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/python/cli.py.j2 +0 -0
  54. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/python/init.py.j2 +0 -0
  55. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/readme.md.j2 +0 -0
  56. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/scripts/exec.cmd.j2 +0 -0
  57. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/scripts/exec.sh.j2 +0 -0
  58. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/scripts/run.cmd.j2 +0 -0
  59. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/scripts/run.sh.j2 +0 -0
  60. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/scripts/setup.cmd.j2 +0 -0
  61. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/scripts/setup.sh.j2 +0 -0
  62. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/tools.py +0 -0
  63. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/updater.py +0 -0
  64. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/upgrader.py +0 -0
  65. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/validator.py +0 -0
  66. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/vcs/__init__.py +0 -0
  67. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/vcs/base.py +0 -0
  68. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/vcs/bitbucket.py +0 -0
  69. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/vcs/github.py +0 -0
  70. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/vcs/gitlab.py +0 -0
  71. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/vcs_commands.py +0 -0
  72. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith.egg-info/dependency_links.txt +0 -0
  73. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith.egg-info/entry_points.txt +0 -0
  74. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith.egg-info/requires.txt +0 -0
  75. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith.egg-info/top_level.txt +0 -0
  76. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_auditor.py +0 -0
  77. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_cli.py +0 -0
  78. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_compressor.py +0 -0
  79. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_importer.py +0 -0
  80. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_integrations.py +0 -0
  81. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_scaffolder.py +0 -0
  82. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_smoke.py +0 -0
  83. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_tools.py +0 -0
  84. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_validator.py +0 -0
  85. {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_vcs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specsmith
3
- Version: 0.1.4.dev33
3
+ Version: 0.1.4.dev35
4
4
  Summary: Forge governed project scaffolds from the Agentic AI Development Workflow Specification.
5
5
  Author: BitConcepts
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "specsmith"
7
- version = "0.1.4.dev33"
7
+ version = "0.1.4.dev35"
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
@@ -168,6 +168,7 @@ def detect_project(root: Path) -> DetectionResult:
168
168
  ".ruff_cache",
169
169
  ".mypy_cache",
170
170
  ".work",
171
+ ".specsmith",
171
172
  "build",
172
173
  "dist",
173
174
  "target",
@@ -1117,6 +1118,14 @@ def generate_overlay(
1117
1118
  agents_path.write_text(hub, encoding="utf-8")
1118
1119
  created.append(agents_path)
1119
1120
 
1121
+ # Initialize credit tracking with unlimited budget
1122
+ specsmith_dir = target / ".specsmith"
1123
+ if not specsmith_dir.exists():
1124
+ from specsmith.credits import CreditBudget, save_budget
1125
+
1126
+ save_budget(target, CreditBudget()) # unlimited by default
1127
+ created.append(target / ".specsmith" / "credit-budget.json")
1128
+
1120
1129
  # --- CI config (merge: only create if no CI detected) ---
1121
1130
  if not result.existing_ci and result.vcs_platform:
1122
1131
  try:
@@ -78,4 +78,10 @@ When user says `sync`: run `specsmith sync --project-dir .`
78
78
  When user says `pr`: run `specsmith pr --project-dir .`
79
79
  When user says `audit`: run `specsmith audit --project-dir .`
80
80
  When user says `session-end`: run `specsmith session-end --project-dir .`
81
+
82
+ ## Credit Tracking
83
+ At session end, record token usage:
84
+ `specsmith credits record --model <model> --provider anthropic \
85
+ --tokens-in <N> --tokens-out <N> --task "<desc>"`
86
+ Check budget: `specsmith credits summary`
81
87
  """
@@ -84,10 +84,19 @@ When user says `session-end`: run `specsmith session-end --project-dir .`
84
84
  ## Verification
85
85
  Before marking any task complete, run: {verify_line}
86
86
 
87
+ ## Credit Tracking
88
+ After completing tasks, record token usage:
89
+ ```
90
+ specsmith credits record --model <model> --provider <provider> \
91
+ --tokens-in <N> --tokens-out <N> --task "<desc>"
92
+ ```
93
+ Check budget: `specsmith credits summary`
94
+
87
95
  ## Rules
88
96
  - Proposals before changes (no exceptions)
89
97
  - Verify before recording completion
90
98
  - Use execution shims (`scripts/exec.cmd` / `scripts/exec.sh`) for external commands
91
99
  - Keep AGENTS.md under 200 lines
92
100
  - Record every session in the ledger
101
+ - Record credit usage at session end
93
102
  """
@@ -80,6 +80,12 @@ def scaffold_project(config: ProjectConfig, target: Path) -> list[Path]:
80
80
  except ValueError:
81
81
  pass # Unknown platform — skip silently
82
82
 
83
+ # Initialize credit tracking with unlimited budget
84
+ from specsmith.credits import CreditBudget, save_budget
85
+
86
+ save_budget(target, CreditBudget()) # unlimited by default
87
+ created.append(target / ".specsmith" / "credit-budget.json")
88
+
83
89
  # Git init
84
90
  if config.git_init:
85
91
  subprocess.run( # noqa: S603
@@ -127,4 +127,27 @@ def run_session_end(root: Path) -> SessionReport:
127
127
  SessionCheck(name="audit", status="warn", message="Could not run audit")
128
128
  )
129
129
 
130
+ # Credit spend summary for this session
131
+ try:
132
+ from specsmith.credits import get_summary
133
+
134
+ cs = get_summary(root)
135
+ if cs.entry_count > 0:
136
+ report.checks.append(
137
+ SessionCheck(
138
+ name="credits",
139
+ status="ok",
140
+ message=(
141
+ f"Credits: ${cs.total_cost_usd:.4f} total, "
142
+ f"{cs.session_count} session(s)"
143
+ ),
144
+ )
145
+ )
146
+ for alert in cs.alerts:
147
+ report.checks.append(
148
+ SessionCheck(name="credit-alert", status="warn", message=alert)
149
+ )
150
+ except Exception: # noqa: BLE001
151
+ pass # Credits not configured — skip silently
152
+
130
153
  return report
@@ -36,6 +36,9 @@ htmlcov/
36
36
  # Build artifacts
37
37
  .work/
38
38
  *.log
39
+
40
+ # specsmith local data (credits, config)
41
+ .specsmith/
39
42
  {% if project.type.value in ('cli-rust', 'library-rust') %}
40
43
 
41
44
  # Rust
@@ -48,3 +48,14 @@ If a cheaper check fails, fix that before running more expensive checks.
48
48
  - **low** — docs-only, single-file edits, small scaffolds
49
49
  - **medium** — multi-file implementation, routine refactors, standard test runs
50
50
  - **high** — architecture changes, large builds, broad audits
51
+
52
+ ## Credit tracking
53
+
54
+ This project tracks AI credit spend automatically. At the end of each session:
55
+
56
+ 1. Record usage: `specsmith credits record --model <model> --provider <provider> --tokens-in <N> --tokens-out <N> --task "<description>"`
57
+ 2. Check budget: `specsmith credits summary`
58
+ 3. If budget alerts appear, review with: `specsmith credits analyze`
59
+
60
+ Budget configuration: `specsmith credits budget --cap <USD> --alert-pct 80`
61
+ Credit data stored in `.specsmith/credits.json` (gitignored).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specsmith
3
- Version: 0.1.4.dev33
3
+ Version: 0.1.4.dev35
4
4
  Summary: Forge governed project scaffolds from the Agentic AI Development Workflow Specification.
5
5
  Author: BitConcepts
6
6
  License: MIT
@@ -8,6 +8,8 @@ src/specsmith/auditor.py
8
8
  src/specsmith/cli.py
9
9
  src/specsmith/compressor.py
10
10
  src/specsmith/config.py
11
+ src/specsmith/credit_analyzer.py
12
+ src/specsmith/credits.py
11
13
  src/specsmith/differ.py
12
14
  src/specsmith/doctor.py
13
15
  src/specsmith/exporter.py
File without changes