devcouncil 0.1.1 → 0.3.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 (159) hide show
  1. package/README.md +201 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +348 -12
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +32 -5
  8. package/src/devcouncil/assets/__init__.py +1 -0
  9. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  10. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  11. package/src/devcouncil/cli/commands/agents.py +292 -0
  12. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  13. package/src/devcouncil/cli/commands/check.py +220 -0
  14. package/src/devcouncil/cli/commands/config.py +43 -4
  15. package/src/devcouncil/cli/commands/cost.py +57 -0
  16. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  17. package/src/devcouncil/cli/commands/design.py +186 -0
  18. package/src/devcouncil/cli/commands/doctor.py +379 -22
  19. package/src/devcouncil/cli/commands/evidence.py +48 -0
  20. package/src/devcouncil/cli/commands/go.py +532 -33
  21. package/src/devcouncil/cli/commands/handoff.py +69 -0
  22. package/src/devcouncil/cli/commands/hook.py +296 -15
  23. package/src/devcouncil/cli/commands/init.py +161 -20
  24. package/src/devcouncil/cli/commands/integrate.py +1371 -124
  25. package/src/devcouncil/cli/commands/logs.py +106 -0
  26. package/src/devcouncil/cli/commands/map.py +80 -10
  27. package/src/devcouncil/cli/commands/okf.py +245 -0
  28. package/src/devcouncil/cli/commands/plan.py +256 -55
  29. package/src/devcouncil/cli/commands/prompt.py +18 -7
  30. package/src/devcouncil/cli/commands/repair.py +50 -24
  31. package/src/devcouncil/cli/commands/report.py +8 -0
  32. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  33. package/src/devcouncil/cli/commands/rollback.py +27 -28
  34. package/src/devcouncil/cli/commands/run.py +195 -54
  35. package/src/devcouncil/cli/commands/runs.py +223 -0
  36. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  37. package/src/devcouncil/cli/commands/semantic.py +47 -0
  38. package/src/devcouncil/cli/commands/setup.py +145 -6
  39. package/src/devcouncil/cli/commands/shell.py +73 -0
  40. package/src/devcouncil/cli/commands/skills.py +267 -0
  41. package/src/devcouncil/cli/commands/status.py +30 -15
  42. package/src/devcouncil/cli/commands/trace.py +47 -3
  43. package/src/devcouncil/cli/commands/verify.py +144 -3
  44. package/src/devcouncil/cli/commands/watch.py +32 -12
  45. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  46. package/src/devcouncil/cli/main.py +91 -7
  47. package/src/devcouncil/domain/evidence.py +29 -2
  48. package/src/devcouncil/domain/gap.py +27 -1
  49. package/src/devcouncil/domain/task.py +31 -2
  50. package/src/devcouncil/execution/checkpoints.py +256 -0
  51. package/src/devcouncil/execution/context_builder.py +1 -1
  52. package/src/devcouncil/execution/fs_watcher.py +205 -0
  53. package/src/devcouncil/execution/handoff.py +102 -0
  54. package/src/devcouncil/execution/hook_policy.py +162 -74
  55. package/src/devcouncil/execution/patch.py +65 -10
  56. package/src/devcouncil/execution/permissions.py +24 -24
  57. package/src/devcouncil/execution/policy_engine.py +350 -0
  58. package/src/devcouncil/execution/prompt_builder.py +751 -23
  59. package/src/devcouncil/execution/shell_session.py +231 -0
  60. package/src/devcouncil/execution/task_runner.py +24 -9
  61. package/src/devcouncil/executors/agent_registry.py +596 -0
  62. package/src/devcouncil/executors/coding_cli.py +791 -39
  63. package/src/devcouncil/executors/mini_swe.py +6 -0
  64. package/src/devcouncil/executors/native/agent.py +135 -19
  65. package/src/devcouncil/executors/openhands.py +6 -0
  66. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  67. package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
  68. package/src/devcouncil/gating/policy.py +190 -11
  69. package/src/devcouncil/hardware.py +184 -0
  70. package/src/devcouncil/indexing/ast_matcher.py +17 -7
  71. package/src/devcouncil/indexing/lsp.py +45 -4
  72. package/src/devcouncil/indexing/repo_mapper.py +1284 -15
  73. package/src/devcouncil/indexing/semantic_index.py +221 -0
  74. package/src/devcouncil/integrations/actions.py +166 -0
  75. package/src/devcouncil/integrations/check.py +426 -0
  76. package/src/devcouncil/integrations/claude_assets.py +444 -0
  77. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  78. package/src/devcouncil/integrations/github_intent.py +149 -0
  79. package/src/devcouncil/integrations/gitnexus.py +45 -2
  80. package/src/devcouncil/integrations/mcp/server.py +1944 -32
  81. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  82. package/src/devcouncil/integrations/pr_comments.py +9 -0
  83. package/src/devcouncil/knowledge/__init__.py +23 -0
  84. package/src/devcouncil/knowledge/design.py +374 -0
  85. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  86. package/src/devcouncil/knowledge/fetch.py +223 -0
  87. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  88. package/src/devcouncil/knowledge/okf.py +202 -0
  89. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  90. package/src/devcouncil/knowledge/sources.py +239 -0
  91. package/src/devcouncil/live/cards.py +181 -25
  92. package/src/devcouncil/live/repair_prompt.py +29 -6
  93. package/src/devcouncil/live/reviewer.py +72 -13
  94. package/src/devcouncil/live/signals.py +2 -2
  95. package/src/devcouncil/live/summary.py +18 -8
  96. package/src/devcouncil/live/transcripts.py +47 -11
  97. package/src/devcouncil/llm/cache.py +20 -8
  98. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  99. package/src/devcouncil/llm/provider.py +617 -49
  100. package/src/devcouncil/llm/router.py +337 -53
  101. package/src/devcouncil/optimization/__init__.py +1 -0
  102. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  103. package/src/devcouncil/optimization/skillopt.py +673 -0
  104. package/src/devcouncil/planning/arbiter_service.py +10 -2
  105. package/src/devcouncil/planning/correction_manifest.py +346 -0
  106. package/src/devcouncil/planning/critique_service.py +16 -4
  107. package/src/devcouncil/planning/plan_service.py +86 -6
  108. package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
  109. package/src/devcouncil/planning/repair_service.py +8 -2
  110. package/src/devcouncil/planning/spec_service.py +37 -3
  111. package/src/devcouncil/repo/ci_scaffold.py +165 -0
  112. package/src/devcouncil/repo/gitignore.py +123 -0
  113. package/src/devcouncil/repo/sca.py +384 -0
  114. package/src/devcouncil/reporting/json_report.py +22 -1
  115. package/src/devcouncil/reporting/markdown_report.py +29 -1
  116. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  117. package/src/devcouncil/reporting/okf_html.py +323 -0
  118. package/src/devcouncil/reporting/report_builder.py +18 -1
  119. package/src/devcouncil/skills/__init__.py +19 -0
  120. package/src/devcouncil/skills/library/README.md +46 -0
  121. package/src/devcouncil/skills/library/ai-training.md +50 -0
  122. package/src/devcouncil/skills/library/android.md +50 -0
  123. package/src/devcouncil/skills/library/backend.md +52 -0
  124. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  125. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  126. package/src/devcouncil/skills/library/desktop.md +46 -0
  127. package/src/devcouncil/skills/library/devops.md +48 -0
  128. package/src/devcouncil/skills/library/game-dev.md +46 -0
  129. package/src/devcouncil/skills/library/ios.md +48 -0
  130. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  131. package/src/devcouncil/skills/library/security.md +48 -0
  132. package/src/devcouncil/skills/library/systems.md +48 -0
  133. package/src/devcouncil/skills/library/web.md +47 -0
  134. package/src/devcouncil/skills/library/windows.md +47 -0
  135. package/src/devcouncil/skills/registry.py +408 -0
  136. package/src/devcouncil/storage/db.py +140 -3
  137. package/src/devcouncil/storage/models.py +125 -0
  138. package/src/devcouncil/storage/native.py +559 -0
  139. package/src/devcouncil/storage/repositories.py +157 -78
  140. package/src/devcouncil/telemetry/cost.py +123 -17
  141. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  142. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  143. package/src/devcouncil/telemetry/pricing.py +28 -0
  144. package/src/devcouncil/telemetry/stages.py +141 -0
  145. package/src/devcouncil/telemetry/traces.py +62 -7
  146. package/src/devcouncil/telemetry/tracker.py +24 -10
  147. package/src/devcouncil/ui/dashboard.py +393 -28
  148. package/src/devcouncil/utils/redaction.py +9 -3
  149. package/src/devcouncil/utils/subprocess_env.py +69 -0
  150. package/src/devcouncil/verification/acceptance_compiler.py +253 -0
  151. package/src/devcouncil/verification/ad_hoc_check.py +135 -0
  152. package/src/devcouncil/verification/diff_coverage.py +353 -0
  153. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  154. package/src/devcouncil/verification/next_actions.py +189 -0
  155. package/src/devcouncil/verification/sandbox.py +181 -0
  156. package/src/devcouncil/verification/test_resolver.py +91 -0
  157. package/src/devcouncil/verification/verifier.py +1549 -143
  158. package/uv.lock +205 -64
  159. 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):
