devcouncil 0.1.1 → 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 (129) hide show
  1. package/README.md +190 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +167 -5
  5. package/src/devcouncil/artifacts/graph.py +23 -3
  6. package/src/devcouncil/assets/__init__.py +1 -0
  7. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  8. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  9. package/src/devcouncil/cli/commands/agents.py +292 -0
  10. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  11. package/src/devcouncil/cli/commands/check.py +209 -0
  12. package/src/devcouncil/cli/commands/config.py +43 -4
  13. package/src/devcouncil/cli/commands/cost.py +57 -0
  14. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  15. package/src/devcouncil/cli/commands/doctor.py +221 -21
  16. package/src/devcouncil/cli/commands/evidence.py +48 -0
  17. package/src/devcouncil/cli/commands/go.py +452 -33
  18. package/src/devcouncil/cli/commands/handoff.py +69 -0
  19. package/src/devcouncil/cli/commands/hook.py +124 -15
  20. package/src/devcouncil/cli/commands/init.py +154 -18
  21. package/src/devcouncil/cli/commands/integrate.py +894 -105
  22. package/src/devcouncil/cli/commands/map.py +80 -10
  23. package/src/devcouncil/cli/commands/plan.py +212 -51
  24. package/src/devcouncil/cli/commands/prompt.py +18 -7
  25. package/src/devcouncil/cli/commands/repair.py +40 -23
  26. package/src/devcouncil/cli/commands/report.py +8 -0
  27. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  28. package/src/devcouncil/cli/commands/rollback.py +27 -28
  29. package/src/devcouncil/cli/commands/run.py +69 -49
  30. package/src/devcouncil/cli/commands/runs.py +223 -0
  31. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  32. package/src/devcouncil/cli/commands/semantic.py +47 -0
  33. package/src/devcouncil/cli/commands/setup.py +145 -6
  34. package/src/devcouncil/cli/commands/shell.py +73 -0
  35. package/src/devcouncil/cli/commands/skills.py +88 -0
  36. package/src/devcouncil/cli/commands/status.py +25 -1
  37. package/src/devcouncil/cli/commands/trace.py +47 -3
  38. package/src/devcouncil/cli/commands/verify.py +138 -3
  39. package/src/devcouncil/cli/commands/watch.py +9 -9
  40. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  41. package/src/devcouncil/cli/main.py +56 -7
  42. package/src/devcouncil/domain/evidence.py +22 -2
  43. package/src/devcouncil/domain/gap.py +27 -1
  44. package/src/devcouncil/domain/task.py +31 -2
  45. package/src/devcouncil/execution/checkpoints.py +246 -0
  46. package/src/devcouncil/execution/context_builder.py +1 -1
  47. package/src/devcouncil/execution/fs_watcher.py +180 -0
  48. package/src/devcouncil/execution/handoff.py +102 -0
  49. package/src/devcouncil/execution/hook_policy.py +162 -74
  50. package/src/devcouncil/execution/patch.py +59 -10
  51. package/src/devcouncil/execution/permissions.py +17 -24
  52. package/src/devcouncil/execution/policy_engine.py +343 -0
  53. package/src/devcouncil/execution/prompt_builder.py +633 -21
  54. package/src/devcouncil/execution/shell_session.py +225 -0
  55. package/src/devcouncil/execution/task_runner.py +6 -2
  56. package/src/devcouncil/executors/agent_registry.py +575 -0
  57. package/src/devcouncil/executors/coding_cli.py +663 -39
  58. package/src/devcouncil/executors/native/agent.py +121 -20
  59. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  60. package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
  61. package/src/devcouncil/gating/policy.py +158 -10
  62. package/src/devcouncil/hardware.py +184 -0
  63. package/src/devcouncil/indexing/ast_matcher.py +1 -1
  64. package/src/devcouncil/indexing/lsp.py +45 -4
  65. package/src/devcouncil/indexing/repo_mapper.py +1256 -9
  66. package/src/devcouncil/indexing/semantic_index.py +205 -0
  67. package/src/devcouncil/integrations/actions.py +146 -0
  68. package/src/devcouncil/integrations/check.py +423 -0
  69. package/src/devcouncil/integrations/github_intent.py +142 -0
  70. package/src/devcouncil/integrations/gitnexus.py +35 -0
  71. package/src/devcouncil/integrations/mcp/server.py +1552 -29
  72. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  73. package/src/devcouncil/live/cards.py +161 -19
  74. package/src/devcouncil/live/signals.py +2 -2
  75. package/src/devcouncil/live/transcripts.py +9 -6
  76. package/src/devcouncil/llm/cache.py +10 -6
  77. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  78. package/src/devcouncil/llm/provider.py +515 -34
  79. package/src/devcouncil/llm/router.py +231 -46
  80. package/src/devcouncil/optimization/__init__.py +1 -0
  81. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  82. package/src/devcouncil/planning/correction_manifest.py +303 -0
  83. package/src/devcouncil/planning/critique_service.py +7 -2
  84. package/src/devcouncil/planning/plan_service.py +17 -3
  85. package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
  86. package/src/devcouncil/planning/spec_service.py +27 -1
  87. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  88. package/src/devcouncil/repo/gitignore.py +123 -0
  89. package/src/devcouncil/repo/sca.py +374 -0
  90. package/src/devcouncil/reporting/json_report.py +11 -1
  91. package/src/devcouncil/reporting/markdown_report.py +15 -0
  92. package/src/devcouncil/skills/__init__.py +19 -0
  93. package/src/devcouncil/skills/library/README.md +46 -0
  94. package/src/devcouncil/skills/library/ai-training.md +50 -0
  95. package/src/devcouncil/skills/library/android.md +50 -0
  96. package/src/devcouncil/skills/library/backend.md +52 -0
  97. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  98. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  99. package/src/devcouncil/skills/library/desktop.md +46 -0
  100. package/src/devcouncil/skills/library/devops.md +48 -0
  101. package/src/devcouncil/skills/library/game-dev.md +46 -0
  102. package/src/devcouncil/skills/library/ios.md +48 -0
  103. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  104. package/src/devcouncil/skills/library/security.md +48 -0
  105. package/src/devcouncil/skills/library/systems.md +48 -0
  106. package/src/devcouncil/skills/library/web.md +47 -0
  107. package/src/devcouncil/skills/library/windows.md +47 -0
  108. package/src/devcouncil/skills/registry.py +330 -0
  109. package/src/devcouncil/storage/db.py +83 -2
  110. package/src/devcouncil/storage/models.py +121 -0
  111. package/src/devcouncil/storage/native.py +557 -0
  112. package/src/devcouncil/storage/repositories.py +137 -75
  113. package/src/devcouncil/telemetry/cost.py +123 -17
  114. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  115. package/src/devcouncil/telemetry/pricing.py +28 -0
  116. package/src/devcouncil/telemetry/traces.py +62 -7
  117. package/src/devcouncil/telemetry/tracker.py +12 -9
  118. package/src/devcouncil/ui/dashboard.py +324 -23
  119. package/src/devcouncil/utils/redaction.py +9 -3
  120. package/src/devcouncil/utils/subprocess_env.py +69 -0
  121. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  122. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  123. package/src/devcouncil/verification/diff_coverage.py +353 -0
  124. package/src/devcouncil/verification/next_actions.py +189 -0
  125. package/src/devcouncil/verification/sandbox.py +178 -0
  126. package/src/devcouncil/verification/test_resolver.py +91 -0
  127. package/src/devcouncil/verification/verifier.py +1065 -47
  128. package/uv.lock +205 -64
  129. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,4 +1,4 @@
