devcouncil 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +197 -494
  3. package/package.json +9 -2
  4. package/pyproject.toml +62 -27
  5. package/src/devcouncil/__main__.py +4 -4
  6. package/src/devcouncil/app/__init__.py +28 -28
  7. package/src/devcouncil/app/config.py +297 -108
  8. package/src/devcouncil/app/errors.py +23 -23
  9. package/src/devcouncil/app/events.py +44 -44
  10. package/src/devcouncil/app/orchestrator.py +67 -67
  11. package/src/devcouncil/app/project_status.py +29 -0
  12. package/src/devcouncil/app/run_context.py +39 -39
  13. package/src/devcouncil/app/state_machine.py +108 -108
  14. package/src/devcouncil/artifacts/__init__.py +1 -1
  15. package/src/devcouncil/artifacts/coverage.py +96 -96
  16. package/src/devcouncil/artifacts/graph.py +163 -143
  17. package/src/devcouncil/artifacts/migrations.py +20 -20
  18. package/src/devcouncil/artifacts/schemas.py +23 -23
  19. package/src/devcouncil/artifacts/serializer.py +21 -21
  20. package/src/devcouncil/artifacts/validators.py +27 -27
  21. package/src/devcouncil/assets/__init__.py +1 -0
  22. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  23. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  24. package/src/devcouncil/cli/commands/agents.py +292 -0
  25. package/src/devcouncil/cli/commands/artifacts.py +54 -48
  26. package/src/devcouncil/cli/commands/ast.py +22 -0
  27. package/src/devcouncil/cli/commands/baseline.py +35 -32
  28. package/src/devcouncil/cli/commands/check.py +209 -0
  29. package/src/devcouncil/cli/commands/config.py +115 -54
  30. package/src/devcouncil/cli/commands/cost.py +57 -0
  31. package/src/devcouncil/cli/commands/dashboard.py +31 -0
  32. package/src/devcouncil/cli/commands/doctor.py +291 -47
  33. package/src/devcouncil/cli/commands/evidence.py +48 -0
  34. package/src/devcouncil/cli/commands/go.py +656 -0
  35. package/src/devcouncil/cli/commands/handoff.py +69 -0
  36. package/src/devcouncil/cli/commands/hook.py +209 -33
  37. package/src/devcouncil/cli/commands/init.py +204 -57
  38. package/src/devcouncil/cli/commands/integrate.py +1171 -76
  39. package/src/devcouncil/cli/commands/lsp.py +20 -0
  40. package/src/devcouncil/cli/commands/map.py +96 -22
  41. package/src/devcouncil/cli/commands/plan.py +422 -210
  42. package/src/devcouncil/cli/commands/prompt.py +48 -34
  43. package/src/devcouncil/cli/commands/repair.py +89 -69
  44. package/src/devcouncil/cli/commands/report.py +120 -54
  45. package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
  46. package/src/devcouncil/cli/commands/rollback.py +55 -54
  47. package/src/devcouncil/cli/commands/run.py +285 -220
  48. package/src/devcouncil/cli/commands/runs.py +223 -0
  49. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  50. package/src/devcouncil/cli/commands/semantic.py +47 -0
  51. package/src/devcouncil/cli/commands/setup.py +300 -20
  52. package/src/devcouncil/cli/commands/shell.py +73 -0
  53. package/src/devcouncil/cli/commands/show.py +76 -57
  54. package/src/devcouncil/cli/commands/skills.py +88 -0
  55. package/src/devcouncil/cli/commands/status.py +141 -105
  56. package/src/devcouncil/cli/commands/tasks.py +55 -41
  57. package/src/devcouncil/cli/commands/trace.py +49 -4
  58. package/src/devcouncil/cli/commands/verify.py +293 -128
  59. package/src/devcouncil/cli/commands/version.py +20 -20
  60. package/src/devcouncil/cli/commands/watch.py +574 -0
  61. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  62. package/src/devcouncil/cli/main.py +92 -25
  63. package/src/devcouncil/council/prompts/arbiter.md +19 -19
  64. package/src/devcouncil/council/prompts/critic_a.md +10 -10
  65. package/src/devcouncil/council/prompts/critic_b.md +10 -10
  66. package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
  67. package/src/devcouncil/council/prompts/planner_a.md +16 -16
  68. package/src/devcouncil/council/prompts/planner_b.md +16 -16
  69. package/src/devcouncil/council/prompts/rebuttal.md +10 -10
  70. package/src/devcouncil/council/prompts/spec_writer.md +12 -12
  71. package/src/devcouncil/domain/assumption.py +17 -17
  72. package/src/devcouncil/domain/critique.py +32 -32
  73. package/src/devcouncil/domain/evidence.py +47 -27
  74. package/src/devcouncil/domain/gap.py +52 -26
  75. package/src/devcouncil/domain/requirement.py +22 -22
  76. package/src/devcouncil/domain/task.py +55 -26
  77. package/src/devcouncil/execution/__init__.py +1 -1
  78. package/src/devcouncil/execution/checkpoints.py +246 -0
  79. package/src/devcouncil/execution/context_builder.py +54 -54
  80. package/src/devcouncil/execution/executor.py +15 -15
  81. package/src/devcouncil/execution/fs_watcher.py +180 -0
  82. package/src/devcouncil/execution/handoff.py +102 -0
  83. package/src/devcouncil/execution/hook_policy.py +186 -77
  84. package/src/devcouncil/execution/patch.py +77 -28
  85. package/src/devcouncil/execution/permissions.py +52 -59
  86. package/src/devcouncil/execution/policy_engine.py +343 -0
  87. package/src/devcouncil/execution/prompt_builder.py +650 -38
  88. package/src/devcouncil/execution/shell_session.py +225 -0
  89. package/src/devcouncil/execution/task_runner.py +68 -64
  90. package/src/devcouncil/executors/__init__.py +1 -1
  91. package/src/devcouncil/executors/agent_registry.py +575 -0
  92. package/src/devcouncil/executors/coding_cli.py +736 -0
  93. package/src/devcouncil/executors/mini_swe.py +63 -63
  94. package/src/devcouncil/executors/native/agent.py +186 -85
  95. package/src/devcouncil/executors/openhands.py +56 -56
  96. package/src/devcouncil/gating/__init__.py +1 -1
  97. package/src/devcouncil/gating/checks/clean_git.py +52 -45
  98. package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
  99. package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
  100. package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
  101. package/src/devcouncil/gating/policy.py +315 -167
  102. package/src/devcouncil/hardware.py +184 -0
  103. package/src/devcouncil/indexing/__init__.py +1 -1
  104. package/src/devcouncil/indexing/ast_matcher.py +168 -0
  105. package/src/devcouncil/indexing/graph_index.py +48 -48
  106. package/src/devcouncil/indexing/lsp.py +161 -0
  107. package/src/devcouncil/indexing/repo_mapper.py +1455 -204
  108. package/src/devcouncil/indexing/semantic_index.py +205 -0
  109. package/src/devcouncil/integrations/actions.py +146 -0
  110. package/src/devcouncil/integrations/check.py +423 -0
  111. package/src/devcouncil/integrations/github.py +35 -35
  112. package/src/devcouncil/integrations/github_intent.py +142 -0
  113. package/src/devcouncil/integrations/gitnexus.py +62 -27
  114. package/src/devcouncil/integrations/graphify.py +34 -34
  115. package/src/devcouncil/integrations/mcp/server.py +2072 -96
  116. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  117. package/src/devcouncil/integrations/pr_comments.py +62 -0
  118. package/src/devcouncil/live/__init__.py +2 -0
  119. package/src/devcouncil/live/cards.py +349 -0
  120. package/src/devcouncil/live/models.py +63 -0
  121. package/src/devcouncil/live/repair_prompt.py +83 -0
  122. package/src/devcouncil/live/reviewer.py +70 -0
  123. package/src/devcouncil/live/signals.py +135 -0
  124. package/src/devcouncil/live/summary.py +34 -0
  125. package/src/devcouncil/live/tasks.py +18 -0
  126. package/src/devcouncil/live/transcripts.py +141 -0
  127. package/src/devcouncil/llm/__init__.py +1 -1
  128. package/src/devcouncil/llm/cache.py +42 -38
  129. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  130. package/src/devcouncil/llm/provider.py +627 -125
  131. package/src/devcouncil/llm/router.py +303 -118
  132. package/src/devcouncil/optimization/__init__.py +1 -0
  133. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  134. package/src/devcouncil/planning/__init__.py +1 -1
  135. package/src/devcouncil/planning/arbiter_service.py +57 -57
  136. package/src/devcouncil/planning/correction_manifest.py +303 -0
  137. package/src/devcouncil/planning/critique_service.py +71 -66
  138. package/src/devcouncil/planning/plan_service.py +60 -46
  139. package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
  140. package/src/devcouncil/planning/repair_service.py +39 -39
  141. package/src/devcouncil/planning/spec_service.py +70 -44
  142. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  143. package/src/devcouncil/repo/gitignore.py +123 -0
  144. package/src/devcouncil/repo/sca.py +374 -0
  145. package/src/devcouncil/reporting/github_check.py +32 -32
  146. package/src/devcouncil/reporting/json_report.py +30 -17
  147. package/src/devcouncil/reporting/markdown_report.py +83 -46
  148. package/src/devcouncil/reporting/report_builder.py +14 -14
  149. package/src/devcouncil/skills/__init__.py +19 -0
  150. package/src/devcouncil/skills/library/README.md +46 -0
  151. package/src/devcouncil/skills/library/ai-training.md +50 -0
  152. package/src/devcouncil/skills/library/android.md +50 -0
  153. package/src/devcouncil/skills/library/backend.md +52 -0
  154. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  155. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  156. package/src/devcouncil/skills/library/desktop.md +46 -0
  157. package/src/devcouncil/skills/library/devops.md +48 -0
  158. package/src/devcouncil/skills/library/game-dev.md +46 -0
  159. package/src/devcouncil/skills/library/ios.md +48 -0
  160. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  161. package/src/devcouncil/skills/library/security.md +48 -0
  162. package/src/devcouncil/skills/library/systems.md +48 -0
  163. package/src/devcouncil/skills/library/web.md +47 -0
  164. package/src/devcouncil/skills/library/windows.md +47 -0
  165. package/src/devcouncil/skills/registry.py +330 -0
  166. package/src/devcouncil/storage/db.py +147 -66
  167. package/src/devcouncil/storage/models.py +204 -83
  168. package/src/devcouncil/storage/native.py +557 -0
  169. package/src/devcouncil/storage/repositories.py +388 -249
  170. package/src/devcouncil/telemetry/cost.py +140 -34
  171. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  172. package/src/devcouncil/telemetry/pricing.py +28 -0
  173. package/src/devcouncil/telemetry/traces.py +62 -7
  174. package/src/devcouncil/telemetry/tracker.py +52 -49
  175. package/src/devcouncil/ui/__init__.py +1 -0
  176. package/src/devcouncil/ui/dashboard.py +423 -0
  177. package/src/devcouncil/utils/__init__.py +1 -1
  178. package/src/devcouncil/utils/redaction.py +147 -141
  179. package/src/devcouncil/utils/subprocess_env.py +69 -0
  180. package/src/devcouncil/verification/__init__.py +1 -1
  181. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  182. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  183. package/src/devcouncil/verification/diff_coverage.py +353 -0
  184. package/src/devcouncil/verification/implementation_reviewer.py +55 -55
  185. package/src/devcouncil/verification/next_actions.py +189 -0
  186. package/src/devcouncil/verification/sandbox.py +178 -0
  187. package/src/devcouncil/verification/test_resolver.py +91 -0
  188. package/src/devcouncil/verification/verifier.py +1342 -307
  189. package/uv.lock +205 -64
  190. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,34 +1,140 @@