@@ -150,23 +150,43 @@ class GapRepository:
150
150
  def __init__(self, session: Session):
151
151
  self.session = session
152
152
 
153
+ @staticmethod
154
+ def _to_gap(m: GapModel) -> Gap:
155
+ return Gap.model_validate({
156
+ "id": m.id,
157
+ "severity": m.severity,
158
+ "gap_type": m.gap_type,
159
+ "requirement_id": m.requirement_id,
160
+ "task_id": m.task_id,
161
+ "description": m.description,
162
+ "evidence": json.loads(m.evidence_json),
163
+ "recommended_fix": m.recommended_fix,
164
+ "blocking": m.blocking,
165
+ "file": m.file,
166
+ "line": m.line,
167
+ "suggested_command": m.suggested_command,
168
+ "acceptance_criterion_id": m.acceptance_criterion_id,
169
+ "expected_verification_method": m.expected_verification_method,
170
+ })
171
+
153
172
  def get_all(self) -> List[Gap]:
154
173
  statement = select(GapModel)
155
174
  models = self.session.exec(statement).all()
156
- results = []
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
- ))
169
- return results
175
+ return [self._to_gap(m) for m in models]
176
+
177
+ def get_for_task(self, task_id: str) -> List[Gap]:
178
+ statement = select(GapModel).where(col(GapModel.task_id) == task_id)
179
+ models = self.session.exec(statement).all()
180
+ return [self._to_gap(m) for m in models]
181
+
182
+ def get_blocking_for_task(self, task_id: str) -> List[Gap]:
183
+ statement = (
184
+ select(GapModel)
185
+ .where(col(GapModel.task_id) == task_id)
186
+ .where(col(GapModel.blocking) == True) # noqa: E712 - SQL boolean comparison
187
+ )
188
+ models = self.session.exec(statement).all()
189
+ return [self._to_gap(m) for m in models]
170
190
 
