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.
- {specsmith-0.1.4.dev33/src/specsmith.egg-info → specsmith-0.1.4.dev35}/PKG-INFO +1 -1
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/pyproject.toml +1 -1
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/cli.py +135 -0
- specsmith-0.1.4.dev35/src/specsmith/credit_analyzer.py +186 -0
- specsmith-0.1.4.dev35/src/specsmith/credits.py +284 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/importer.py +9 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/claude_code.py +6 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/warp.py +9 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/scaffolder.py +6 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/session.py +23 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/gitignore.j2 +3 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/governance/context-budget.md.j2 +11 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35/src/specsmith.egg-info}/PKG-INFO +1 -1
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith.egg-info/SOURCES.txt +2 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/LICENSE +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/README.md +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/setup.cfg +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/__init__.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/__main__.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/architect.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/auditor.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/commands/__init__.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/compressor.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/config.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/differ.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/doctor.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/exporter.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/__init__.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/aider.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/base.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/copilot.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/cursor.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/gemini.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/integrations/windsurf.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/ledger.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/plugins.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/releaser.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/requirements.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/agents.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/docs/architecture.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/docs/requirements.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/docs/test-spec.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/docs/workflow.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/editorconfig.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/gitattributes.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/governance/drift-metrics.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/governance/roles.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/governance/rules.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/governance/verification.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/governance/workflow.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/ledger.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/pyproject.toml.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/python/cli.py.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/python/init.py.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/readme.md.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/scripts/exec.cmd.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/scripts/exec.sh.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/scripts/run.cmd.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/scripts/run.sh.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/scripts/setup.cmd.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/scripts/setup.sh.j2 +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/tools.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/updater.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/upgrader.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/validator.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/vcs/__init__.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/vcs/base.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/vcs/bitbucket.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/vcs/github.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/vcs/gitlab.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/vcs_commands.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith.egg-info/dependency_links.txt +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith.egg-info/entry_points.txt +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith.egg-info/requires.txt +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith.egg-info/top_level.txt +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_auditor.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_cli.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_compressor.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_importer.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_integrations.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_scaffolder.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_smoke.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_tools.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/tests/test_validator.py +0 -0
- {specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/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.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
|
|
@@ -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).
|
|
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.dev35}/src/specsmith/templates/docs/architecture.md.j2
RENAMED
|
File without changes
|
{specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/docs/requirements.md.j2
RENAMED
|
File without changes
|
{specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/docs/test-spec.md.j2
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/governance/roles.md.j2
RENAMED
|
File without changes
|
{specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/src/specsmith/templates/governance/rules.md.j2
RENAMED
|
File without changes
|
|
File without changes
|
{specsmith-0.1.4.dev33 → specsmith-0.1.4.dev35}/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.dev35}/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
|