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.
- package/README.md +190 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +167 -5
- package/src/devcouncil/artifacts/graph.py +23 -3
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +209 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/doctor.py +221 -21
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +452 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +124 -15
- package/src/devcouncil/cli/commands/init.py +154 -18
- package/src/devcouncil/cli/commands/integrate.py +894 -105
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/plan.py +212 -51
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +40 -23
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +69 -49
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +88 -0
- package/src/devcouncil/cli/commands/status.py +25 -1
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +138 -3
- package/src/devcouncil/cli/commands/watch.py +9 -9
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +56 -7
- package/src/devcouncil/domain/evidence.py +22 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +246 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +180 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +59 -10
- package/src/devcouncil/execution/permissions.py +17 -24
- package/src/devcouncil/execution/policy_engine.py +343 -0
- package/src/devcouncil/execution/prompt_builder.py +633 -21
- package/src/devcouncil/execution/shell_session.py +225 -0
- package/src/devcouncil/execution/task_runner.py +6 -2
- package/src/devcouncil/executors/agent_registry.py +575 -0
- package/src/devcouncil/executors/coding_cli.py +663 -39
- package/src/devcouncil/executors/native/agent.py +121 -20
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
- package/src/devcouncil/gating/policy.py +158 -10
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +1 -1
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1256 -9
- package/src/devcouncil/indexing/semantic_index.py +205 -0
- package/src/devcouncil/integrations/actions.py +146 -0
- package/src/devcouncil/integrations/check.py +423 -0
- package/src/devcouncil/integrations/github_intent.py +142 -0
- package/src/devcouncil/integrations/gitnexus.py +35 -0
- package/src/devcouncil/integrations/mcp/server.py +1552 -29
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/live/cards.py +161 -19
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/transcripts.py +9 -6
- package/src/devcouncil/llm/cache.py +10 -6
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +515 -34
- package/src/devcouncil/llm/router.py +231 -46
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/planning/correction_manifest.py +303 -0
- package/src/devcouncil/planning/critique_service.py +7 -2
- package/src/devcouncil/planning/plan_service.py +17 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
- package/src/devcouncil/planning/spec_service.py +27 -1
- package/src/devcouncil/repo/ci_scaffold.py +157 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +374 -0
- package/src/devcouncil/reporting/json_report.py +11 -1
- package/src/devcouncil/reporting/markdown_report.py +15 -0
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +330 -0
- package/src/devcouncil/storage/db.py +83 -2
- package/src/devcouncil/storage/models.py +121 -0
- package/src/devcouncil/storage/native.py +557 -0
- package/src/devcouncil/storage/repositories.py +137 -75
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +12 -9
- package/src/devcouncil/ui/dashboard.py +324 -23
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +125 -0
- package/src/devcouncil/verification/ad_hoc_check.py +129 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +178 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1065 -47
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from sqlmodel import Session,
|
|
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
|
|
33
|
-
title
|
|
34
|
-
description
|
|
35
|
-
priority
|
|
36
|
-
source
|
|
37
|
-
acceptance_criteria
|
|
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
|
|
64
|
-
statement
|
|
65
|
-
confidence
|
|
66
|
-
impact
|
|
67
|
-
reversible
|
|
68
|
-
requires_user_confirmation
|
|
69
|
-
linked_requirement_ids
|
|
70
|
-
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
|
|
102
|
-
title
|
|
103
|
-
description
|
|
104
|
-
requirement_ids
|
|
105
|
-
acceptance_criterion_ids
|
|
106
|
-
planned_files
|
|
107
|
-
expected_tests
|
|
108
|
-
allowed_commands
|
|
109
|
-
forbidden_changes
|
|
110
|
-
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
|
|
121
|
-
title
|
|
122
|
-
description
|
|
123
|
-
requirement_ids
|
|
124
|
-
acceptance_criterion_ids
|
|
125
|
-
planned_files
|
|
126
|
-
expected_tests
|
|
127
|
-
allowed_commands
|
|
128
|
-
forbidden_changes
|
|
129
|
-
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
|
|
160
|
-
severity
|
|
161
|
-
gap_type
|
|
162
|
-
requirement_id
|
|
163
|
-
task_id
|
|
164
|
-
description
|
|
165
|
-
evidence
|
|
166
|
-
recommended_fix
|
|
167
|
-
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
|
|
256
|
-
source_agent
|
|
257
|
-
target_plan_id
|
|
258
|
-
severity
|
|
259
|
-
finding_type
|
|
260
|
-
claim
|
|
261
|
-
linked_requirement_id
|
|
262
|
-
suggested_requirement
|
|
263
|
-
suggested_task
|
|
264
|
-
falsifiable_check
|
|
265
|
-
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
|
-
|
|
422
|
-
|
|
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
|
|
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 = {"
|
|
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
|
-
|
|
26
|
-
|
|
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["
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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=
|
|
40
|
-
task_id=
|
|
41
|
-
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
|