171
191
  def save(self, gap: Gap):
172
192
  model = GapModel(
@@ -178,17 +198,22 @@ class GapRepository:
178
198
  description=gap.description,
179
199
  evidence_json=json.dumps(gap.evidence),
180
200
  recommended_fix=gap.recommended_fix,
181
- blocking=gap.blocking
201
+ blocking=gap.blocking,
202
+ file=gap.file,
203
+ line=gap.line,
204
+ suggested_command=gap.suggested_command,
205
+ acceptance_criterion_id=gap.acceptance_criterion_id,
206
+ expected_verification_method=gap.expected_verification_method,
182
207
  )
183
208
  self.session.merge(model)
184
209
  self.session.commit()
185
210
 
186
211
  def delete_for_task(self, task_id: str):
187
- self.session.exec(delete(GapModel).where(GapModel.task_id == task_id))
212
+ self.session.exec(delete(GapModel).where(col(GapModel.task_id) == task_id))
188
213
  self.session.commit()
189
214
 
190
215
  def delete_plan_gaps(self):
191
- self.session.exec(delete(GapModel).where(GapModel.id.like("GAP-PLAN-%")))
216
+ self.session.exec(delete(GapModel).where(col(GapModel.id).like("GAP-PLAN-%")))
192
217
  self.session.commit()
193
218
 
194
219
  class EvidenceRepository:
@@ -224,22 +249,40 @@ class EvidenceRepository:
224
249
  self.session.add(model)
225
250
  self.session.commit()
226
251
 
252
+ def save_diff_coverage_evidence(self, ev: DiffCoverageEvidence):
253
+ model = EvidenceModel(
254
+ type="diff_coverage",
255
+ task_id=ev.task_id,
256
+ data_json=ev.model_dump_json()
257
+ )
258
+ self.session.add(model)
259
+ self.session.commit()
260
+
261
+ def get_command_results_for_task(self, task_id: str) -> List[CommandResult]:
262
+ statement = select(EvidenceModel).where(EvidenceModel.task_id == task_id).where(
263
+ EvidenceModel.type == "command"
264
+ )
265
+ models = self.session.exec(statement).all()
266
+ return [CommandResult.model_validate(json.loads(m.data_json)) for m in models]
267
+
227
268
  def get_all(self) -> List[Any]:
228
269
  statement = select(EvidenceModel)
229
270
  models = self.session.exec(statement).all()