1
- import logging
2
- from typing import Dict
3
-
4
- logger = logging.getLogger(__name__)
5
-
6
- class CostEstimator:
7
- """Estimates LLM usage cost based on provider pricing."""
8
-
9
- # Rough estimates, update for production
10
- PRICING = {
11
- "anthropic/claude-3.5-sonnet": {"input": 0.000003, "output": 0.000015},
12
- "anthropic/claude-3-opus": {"input": 0.000015, "output": 0.000075},
13
- "anthropic/claude-sonnet-4": {"input": 0.000003, "output": 0.000015},
14
- "google/gemini-pro-1.5": {"input": 0.00000125, "output": 0.000005},
15
- "google/gemini-2.5-pro": {"input": 0.00000125, "output": 0.00001},
16
- "openai/gpt-4o": {"input": 0.000005, "output": 0.000015},
17
- "openai/o3-mini": {"input": 0.0000011, "output": 0.0000044},
18
- }
19
-
20
- # Conservative default for unknown models
21
- DEFAULT_PRICING = {"input": 0.000005, "output": 0.000015}
22
-
23
- @classmethod
24
- def estimate_cost(cls, model: str, usage: Dict[str, int]) -> float:
25
- prices = cls.PRICING.get(model)
26
- if not prices:
27
- logger.debug("Unknown model for cost estimation: %s — using default pricing", model)
28
- prices = cls.DEFAULT_PRICING
29
-
30
- prompt_tokens = usage.get("prompt_tokens", 0)
31
- completion_tokens = usage.get("completion_tokens", 0)
32
-
33
- cost = (prompt_tokens * prices["input"]) + (completion_tokens * prices["output"])
34
- return cost
1
+ import json
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List
5
+
6
+ from devcouncil.telemetry.pricing import pricing_for_model
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ UNATTRIBUTED = "(unattributed)"
11
+
12
+
13
+ class CostEstimator:
14
+ """Estimates LLM usage cost based on provider pricing."""
15
+
16
+ # Conservative default for unknown models
17
+ DEFAULT_PRICING = {"prompt_per_1k": 0.005, "completion_per_1k": 0.015}
18
+
19
+ # Local providers run on-device and incur no per-token cost. Ollama model ids
20
+ # are open-ended (e.g. ``qwen2.5-coder:7b`` or an ``ollama/<name>`` form), so
21
+ # match the conventional prefixes rather than relying on the open-ended yaml.
22
+ LOCAL_MODEL_PREFIXES = ("ollama/", "ollama:")
23
+
24
+ @classmethod
25
+ def _is_local_model(cls, model: str) -> bool:
26
+ return model.startswith(cls.LOCAL_MODEL_PREFIXES)
27
+
28
+ @classmethod
29
+ def estimate_cost(cls, model: str, usage: Dict[str, int]) -> float:
30
+ # Local/Ollama models are free regardless of yaml coverage; short-circuit
31
+ # before the conservative DEFAULT_PRICING fallback would bill them.
32
+ if cls._is_local_model(model):
33
+ return 0.0
34
+ prices = pricing_for_model(model, cls.DEFAULT_PRICING)
35
+ if prices == cls.DEFAULT_PRICING:
36
+ logger.debug("Unknown model for cost estimation: %s — using default pricing", model)
37
+
38
+ prompt_tokens = usage.get("prompt_tokens", 0)
39
+ completion_tokens = usage.get("completion_tokens", 0)
40
+
41
+ cost = ((prompt_tokens / 1000.0) * prices["prompt_per_1k"]) + (
42
+ (completion_tokens / 1000.0) * prices["completion_per_1k"]
43
+ )
44
+ return cost
45
+
46
+
47
+ def _model_calls_file(project_root: Path) -> Path:
48
+ return project_root / ".devcouncil" / "logs" / "model_calls.jsonl"
49
+
50
+
51
+ def read_cost_records(project_root: Path) -> List[Dict[str, Any]]:
52
+ """Read the model-call ledger and attach an estimated cost to each record.
53
+
54
+ Never raises: malformed lines are skipped. Records missing ``task_id`` /
55
+ ``run_id`` (older entries written before per-task attribution) keep those as
56
+ ``None`` so callers can bucket them under ``(unattributed)``.
57
+ """
58
+ log_file = _model_calls_file(project_root)
59
+ records: List[Dict[str, Any]] = []
60
+ if not log_file.exists():
61
+ return records
62
+ try:
63
+ lines = log_file.read_text(encoding="utf-8").splitlines()
64
+ except Exception as exc:
65
+ logger.debug("Failed to read model_calls ledger: %s", exc)
66
+ return records
67
+
68
+ for line in lines:
69
+ if not line.strip():
70
+ continue
71
+ try:
72
+ entry = json.loads(line)
73
+ except Exception as exc:
74
+ logger.debug("Skipping invalid model_calls line: %s", exc)
75
+ continue
76
+ model = ""
77
+ response = entry.get("response")
78
+ if isinstance(response, dict):
79
+ model = str(response.get("model", "") or "")
80
+ raw_usage = entry.get("usage")
81
+ usage: Dict[str, Any] = raw_usage if isinstance(raw_usage, dict) else {}
82
+ # Local providers (ollama) are always free, regardless of the open-ended model
83
+ # tag Ollama echoes back (e.g. ``mistral:latest``) — trust the recorded provider
84
+ # over fragile model-id prefix matching.
85
+ provider = str(entry.get("provider") or "")
86
+ try:
87
+ cost = 0.0 if provider == "ollama" else CostEstimator.estimate_cost(model, usage)
88
+ except Exception:
89
+ cost = 0.0
90
+ records.append(
91
+ {
92
+ "task_id": entry.get("task_id"),
93
+ "run_id": entry.get("run_id"),
94
+ "timestamp": entry.get("timestamp"),
95
+ "model": model,
96
+ "usage": usage,
97
+ "cost": cost,
98
+ }
99
+ )
100
+ return records
101
+
102
+
103
+ def _group(records: List[Dict[str, Any]], key: str) -> Dict[str, Dict[str, Any]]:
104
+ groups: Dict[str, Dict[str, Any]] = {}
105
+ for record in records:
106
+ raw = record.get(key)
107
+ bucket = str(raw) if isinstance(raw, str) and raw else UNATTRIBUTED
108
+ group = groups.setdefault(
109
+ bucket,
110
+ {"cost": 0.0, "calls": 0, "prompt_tokens": 0, "completion_tokens": 0},
111
+ )
112
+ raw_usage = record.get("usage")
113
+ usage: Dict[str, Any] = raw_usage if isinstance(raw_usage, dict) else {}
114
+ group["cost"] += float(record.get("cost", 0.0) or 0.0)
115
+ group["calls"] += 1
116
+ group["prompt_tokens"] += int(usage.get("prompt_tokens", 0) or 0)
117
+ group["completion_tokens"] += int(usage.get("completion_tokens", 0) or 0)
118
+ return groups
119
+
120
+
121
+ def group_cost(project_root: Path) -> Dict[str, Any]:
122
+ """Aggregate model-call cost grouped by ``task_id`` and ``run_id``.
123
+
124
+ Returns a JSON-friendly summary: a grand total plus per-task and per-run
125
+ breakdowns. Unattributed records (older entries, or calls made without a
126
+ task/run context) are bucketed under ``(unattributed)``. Never raises.
127
+ """
128
+ records = read_cost_records(project_root)
129
+ total_cost = sum(float(record.get("cost", 0.0) or 0.0) for record in records)
130
+ return {
131
+ "total_cost": total_cost,
132
+ "total_calls": len(records),
133
+ "by_task": _group(records, "task_id"),
134
+ "by_run": _group(records, "run_id"),
135
+ }
136
+
137
+
138
+ def cost_by_task(project_root: Path) -> Dict[str, Dict[str, Any]]:
139
+ """Convenience accessor for the per-task cost breakdown (used by ``dev status``)."""
140
+ return group_cost(project_root)["by_task"]
@@ -0,0 +1,48 @@
1
+ anthropic/claude-3-opus:
2
+ prompt_per_1k: 0.015
3
+ completion_per_1k: 0.075
4
+ anthropic/claude-3.5-sonnet:
5
+ prompt_per_1k: 0.003
6
+ completion_per_1k: 0.015
7
+ anthropic/claude-sonnet-4:
8
+ prompt_per_1k: 0.003
9
+ completion_per_1k: 0.015
10
+ google/gemini-pro-1.5:
11
+ prompt_per_1k: 0.00125
12
+ completion_per_1k: 0.005
13
+ google/gemini-2.5-pro:
14
+ prompt_per_1k: 0.00125
15
+ completion_per_1k: 0.01
16
+ openai/gpt-4o:
17
+ prompt_per_1k: 0.005
18
+ completion_per_1k: 0.015
19
+ openai/o3-mini:
20
+ prompt_per_1k: 0.0011
21
+ completion_per_1k: 0.0044
22
+ anthropic/claude-sonnet-4.6:
23
+ prompt_per_1k: 0.003
24
+ completion_per_1k: 0.015
25
+ anthropic/claude-opus-4.8:
26
+ prompt_per_1k: 0.005
27
+ completion_per_1k: 0.025
28
+ openai/gpt-5.5:
29
+ prompt_per_1k: 0.005
30
+ completion_per_1k: 0.03
31
+ google/gemini-2.5-flash:
32
+ prompt_per_1k: 0.0003
33
+ completion_per_1k: 0.0025
34
+ # Local Ollama models run on-device and are free. Bare tags (no ``ollama/``
35
+ # prefix) are listed here so the cost reporter shows $0; the cost.py prefix
36
+ # guard covers any other ``ollama/`` or ``ollama:`` model id.
37
+ qwen2.5-coder:7b:
38
+ prompt_per_1k: 0.0
39
+ completion_per_1k: 0.0
40
+ qwen2.5-coder:14b:
41
+ prompt_per_1k: 0.0
42
+ completion_per_1k: 0.0
43
+ qwen2.5-coder:32b:
44
+ prompt_per_1k: 0.0
45
+ completion_per_1k: 0.0
46
+ llama3.1:
47
+ prompt_per_1k: 0.0
48
+ completion_per_1k: 0.0
@@ -0,0 +1,28 @@
1
+ from functools import lru_cache
2
+ from importlib import resources
3
+ from typing import Dict
4
+
5
+ import yaml
6
+
7
+ MODEL_PRICING_RESOURCE = "model_pricing.yaml"
8
+
9
+
10
+ @lru_cache(maxsize=1)
11
+ def load_model_pricing() -> Dict[str, Dict[str, float]]:
12
+ data = resources.files(__package__).joinpath(MODEL_PRICING_RESOURCE).read_text(encoding="utf-8")
13
+ loaded = yaml.safe_load(data) or {}
14
+ return {
15
+ str(model): {
16
+ "prompt_per_1k": float(pricing.get("prompt_per_1k", 0.0)),
17
+ "completion_per_1k": float(pricing.get("completion_per_1k", 0.0)),
18
+ }
19
+ for model, pricing in loaded.items()
20
+ if isinstance(pricing, dict)
21
+ }
22
+
23
+
24
+ def pricing_for_model(model: str, default: Dict[str, float] | None = None) -> Dict[str, float]:
25
+ pricing = load_model_pricing().get(model)
26
+ if pricing is not None:
27
+ return pricing
28
+ return default or {"prompt_per_1k": 0.0, "completion_per_1k": 0.0}
@@ -3,7 +3,7 @@ import logging
3
3
  import uuid