1
- from sqlmodel import Session, select, delete
1
+ from sqlmodel import Session, col, delete, select
2
2
  from typing import List, Optional, Any
3
3
  import json
4
4
  from devcouncil.storage.models import (
@@ -14,7 +14,7 @@ from devcouncil.domain.requirement import Requirement, AcceptanceCriterion
14
14
  from devcouncil.domain.assumption import Assumption
15
15
  from devcouncil.domain.task import Task, PlannedFile
16
16
  from devcouncil.domain.gap import Gap
17
- from devcouncil.domain.evidence import CommandResult, DiffEvidence, TestEvidence
17
+ from devcouncil.domain.evidence import CommandResult, DiffEvidence, DiffCoverageEvidence, TestEvidence
18
18
  from devcouncil.domain.critique import CritiqueFinding
19
19
  from devcouncil.artifacts.graph import ArtifactGraph
20
20
 
@@ -28,14 +28,14 @@ class RequirementRepository:
28
28
  results = []
29
29
  for m in models:
30
30
  ac_list = [AcceptanceCriterion.model_validate(ac) for ac in json.loads(m.acceptance_criteria_json)]
31
- results.append(Requirement(
32
- id=m.id,
33
- title=m.title,
34
- description=m.description,
35
- priority=m.priority,
36
- source=m.source,
37
- acceptance_criteria=ac_list
38
- ))
31
+ results.append(Requirement.model_validate({
32
+ "id": m.id,
33
+ "title": m.title,
34
+ "description": m.description,
35
+ "priority": m.priority,
36
+ "source": m.source,
37
+ "acceptance_criteria": ac_list,
38
+ }))
39
39
  return results
40
40
 
41
41
  def save(self, req: Requirement):
@@ -59,16 +59,16 @@ class AssumptionRepository:
59
59
  statement = select(AssumptionModel)
60
60
  models = self.session.exec(statement).all()
61
61
  return [
62
- Assumption(
63
- id=m.id,
64
- statement=m.statement,
65
- confidence=m.confidence,
66
- impact=m.impact,
67
- reversible=m.reversible,
68
- requires_user_confirmation=m.requires_user_confirmation,
69
- linked_requirement_ids=json.loads(m.linked_requirement_ids_json),
70
- status=m.status,
71
- )
62
+ Assumption.model_validate({
63
+ "id": m.id,
64
+ "statement": m.statement,
65
+ "confidence": m.confidence,
66
+ "impact": m.impact,
67
+ "reversible": m.reversible,
68
+ "requires_user_confirmation": m.requires_user_confirmation,
69
+ "linked_requirement_ids": json.loads(m.linked_requirement_ids_json),
70
+ "status": m.status,
71
+ })
72
72
  for m in models
73
73
  ]
74
74
 
@@ -97,18 +97,18 @@ class TaskRepository:
97
97
  return None
98
98
 
99
99
  pf_list = [PlannedFile.model_validate(pf) for pf in json.loads(m.planned_files_json)]
100
- return Task(
101
- id=m.id,
102
- title=m.title,
103
- description=m.description,
104
- requirement_ids=json.loads(m.requirement_ids_json),
105
- acceptance_criterion_ids=json.loads(m.acceptance_criterion_ids_json),
106
- planned_files=pf_list,
107
- expected_tests=json.loads(m.expected_tests_json),
108
- allowed_commands=json.loads(m.allowed_commands_json),
109
- forbidden_changes=json.loads(m.forbidden_changes_json),
110
- status=m.status
111
- )
100
+ return Task.model_validate({
101
+ "id": m.id,
102
+ "title": m.title,
103
+ "description": m.description,
104
+ "requirement_ids": json.loads(m.requirement_ids_json),
105
+ "acceptance_criterion_ids": json.loads(m.acceptance_criterion_ids_json),
106
+ "planned_files": pf_list,
107
+ "expected_tests": json.loads(m.expected_tests_json),
108
+ "allowed_commands": json.loads(m.allowed_commands_json),
109
+ "forbidden_changes": json.loads(m.forbidden_changes_json),
110
+ "status": m.status,
111
+ })
112
112
 
113
113
  def get_all(self) -> List[Task]:
114
114
  statement = select(TaskModel)
@@ -116,18 +116,18 @@ class TaskRepository:
116
116
  results = []
117
117
  for m in models:
118
118
  pf_list = [PlannedFile.model_validate(pf) for pf in json.loads(m.planned_files_json)]
119
- results.append(Task(
120
- id=m.id,
121
- title=m.title,
122
- description=m.description,
123
- requirement_ids=json.loads(m.requirement_ids_json),
124
- acceptance_criterion_ids=json.loads(m.acceptance_criterion_ids_json),
125
- planned_files=pf_list,
126
- expected_tests=json.loads(m.expected_tests_json),
127
- allowed_commands=json.loads(m.allowed_commands_json),
128
- forbidden_changes=json.loads(m.forbidden_changes_json),
129
- status=m.status
130
- ))
119
+ results.append(Task.model_validate({
120
+ "id": m.id,
121
+ "title": m.title,
122
+ "description": m.description,
123
+ "requirement_ids": json.loads(m.requirement_ids_json),
124
+ "acceptance_criterion_ids": json.loads(m.acceptance_criterion_ids_json),
125
+ "planned_files": pf_list,
126
+ "expected_tests": json.loads(m.expected_tests_json),
127
+ "allowed_commands": json.loads(m.allowed_commands_json),
128
+ "forbidden_changes": json.loads(m.forbidden_changes_json),
129
+ "status": m.status,
130
+ }))
131
131
  return results
132
132
 
133
133
  def save(self, task: Task):
@@ -155,17 +155,21 @@ class GapRepository:
155
155
  models = self.session.exec(statement).all()
156
156
  results = []
157
157
  for m in models:
158
- results.append(Gap(
159
- id=m.id,
160
- severity=m.severity,
161
- gap_type=m.gap_type,
162
- requirement_id=m.requirement_id,
163
- task_id=m.task_id,
164
- description=m.description,
165
- evidence=json.loads(m.evidence_json),
166
- recommended_fix=m.recommended_fix,
167
- blocking=m.blocking
168
- ))
158
+ results.append(Gap.model_validate({
159
+ "id": m.id,
160
+ "severity": m.severity,
161
+ "gap_type": m.gap_type,
162
+ "requirement_id": m.requirement_id,
163
+ "task_id": m.task_id,
164
+ "description": m.description,
165
+ "evidence": json.loads(m.evidence_json),
166
+ "recommended_fix": m.recommended_fix,
167
+ "blocking": m.blocking,
168
+ "file": m.file,
169
+ "line": m.line,
170
+ "suggested_command": m.suggested_command,
171
+ "acceptance_criterion_id": m.acceptance_criterion_id,
172
+ }))
169
173
  return results
170
174
 
171
175
  def save(self, gap: Gap):
@@ -178,17 +182,21 @@ class GapRepository:
178
182
  description=gap.description,
179
183
  evidence_json=json.dumps(gap.evidence),
180
184
  recommended_fix=gap.recommended_fix,
181
- blocking=gap.blocking
185
+ blocking=gap.blocking,
186
+ file=gap.file,
187
+ line=gap.line,
188
+ suggested_command=gap.suggested_command,
189
+ acceptance_criterion_id=gap.acceptance_criterion_id,
182
190
  )