230
- results = []
271
+ results: List[Any] = []
231
272
  for m in models:
232
273
  data = json.loads(m.data_json)
233
274
  if m.type == "command":
234
275
  results.append(CommandResult.model_validate(data))
235
276
  elif m.type == "diff":
236
277
  results.append(DiffEvidence.model_validate(data))
278
+ elif m.type == "diff_coverage":
279
+ results.append(DiffCoverageEvidence.model_validate(data))
237
280
  elif m.type == "test":
238
281
  results.append(TestEvidence.model_validate(data))
239
282
  return results
240
283
 
241
284
  def delete_for_task(self, task_id: str):
242
- self.session.exec(delete(EvidenceModel).where(EvidenceModel.task_id == task_id))
285
+ self.session.exec(delete(EvidenceModel).where(col(EvidenceModel.task_id) == task_id))
243
286
  self.session.commit()
244
287
 
245
288
 
@@ -251,19 +294,19 @@ class CritiqueFindingRepository:
251
294
  statement = select(CritiqueFindingModel)
252
295
  models = self.session.exec(statement).all()
253
296
  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
- )
297
+ CritiqueFinding.model_validate({
298
+ "id": m.id,
299
+ "source_agent": m.source_agent,
300
+ "target_plan_id": m.target_plan_id,
301
+ "severity": m.severity,
302
+ "finding_type": m.finding_type,
303
+ "claim": m.claim,
304
+ "linked_requirement_id": m.linked_requirement_id,
305
+ "suggested_requirement": m.suggested_requirement,
306
+ "suggested_task": m.suggested_task,
307
+ "falsifiable_check": m.falsifiable_check,
308
+ "status": m.status,
309
+ })
267
310
  for m in models
268
311
  ]
269
312
 
@@ -386,6 +429,10 @@ class ArtifactGraphRepository:
386
429
  for ev in self.evidence_repo.get_all():
387
430
  if isinstance(ev, CommandResult):
388
431
  graph.add_command_result(ev)
432
+ elif isinstance(ev, DiffCoverageEvidence):
433
+ # Must precede DiffEvidence: distinct type, and the diff↔coverage proof
434
+ # was silently dropped on reload before this branch existed.
435
+ graph.add_diff_coverage_evidence(ev)
389
436
  elif isinstance(ev, DiffEvidence):
390
437
  graph.add_diff_evidence(ev)
391
438
  elif isinstance(ev, TestEvidence):
@@ -416,8 +463,40 @@ class StateRepository:
416
463
  def record_phase(self, phase: str):
417
464
  current = self.get_state()
418
465
  history = []
466
+ prev = None
419
467
  if current:
420
468
  history = json.loads(current.history_json)
421
- if not history or history[-1] != phase:
422
- history.append(phase)
469
+ prev = current.current_phase
470
+ # Keep the persisted history a LEGAL transition sequence. A repair re-run records
471
+ # TASK_EXECUTING straight after a terminal TASK_BLOCKED/TASK_VERIFIED, which is not
472
+ # a direct transition; bridge it through the intermediate phase (TASK_READY) so the
473
+ # state machine's invariant holds on reload. Fail-soft: an unbridgeable jump is
474
+ # still recorded verbatim rather than lost.
475
+ for step in self._bridge_phases(prev, phase):
476
+ if not history or history[-1] != step:
477
+ history.append(step)
423
478
  self.save_state(phase, history)
479
+
480
+ @staticmethod
481
+ def _bridge_phases(prev: Optional[str], target: str) -> List[str]:
482
+ """Return the phases to append so prev -> ... -> target is a legal path.
483
+
484
+ Inserts a single intermediate phase when prev cannot transition directly to
485
+ target but a one-hop bridge exists (the repair loop's
486
+ TASK_BLOCKED/TASK_VERIFIED -> TASK_READY -> TASK_EXECUTING). Returns ``[target]``
487
+ when prev is unknown, equal, already-legal, or no one-hop bridge exists."""
488
+ if prev is None or prev == target:
489
+ return [target]
490
+ try:
491
+ from devcouncil.app.state_machine import TRANSITIONS, ProjectPhase
492
+
493
+ prev_phase = ProjectPhase(prev)
494
+ target_phase = ProjectPhase(target)
495
+ except (ImportError, ValueError):
496
+ return [target]
497
+ if target_phase in TRANSITIONS.get(prev_phase, set()):
498
+ return [target]
499
+ for mid in TRANSITIONS.get(prev_phase, set()):
500
+ if target_phase in TRANSITIONS.get(mid, set()):
501
+ return [mid.value, target]
502
+ 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"]