4
4
  from datetime import datetime, timezone
5
5
  from pathlib import Path
6
- from typing import Any, Dict, Iterable, Optional
6
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
7
7
 
8
8
  from pydantic import BaseModel, ConfigDict, Field
9
9
 
@@ -31,14 +31,17 @@ class TraceEvent(BaseModel):
31
31
  def from_legacy(cls, raw: Dict[str, Any]) -> "TraceEvent":
32
32
  if raw.get("schema") == TRACE_SCHEMA_VERSION:
33
33
  return cls.model_validate(raw)
34
- details = raw.get("details") if isinstance(raw.get("details"), dict) else {}
35
- task_id = details.get("task_id") if isinstance(details.get("task_id"), str) else None
36
- summary = details.get("summary") if isinstance(details.get("summary"), str) else ""
34
+ raw_details = raw.get("details")
35
+ details: Dict[str, Any] = raw_details if isinstance(raw_details, dict) else {}
36
+ raw_task_id = details.get("task_id")
37
+ raw_summary = details.get("summary")
38
+ raw_run_id = raw.get("run_id")
37
39
  return cls(
40
+ schema=TRACE_SCHEMA_VERSION,
38
41
  type=str(raw.get("type", "legacy_event")),
39
- run_id=raw.get("run_id"),
40
- task_id=task_id,
41
- summary=summary,
42
+ run_id=raw_run_id if isinstance(raw_run_id, str) else None,
43
+ task_id=raw_task_id if isinstance(raw_task_id, str) else None,
44
+ summary=raw_summary if isinstance(raw_summary, str) else "",
42
45
  details=details,
43
46
  )