183
191
  self.session.merge(model)
184
192
  self.session.commit()
185
193
 
186
194
  def delete_for_task(self, task_id: str):
187
- self.session.exec(delete(GapModel).where(GapModel.task_id == task_id))
195
+ self.session.exec(delete(GapModel).where(col(GapModel.task_id) == task_id))
188
196
  self.session.commit()
189
197
 
190
198
  def delete_plan_gaps(self):
191
- self.session.exec(delete(GapModel).where(GapModel.id.like("GAP-PLAN-%")))
199
+ self.session.exec(delete(GapModel).where(col(GapModel.id).like("GAP-PLAN-%")))
192
200
  self.session.commit()
193
201
 
194
202
  class EvidenceRepository:
@@ -224,22 +232,40 @@ class EvidenceRepository:
224
232
  self.session.add(model)
225
233
  self.session.commit()
226
234
 
235
+ def save_diff_coverage_evidence(self, ev: DiffCoverageEvidence):
236
+ model = EvidenceModel(
237
+ type="diff_coverage",
238
+ task_id=ev.task_id,
239
+ data_json=ev.model_dump_json()
240
+ )
241
+ self.session.add(model)
242
+ self.session.commit()
243
+
244
+ def get_command_results_for_task(self, task_id: str) -> List[CommandResult]:
245
+ statement = select(EvidenceModel).where(EvidenceModel.task_id == task_id).where(
246
+ EvidenceModel.type == "command"
247
+ )
248
+ models = self.session.exec(statement).all()
249
+ return [CommandResult.model_validate(json.loads(m.data_json)) for m in models]
250
+
227
251
  def get_all(self) -> List[Any]:
228
252
  statement = select(EvidenceModel)
229
253
  models = self.session.exec(statement).all()
230
- results = []
254
+ results: List[Any] = []
231
255
  for m in models:
232
256
  data = json.loads(m.data_json)
233
257
  if m.type == "command":
234
258
  results.append(CommandResult.model_validate(data))
235
259
  elif m.type == "diff":
236
260
  results.append(DiffEvidence.model_validate(data))
261
+ elif m.type == "diff_coverage":
262
+ results.append(DiffCoverageEvidence.model_validate(data))
237
263
  elif m.type == "test":
238
264
  results.append(TestEvidence.model_validate(data))
239
265
  return results
240
266
 
241
267
  def delete_for_task(self, task_id: str):
242
- self.session.exec(delete(EvidenceModel).where(EvidenceModel.task_id == task_id))
268
+ self.session.exec(delete(EvidenceModel).where(col(EvidenceModel.task_id) == task_id))
243
269
  self.session.commit()
244
270
 
245
271
 
@@ -251,19 +277,19 @@ class CritiqueFindingRepository:
251
277
  statement = select(CritiqueFindingModel)
252
278
  models = self.session.exec(statement).all()
253
279
  return [
254
- CritiqueFinding(
255
- id=m.id,
256
- source_agent=m.source_agent,
257
- target_plan_id=m.target_plan_id,
258
- severity=m.severity,
259
- finding_type=m.finding_type,
260
- claim=m.claim,
261
- linked_requirement_id=m.linked_requirement_id,
262
- suggested_requirement=m.suggested_requirement,
263
- suggested_task=m.suggested_task,
264
- falsifiable_check=m.falsifiable_check,
265
- status=m.status,
266
- )
280
+ CritiqueFinding.model_validate({
281
+ "id": m.id,
282
+ "source_agent": m.source_agent,
283
+ "target_plan_id": m.target_plan_id,
284
+ "severity": m.severity,
285
+ "finding_type": m.finding_type,
286
+ "claim": m.claim,
287
+ "linked_requirement_id": m.linked_requirement_id,
288
+ "suggested_requirement": m.suggested_requirement,
289
+ "suggested_task": m.suggested_task,
290
+ "falsifiable_check": m.falsifiable_check,
291
+ "status": m.status,
292
+ })
267
293
  for m in models
268
294
  ]
