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.
- package/README.md +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- 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 +220 -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/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- 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 +195 -54
- 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 +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- 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 +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- 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):
|
|
@@ -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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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
|
-
)
|
|
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
|
-
|
|
422
|
-
|
|
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
|
|
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"]
|