44
47
 
@@ -61,6 +64,7 @@ class TraceLogger:
61
64
  ) -> TraceEvent:
62
65
  """Append an orchestration event trace."""
63
66
  trace = TraceEvent(
67
+ schema=TRACE_SCHEMA_VERSION,
64
68
  type=event_type,
65
69
  details=details,
66
70
  run_id=run_id,
@@ -89,3 +93,54 @@ def read_trace_events(project_root: Path) -> Iterable[TraceEvent]:
89
93
  except Exception as exc:
90
94
  logger.debug("Skipping invalid trace line: %s", exc)
91
95
  return events
96
+
97
+
98
+ def read_trace_events_since(
99
+ project_root: Path, cursor: Optional[int] = None
100
+ ) -> Tuple[List[TraceEvent], int]:
101
+ """Incrementally read trace events appended after ``cursor``.
102
+
103
+ The cursor is a byte offset into the trace file, which is robust to appends
104
+ (the file is append-only) and makes repeated polling O(new bytes) rather than
105
+ O(all events). Returns ``(events, next_cursor)`` where ``next_cursor`` should
106
+ be passed back on the next call to fetch only newer events.
107
+
108
+ Never raises: on any error it returns an empty batch and a safe cursor. A
109
+ ``cursor`` past the current end-of-file (e.g. the log was rotated/truncated)
110
+ is treated as a reset so the caller still makes progress.
111
+ """
112
+ trace_file = project_root / ".devcouncil" / "logs" / "traces.jsonl"
113
+ start = cursor if isinstance(cursor, int) and cursor >= 0 else 0
114
+ if not trace_file.exists():
115
+ return [], start
116
+
117
+ try:
118
+ size = trace_file.stat().st_size
119
+ # File shrank (rotation/truncation) — restart from the beginning.
120
+ if start > size:
121
+ start = 0
122
+
123
+ with open(trace_file, "rb") as f:
124
+ f.seek(start)
125
+ chunk = f.read()
126
+ except Exception as exc:
127
+ logger.debug("Failed incremental trace read: %s", exc)
128
+ return [], start
129
+
130
+ # Only consume up to the last complete line so a half-written final line is
131
+ # re-read (not skipped) on the next poll once it is fully flushed.
132
+ last_newline = chunk.rfind(b"\n")
133
+ if last_newline == -1:
134
+ return [], start
135
+ consumed = chunk[: last_newline + 1]
136
+ next_cursor = start + len(consumed)
137
+
138
+ events: List[TraceEvent] = []
139
+ for raw_line in consumed.decode("utf-8", errors="replace").splitlines():
140
+ if not raw_line.strip():
141
+ continue
142
+ try:
143
+ events.append(TraceEvent.from_legacy(json.loads(raw_line)))
144
+ except Exception as exc:
145
+ logger.debug("Skipping invalid trace line: %s", exc)
146
+ return events, next_cursor
@@ -1,49 +1,52 @@
1
- import json
2
- from pathlib import Path
3
- from typing import Dict, Any
4
-
5
- COST_PER_1K_TOKENS = {
6
- "anthropic/claude-3-opus": {"prompt": 0.015, "completion": 0.075},
7
- "anthropic/claude-3.5-sonnet": {"prompt": 0.003, "completion": 0.015},
8
- "openai/gpt-4o": {"prompt": 0.005, "completion": 0.015},
9
- "google/gemini-pro-1.5": {"prompt": 0.00125, "completion": 0.00375},
10
- }
11
-
12
- class TelemetryTracker:
13
- def __init__(self, project_root: Path):
14
- self.log_file = project_root / ".devcouncil" / "logs" / "telemetry.json"
15
- self.stats = self._load()
16
-
17
- def _load(self) -> Dict[str, Any]:
18
- if self.log_file.exists():
19
- try:
20
- with open(self.log_file, "r") as f:
21
- return json.load(f)
22
- except Exception:
23
- pass
24
- return {"total_cost": 0.0, "total_prompt_tokens": 0, "total_completion_tokens": 0, "models": {}}
25
-
26
- def _save(self):
27
- self.log_file.parent.mkdir(parents=True, exist_ok=True)
28
- with open(self.log_file, "w") as f:
29
- json.dump(self.stats, f, indent=2)
30
-
31
- def log_usage(self, model: str, usage: Dict[str, int]):
32
- prompt_tokens = usage.get("prompt_tokens", 0)
33
- completion_tokens = usage.get("completion_tokens", 0)
34
-
35
- rates = COST_PER_1K_TOKENS.get(model, {"prompt": 0.0, "completion": 0.0})
36
- cost = (prompt_tokens / 1000.0) * rates["prompt"] + (completion_tokens / 1000.0) * rates["completion"]
37
-
38
- self.stats["total_cost"] += cost
39
- self.stats["total_prompt_tokens"] += prompt_tokens
40
- self.stats["total_completion_tokens"] += completion_tokens
41
-
42
- if model not in self.stats["models"]:
43
- self.stats["models"][model] = {"cost": 0.0, "prompt_tokens": 0, "completion_tokens": 0}
44
-
45
- self.stats["models"][model]["cost"] += cost
46
- self.stats["models"][model]["prompt_tokens"] += prompt_tokens
47
- self.stats["models"][model]["completion_tokens"] += completion_tokens
48
-
49
- self._save()
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Dict, Any
4
+
5
+ from devcouncil.telemetry.pricing import pricing_for_model
6
+
7
+ class TelemetryTracker:
8
+ def __init__(self, project_root: Path):
9
+ self.log_file = project_root / ".devcouncil" / "logs" / "telemetry.json"
10
+ self.stats = self._load()
11
+
12
+ def _load(self) -> Dict[str, Any]:
13
+ if self.log_file.exists():
14
+ try:
15
+ with open(self.log_file, "r") as f:
16
+ return json.load(f)
17
+ except Exception:
18
+ pass
19
+ return {"total_cost": 0.0, "total_prompt_tokens": 0, "total_completion_tokens": 0, "models": {}}
20
+
21
+ def _save(self):
22
+ self.log_file.parent.mkdir(parents=True, exist_ok=True)
23
+ with open(self.log_file, "w") as f:
24
+ json.dump(self.stats, f, indent=2)
25
+
26
+ def log_usage(self, model: str, usage: Dict[str, int], *, local: bool = False):
27
+ prompt_tokens = usage.get("prompt_tokens", 0)
28
+ completion_tokens = usage.get("completion_tokens", 0)
29
+
30
+ if local:
31
+ # On-device providers (Ollama) incur no per-token cost. Zero by provider, not
32
+ # by model-id matching — consistent with telemetry/cost.py — so a local tag
33
+ # that happens to collide with a priced entry is never billed.
34
+ cost = 0.0
35
+ else:
36
+ rates = pricing_for_model(model)
37
+ cost = (prompt_tokens / 1000.0) * rates["prompt_per_1k"] + (
38
+ completion_tokens / 1000.0
39
+ ) * rates["completion_per_1k"]
40
+
41
+ self.stats["total_cost"] += cost
42
+ self.stats["total_prompt_tokens"] += prompt_tokens
43
+ self.stats["total_completion_tokens"] += completion_tokens
44
+
45
+ if model not in self.stats["models"]:
46
+ self.stats["models"][model] = {"cost": 0.0, "prompt_tokens": 0, "completion_tokens": 0}
47
+
48
+ self.stats["models"][model]["cost"] += cost
49
+ self.stats["models"][model]["prompt_tokens"] += prompt_tokens
50
+ self.stats["models"][model]["completion_tokens"] += completion_tokens
51
+
52
+ self._save()
@@ -0,0 +1 @@
1
+ """Live DevCouncil dashboard helpers."""