269
295
 
@@ -386,6 +412,10 @@ class ArtifactGraphRepository:
386
412
  for ev in self.evidence_repo.get_all():
387
413
  if isinstance(ev, CommandResult):
388
414
  graph.add_command_result(ev)
415
+ elif isinstance(ev, DiffCoverageEvidence):
416
+ # Must precede DiffEvidence: distinct type, and the diff↔coverage proof
417
+ # was silently dropped on reload before this branch existed.
418
+ graph.add_diff_coverage_evidence(ev)
389
419
  elif isinstance(ev, DiffEvidence):
390
420
  graph.add_diff_evidence(ev)
391
421
  elif isinstance(ev, TestEvidence):
@@ -416,8 +446,40 @@ class StateRepository:
416
446
  def record_phase(self, phase: str):
417
447
  current = self.get_state()
418
448
  history = []
449
+ prev = None
419
450
  if current:
420
451
  history = json.loads(current.history_json)
421
- if not history or history[-1] != phase:
422
- history.append(phase)
452
+ prev = current.current_phase
453
+ # Keep the persisted history a LEGAL transition sequence. A repair re-run records
454
+ # TASK_EXECUTING straight after a terminal TASK_BLOCKED/TASK_VERIFIED, which is not
455
+ # a direct transition; bridge it through the intermediate phase (TASK_READY) so the
456
+ # state machine's invariant holds on reload. Fail-soft: an unbridgeable jump is
457
+ # still recorded verbatim rather than lost.
458
+ for step in self._bridge_phases(prev, phase):
459
+ if not history or history[-1] != step:
460
+ history.append(step)
423
461
  self.save_state(phase, history)
462
+
463
+ @staticmethod
464
+ def _bridge_phases(prev: Optional[str], target: str) -> List[str]:
465
+ """Return the phases to append so prev -> ... -> target is a legal path.
466
+
467
+ Inserts a single intermediate phase when prev cannot transition directly to
468
+ target but a one-hop bridge exists (the repair loop's
469
+ TASK_BLOCKED/TASK_VERIFIED -> TASK_READY -> TASK_EXECUTING). Returns ``[target]``
470
+ when prev is unknown, equal, already-legal, or no one-hop bridge exists."""
471
+ if prev is None or prev == target:
472
+ return [target]
473
+ try:
474
+ from devcouncil.app.state_machine import TRANSITIONS, ProjectPhase
475
+
476
+ prev_phase = ProjectPhase(prev)
477
+ target_phase = ProjectPhase(target)
478
+ except (ImportError, ValueError):
479
+ return [target]
480
+ if target_phase in TRANSITIONS.get(prev_phase, set()):
481
+ return [target]
482
+ for mid in TRANSITIONS.get(prev_phase, set()):
483
+ if target_phase in TRANSITIONS.get(mid, set()):
484
+ return [mid.value, target]
485
+ return [target]
@@ -1,34 +1,140 @@
1
+ import json
1
2
  import logging
2
- from typing import Dict
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List
5
+
6
+ from devcouncil.telemetry.pricing import pricing_for_model
3
7
 
4
8
  logger = logging.getLogger(__name__)
5
9
 
10
+ UNATTRIBUTED = "(unattributed)"
11
+
12
+
6
13
  class CostEstimator:
7
14
  """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
15
 
20
16
  # Conservative default for unknown models
21
- DEFAULT_PRICING = {"input": 0.000005, "output": 0.000015}
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)
22
27
 
23
28
  @classmethod
24
29
  def estimate_cost(cls, model: str, usage: Dict[str, int]) -> float:
25
- prices = cls.PRICING.get(model)
26
- if not prices:
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:
27
36
  logger.debug("Unknown model for cost estimation: %s — using default pricing", model)
28
- prices = cls.DEFAULT_PRICING
29
37
 
30
38
  prompt_tokens = usage.get("prompt_tokens", 0)
31
39
  completion_tokens = usage.get("completion_tokens", 0)
32
40
 
33
- cost = (prompt_tokens * prices["input"]) + (completion_tokens * prices["output"])
41
+ cost = ((prompt_tokens / 1000.0) * prices["prompt_per_1k"]) + (
42
+ (completion_tokens / 1000.0) * prices["completion_per_1k"]
43
+ )
34
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