devcouncil 0.1.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/LICENSE +201 -0
- package/README.md +643 -0
- package/bin/devcouncil.js +62 -0
- package/package.json +47 -0
- package/pyproject.toml +31 -0
- package/src/devcouncil/__init__.py +0 -0
- package/src/devcouncil/__main__.py +4 -0
- package/src/devcouncil/app/__init__.py +28 -0
- package/src/devcouncil/app/config.py +131 -0
- package/src/devcouncil/app/errors.py +23 -0
- package/src/devcouncil/app/events.py +44 -0
- package/src/devcouncil/app/orchestrator.py +92 -0
- package/src/devcouncil/app/run_context.py +39 -0
- package/src/devcouncil/app/state_machine.py +108 -0
- package/src/devcouncil/artifacts/__init__.py +1 -0
- package/src/devcouncil/artifacts/coverage.py +96 -0
- package/src/devcouncil/artifacts/graph.py +143 -0
- package/src/devcouncil/artifacts/migrations.py +20 -0
- package/src/devcouncil/artifacts/schemas.py +23 -0
- package/src/devcouncil/artifacts/serializer.py +21 -0
- package/src/devcouncil/artifacts/validators.py +27 -0
- package/src/devcouncil/cli/__init__.py +0 -0
- package/src/devcouncil/cli/commands/__init__.py +0 -0
- package/src/devcouncil/cli/commands/artifacts.py +48 -0
- package/src/devcouncil/cli/commands/baseline.py +32 -0
- package/src/devcouncil/cli/commands/config.py +54 -0
- package/src/devcouncil/cli/commands/doctor.py +96 -0
- package/src/devcouncil/cli/commands/hook.py +61 -0
- package/src/devcouncil/cli/commands/init.py +142 -0
- package/src/devcouncil/cli/commands/integrate.py +420 -0
- package/src/devcouncil/cli/commands/map.py +38 -0
- package/src/devcouncil/cli/commands/mcp_server.py +18 -0
- package/src/devcouncil/cli/commands/plan.py +276 -0
- package/src/devcouncil/cli/commands/prompt.py +47 -0
- package/src/devcouncil/cli/commands/repair.py +69 -0
- package/src/devcouncil/cli/commands/report.py +71 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +28 -0
- package/src/devcouncil/cli/commands/rollback.py +58 -0
- package/src/devcouncil/cli/commands/run.py +224 -0
- package/src/devcouncil/cli/commands/setup.py +82 -0
- package/src/devcouncil/cli/commands/show.py +57 -0
- package/src/devcouncil/cli/commands/status.py +105 -0
- package/src/devcouncil/cli/commands/tasks.py +41 -0
- package/src/devcouncil/cli/commands/trace.py +43 -0
- package/src/devcouncil/cli/commands/verify.py +163 -0
- package/src/devcouncil/cli/commands/version.py +20 -0
- package/src/devcouncil/cli/main.py +70 -0
- package/src/devcouncil/council/__init__.py +0 -0
- package/src/devcouncil/council/prompts/__init__.py +0 -0
- package/src/devcouncil/council/prompts/arbiter.md +19 -0
- package/src/devcouncil/council/prompts/critic_a.md +10 -0
- package/src/devcouncil/council/prompts/critic_b.md +10 -0
- package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -0
- package/src/devcouncil/council/prompts/planner_a.md +16 -0
- package/src/devcouncil/council/prompts/planner_b.md +16 -0
- package/src/devcouncil/council/prompts/rebuttal.md +10 -0
- package/src/devcouncil/council/prompts/spec_writer.md +12 -0
- package/src/devcouncil/domain/__init__.py +0 -0
- package/src/devcouncil/domain/assumption.py +17 -0
- package/src/devcouncil/domain/critique.py +32 -0
- package/src/devcouncil/domain/evidence.py +27 -0
- package/src/devcouncil/domain/gap.py +26 -0
- package/src/devcouncil/domain/requirement.py +22 -0
- package/src/devcouncil/domain/task.py +26 -0
- package/src/devcouncil/execution/__init__.py +1 -0
- package/src/devcouncil/execution/context_builder.py +60 -0
- package/src/devcouncil/execution/executor.py +15 -0
- package/src/devcouncil/execution/hook_policy.py +144 -0
- package/src/devcouncil/execution/patch.py +28 -0
- package/src/devcouncil/execution/paths.py +14 -0
- package/src/devcouncil/execution/permissions.py +92 -0
- package/src/devcouncil/execution/prompt_builder.py +59 -0
- package/src/devcouncil/execution/task_runner.py +166 -0
- package/src/devcouncil/executors/__init__.py +1 -0
- package/src/devcouncil/executors/mini_swe.py +73 -0
- package/src/devcouncil/executors/native/__init__.py +0 -0
- package/src/devcouncil/executors/native/agent.py +107 -0
- package/src/devcouncil/executors/openhands.py +71 -0
- package/src/devcouncil/gating/__init__.py +1 -0
- package/src/devcouncil/gating/checks/__init__.py +0 -0
- package/src/devcouncil/gating/checks/clean_git.py +45 -0
- package/src/devcouncil/gating/checks/planned_files_check.py +32 -0
- package/src/devcouncil/gating/checks/requirement_coverage.py +26 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +34 -0
- package/src/devcouncil/gating/policy.py +190 -0
- package/src/devcouncil/indexing/__init__.py +1 -0
- package/src/devcouncil/indexing/graph_index.py +48 -0
- package/src/devcouncil/indexing/repo_mapper.py +204 -0
- package/src/devcouncil/indexing/symbol_index.py +0 -0
- package/src/devcouncil/integrations/code_review_graph.py +163 -0
- package/src/devcouncil/integrations/github.py +39 -0
- package/src/devcouncil/integrations/gitnexus.py +27 -0
- package/src/devcouncil/integrations/graphify.py +34 -0
- package/src/devcouncil/integrations/mcp/__init__.py +0 -0
- package/src/devcouncil/integrations/mcp/server.py +146 -0
- package/src/devcouncil/llm/__init__.py +1 -0
- package/src/devcouncil/llm/cache.py +38 -0
- package/src/devcouncil/llm/provider.py +125 -0
- package/src/devcouncil/llm/router.py +125 -0
- package/src/devcouncil/planning/__init__.py +1 -0
- package/src/devcouncil/planning/arbiter_service.py +57 -0
- package/src/devcouncil/planning/critique_service.py +66 -0
- package/src/devcouncil/planning/plan_service.py +46 -0
- package/src/devcouncil/planning/repair_service.py +39 -0
- package/src/devcouncil/planning/spec_service.py +44 -0
- package/src/devcouncil/repo/__init__.py +0 -0
- package/src/devcouncil/reporting/__init__.py +0 -0
- package/src/devcouncil/reporting/github_check.py +32 -0
- package/src/devcouncil/reporting/json_report.py +17 -0
- package/src/devcouncil/reporting/markdown_report.py +46 -0
- package/src/devcouncil/reporting/report_builder.py +14 -0
- package/src/devcouncil/storage/__init__.py +0 -0
- package/src/devcouncil/storage/db.py +66 -0
- package/src/devcouncil/storage/models.py +83 -0
- package/src/devcouncil/storage/repositories.py +346 -0
- package/src/devcouncil/telemetry/__init__.py +0 -0
- package/src/devcouncil/telemetry/cost.py +34 -0
- package/src/devcouncil/telemetry/traces.py +91 -0
- package/src/devcouncil/telemetry/tracker.py +49 -0
- package/src/devcouncil/utils/__init__.py +1 -0
- package/src/devcouncil/utils/redaction.py +141 -0
- package/src/devcouncil/verification/__init__.py +1 -0
- package/src/devcouncil/verification/implementation_reviewer.py +55 -0
- package/src/devcouncil/verification/verifier.py +513 -0
- package/uv.lock +1085 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
|
|
3
|
+
from sqlmodel import SQLModel, create_engine, Session
|
|
4
|
+
from sqlalchemy.exc import OperationalError
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from devcouncil.storage.models import SchemaVersionModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SCHEMA_VERSION = 1
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Database:
|
|
15
|
+
def __init__(self, db_path: Path):
|
|
16
|
+
self.db_path = db_path
|
|
17
|
+
self.engine = create_engine(f"sqlite:///{db_path}")
|
|
18
|
+
|
|
19
|
+
def create_db_and_tables(self):
|
|
20
|
+
self._create_tables()
|
|
21
|
+
self.ensure_schema_version()
|
|
22
|
+
|
|
23
|
+
def ensure_schema_version(self):
|
|
24
|
+
self._create_tables()
|
|
25
|
+
with Session(self.engine) as session:
|
|
26
|
+
current = session.get(SchemaVersionModel, "singleton")
|
|
27
|
+
if current is None:
|
|
28
|
+
session.add(SchemaVersionModel(id="singleton", version=SCHEMA_VERSION))
|
|
29
|
+
session.commit()
|
|
30
|
+
return
|
|
31
|
+
if current.version != SCHEMA_VERSION:
|
|
32
|
+
raise RuntimeError(
|
|
33
|
+
f"Unsupported DevCouncil schema version {current.version}; "
|
|
34
|
+
f"expected {SCHEMA_VERSION}."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def _create_tables(self):
|
|
38
|
+
try:
|
|
39
|
+
SQLModel.metadata.create_all(self.engine)
|
|
40
|
+
except OperationalError as exc:
|
|
41
|
+
if "already exists" not in str(exc):
|
|
42
|
+
raise
|
|
43
|
+
|
|
44
|
+
@contextmanager
|
|
45
|
+
def get_session(self):
|
|
46
|
+
"""Yield a session with automatic commit/rollback/close."""
|
|
47
|
+
session = Session(self.engine)
|
|
48
|
+
try:
|
|
49
|
+
yield session
|
|
50
|
+
session.commit()
|
|
51
|
+
except Exception:
|
|
52
|
+
session.rollback()
|
|
53
|
+
raise
|
|
54
|
+
finally:
|
|
55
|
+
session.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_db(project_root: Path = Path(".")) -> Optional[Database]:
|
|
59
|
+
dev_dir = project_root / ".devcouncil"
|
|
60
|
+
if not dev_dir.exists():
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
db_path = dev_dir / "state.sqlite"
|
|
64
|
+
db = Database(db_path)
|
|
65
|
+
db.ensure_schema_version()
|
|
66
|
+
return db
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from sqlmodel import SQLModel, Field
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SchemaVersionModel(SQLModel, table=True):
|
|
6
|
+
__tablename__ = "schema_version"
|
|
7
|
+
id: str = Field(primary_key=True, default="singleton")
|
|
8
|
+
version: int
|
|
9
|
+
|
|
10
|
+
class RequirementModel(SQLModel, table=True):
|
|
11
|
+
__tablename__ = "requirements"
|
|
12
|
+
id: str = Field(primary_key=True)
|
|
13
|
+
title: str
|
|
14
|
+
description: str
|
|
15
|
+
priority: str
|
|
16
|
+
source: str
|
|
17
|
+
# Store complex lists as JSON strings for simplicity in SQLite
|
|
18
|
+
acceptance_criteria_json: str = Field(default="[]")
|
|
19
|
+
|
|
20
|
+
class AssumptionModel(SQLModel, table=True):
|
|
21
|
+
__tablename__ = "assumptions"
|
|
22
|
+
id: str = Field(primary_key=True)
|
|
23
|
+
statement: str
|
|
24
|
+
confidence: str
|
|
25
|
+
impact: str
|
|
26
|
+
reversible: bool
|
|
27
|
+
requires_user_confirmation: bool
|
|
28
|
+
linked_requirement_ids_json: str = Field(default="[]")
|
|
29
|
+
status: str = "open"
|
|
30
|
+
|
|
31
|
+
class TaskModel(SQLModel, table=True):
|
|
32
|
+
__tablename__ = "tasks"
|
|
33
|
+
id: str = Field(primary_key=True)
|
|
34
|
+
title: str
|
|
35
|
+
description: str
|
|
36
|
+
requirement_ids_json: str = Field(default="[]")
|
|
37
|
+
acceptance_criterion_ids_json: str = Field(default="[]")
|
|
38
|
+
planned_files_json: str = Field(default="[]")
|
|
39
|
+
expected_tests_json: str = Field(default="[]")
|
|
40
|
+
allowed_commands_json: str = Field(default="[]")
|
|
41
|
+
forbidden_changes_json: str = Field(default="[]")
|
|
42
|
+
status: str = "planned"
|
|
43
|
+
|
|
44
|
+
class EvidenceModel(SQLModel, table=True):
|
|
45
|
+
__tablename__ = "evidence"
|
|
46
|
+
id: Optional[int] = Field(default=None, primary_key=True)
|
|
47
|
+
type: str # "command", "diff", "test"
|
|
48
|
+
task_id: Optional[str] = None
|
|
49
|
+
requirement_id: Optional[str] = None
|
|
50
|
+
acceptance_criterion_id: Optional[str] = None
|
|
51
|
+
data_json: str
|
|
52
|
+
|
|
53
|
+
class GapModel(SQLModel, table=True):
|
|
54
|
+
__tablename__ = "gaps"
|
|
55
|
+
id: str = Field(primary_key=True)
|
|
56
|
+
severity: str
|
|
57
|
+
gap_type: str
|
|
58
|
+
requirement_id: Optional[str] = None
|
|
59
|
+
task_id: Optional[str] = None
|
|
60
|
+
description: str
|
|
61
|
+
evidence_json: str = Field(default="[]")
|
|
62
|
+
recommended_fix: str
|
|
63
|
+
blocking: bool
|
|
64
|
+
|
|
65
|
+
class CritiqueFindingModel(SQLModel, table=True):
|
|
66
|
+
__tablename__ = "critique_findings"
|
|
67
|
+
id: str = Field(primary_key=True)
|
|
68
|
+
source_agent: str
|
|
69
|
+
target_plan_id: str
|
|
70
|
+
severity: str
|
|
71
|
+
finding_type: str
|
|
72
|
+
claim: str
|
|
73
|
+
linked_requirement_id: Optional[str] = None
|
|
74
|
+
suggested_requirement: Optional[str] = None
|
|
75
|
+
suggested_task: Optional[str] = None
|
|
76
|
+
falsifiable_check: str
|
|
77
|
+
status: str = "open"
|
|
78
|
+
|
|
79
|
+
class ProjectStateModel(SQLModel, table=True):
|
|
80
|
+
__tablename__ = "project_state"
|
|
81
|
+
id: str = Field(primary_key=True, default="singleton")
|
|
82
|
+
current_phase: str
|
|
83
|
+
history_json: str = Field(default="[]")
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
from sqlmodel import Session, select, delete
|
|
2
|
+
from typing import List, Optional, Any
|
|
3
|
+
import json
|
|
4
|
+
from devcouncil.storage.models import (
|
|
5
|
+
RequirementModel,
|
|
6
|
+
AssumptionModel,
|
|
7
|
+
TaskModel,
|
|
8
|
+
GapModel,
|
|
9
|
+
EvidenceModel,
|
|
10
|
+
CritiqueFindingModel,
|
|
11
|
+
ProjectStateModel,
|
|
12
|
+
)
|
|
13
|
+
from devcouncil.domain.requirement import Requirement, AcceptanceCriterion
|
|
14
|
+
from devcouncil.domain.assumption import Assumption
|
|
15
|
+
from devcouncil.domain.task import Task, PlannedFile
|
|
16
|
+
from devcouncil.domain.gap import Gap
|
|
17
|
+
from devcouncil.domain.evidence import CommandResult, DiffEvidence, TestEvidence
|
|
18
|
+
from devcouncil.domain.critique import CritiqueFinding
|
|
19
|
+
from devcouncil.artifacts.graph import ArtifactGraph
|
|
20
|
+
|
|
21
|
+
class RequirementRepository:
|
|
22
|
+
def __init__(self, session: Session):
|
|
23
|
+
self.session = session
|
|
24
|
+
|
|
25
|
+
def get_all(self) -> List[Requirement]:
|
|
26
|
+
statement = select(RequirementModel)
|
|
27
|
+
models = self.session.exec(statement).all()
|
|
28
|
+
results = []
|
|
29
|
+
for m in models:
|
|
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
|
+
))
|
|
39
|
+
return results
|
|
40
|
+
|
|
41
|
+
def save(self, req: Requirement):
|
|
42
|
+
model = RequirementModel(
|
|
43
|
+
id=req.id,
|
|
44
|
+
title=req.title,
|
|
45
|
+
description=req.description,
|
|
46
|
+
priority=req.priority,
|
|
47
|
+
source=req.source,
|
|
48
|
+
acceptance_criteria_json=json.dumps([ac.model_dump() for ac in req.acceptance_criteria])
|
|
49
|
+
)
|
|
50
|
+
self.session.merge(model)
|
|
51
|
+
self.session.commit()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AssumptionRepository:
|
|
55
|
+
def __init__(self, session: Session):
|
|
56
|
+
self.session = session
|
|
57
|
+
|
|
58
|
+
def get_all(self) -> List[Assumption]:
|
|
59
|
+
statement = select(AssumptionModel)
|
|
60
|
+
models = self.session.exec(statement).all()
|
|
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
|
+
)
|
|
72
|
+
for m in models
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
def save(self, assumption: Assumption):
|
|
76
|
+
model = AssumptionModel(
|
|
77
|
+
id=assumption.id,
|
|
78
|
+
statement=assumption.statement,
|
|
79
|
+
confidence=assumption.confidence,
|
|
80
|
+
impact=assumption.impact,
|
|
81
|
+
reversible=assumption.reversible,
|
|
82
|
+
requires_user_confirmation=assumption.requires_user_confirmation,
|
|
83
|
+
linked_requirement_ids_json=json.dumps(assumption.linked_requirement_ids),
|
|
84
|
+
status=assumption.status,
|
|
85
|
+
)
|
|
86
|
+
self.session.merge(model)
|
|
87
|
+
self.session.commit()
|
|
88
|
+
|
|
89
|
+
class TaskRepository:
|
|
90
|
+
def __init__(self, session: Session):
|
|
91
|
+
self.session = session
|
|
92
|
+
|
|
93
|
+
def get_by_id(self, task_id: str) -> Optional[Task]:
|
|
94
|
+
statement = select(TaskModel).where(TaskModel.id == task_id)
|
|
95
|
+
m = self.session.exec(statement).first()
|
|
96
|
+
if not m:
|
|
97
|
+
return None
|
|
98
|
+
|
|
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
|
+
)
|
|
112
|
+
|
|
113
|
+
def get_all(self) -> List[Task]:
|
|
114
|
+
statement = select(TaskModel)
|
|
115
|
+
models = self.session.exec(statement).all()
|
|
116
|
+
results = []
|
|
117
|
+
for m in models:
|
|
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
|
+
))
|
|
131
|
+
return results
|
|
132
|
+
|
|
133
|
+
def save(self, task: Task):
|
|
134
|
+
model = TaskModel(
|
|
135
|
+
id=task.id,
|
|
136
|
+
title=task.title,
|
|
137
|
+
description=task.description,
|
|
138
|
+
requirement_ids_json=json.dumps(task.requirement_ids),
|
|
139
|
+
acceptance_criterion_ids_json=json.dumps(task.acceptance_criterion_ids),
|
|
140
|
+
planned_files_json=json.dumps([pf.model_dump() for pf in task.planned_files]),
|
|
141
|
+
expected_tests_json=json.dumps(task.expected_tests),
|
|
142
|
+
allowed_commands_json=json.dumps(task.allowed_commands),
|
|
143
|
+
forbidden_changes_json=json.dumps(task.forbidden_changes),
|
|
144
|
+
status=task.status
|
|
145
|
+
)
|
|
146
|
+
self.session.merge(model)
|
|
147
|
+
self.session.commit()
|
|
148
|
+
|
|
149
|
+
class GapRepository:
|
|
150
|
+
def __init__(self, session: Session):
|
|
151
|
+
self.session = session
|
|
152
|
+
|
|
153
|
+
def get_all(self) -> List[Gap]:
|
|
154
|
+
statement = select(GapModel)
|
|
155
|
+
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
|
|
170
|
+
|
|
171
|
+
def save(self, gap: Gap):
|
|
172
|
+
model = GapModel(
|
|
173
|
+
id=gap.id,
|
|
174
|
+
severity=gap.severity,
|
|
175
|
+
gap_type=gap.gap_type,
|
|
176
|
+
requirement_id=gap.requirement_id,
|
|
177
|
+
task_id=gap.task_id,
|
|
178
|
+
description=gap.description,
|
|
179
|
+
evidence_json=json.dumps(gap.evidence),
|
|
180
|
+
recommended_fix=gap.recommended_fix,
|
|
181
|
+
blocking=gap.blocking
|
|
182
|
+
)
|
|
183
|
+
self.session.merge(model)
|
|
184
|
+
self.session.commit()
|
|
185
|
+
|
|
186
|
+
def delete_for_task(self, task_id: str):
|
|
187
|
+
self.session.exec(delete(GapModel).where(GapModel.task_id == task_id))
|
|
188
|
+
self.session.commit()
|
|
189
|
+
|
|
190
|
+
def delete_plan_gaps(self):
|
|
191
|
+
self.session.exec(delete(GapModel).where(GapModel.id.like("GAP-PLAN-%")))
|
|
192
|
+
self.session.commit()
|
|
193
|
+
|
|
194
|
+
class EvidenceRepository:
|
|
195
|
+
def __init__(self, session: Session):
|
|
196
|
+
self.session = session
|
|
197
|
+
|
|
198
|
+
def save_command_result(self, task_id: str, result: CommandResult):
|
|
199
|
+
model = EvidenceModel(
|
|
200
|
+
type="command",
|
|
201
|
+
task_id=task_id,
|
|
202
|
+
data_json=result.model_dump_json()
|
|
203
|
+
)
|
|
204
|
+
self.session.add(model)
|
|
205
|
+
self.session.commit()
|
|
206
|
+
|
|
207
|
+
def save_diff_evidence(self, ev: DiffEvidence):
|
|
208
|
+
model = EvidenceModel(
|
|
209
|
+
type="diff",
|
|
210
|
+
task_id=ev.task_id,
|
|
211
|
+
data_json=ev.model_dump_json()
|
|
212
|
+
)
|
|
213
|
+
self.session.add(model)
|
|
214
|
+
self.session.commit()
|
|
215
|
+
|
|
216
|
+
def save_test_evidence(self, ev: TestEvidence, task_id: Optional[str] = None):
|
|
217
|
+
model = EvidenceModel(
|
|
218
|
+
type="test",
|
|
219
|
+
task_id=task_id,
|
|
220
|
+
requirement_id=ev.requirement_id,
|
|
221
|
+
acceptance_criterion_id=ev.acceptance_criterion_id,
|
|
222
|
+
data_json=ev.model_dump_json()
|
|
223
|
+
)
|
|
224
|
+
self.session.add(model)
|
|
225
|
+
self.session.commit()
|
|
226
|
+
|
|
227
|
+
def get_all(self) -> List[Any]:
|
|
228
|
+
statement = select(EvidenceModel)
|
|
229
|
+
models = self.session.exec(statement).all()
|
|
230
|
+
results = []
|
|
231
|
+
for m in models:
|
|
232
|
+
data = json.loads(m.data_json)
|
|
233
|
+
if m.type == "command":
|
|
234
|
+
results.append(CommandResult.model_validate(data))
|
|
235
|
+
elif m.type == "diff":
|
|
236
|
+
results.append(DiffEvidence.model_validate(data))
|
|
237
|
+
elif m.type == "test":
|
|
238
|
+
results.append(TestEvidence.model_validate(data))
|
|
239
|
+
return results
|
|
240
|
+
|
|
241
|
+
def delete_for_task(self, task_id: str):
|
|
242
|
+
self.session.exec(delete(EvidenceModel).where(EvidenceModel.task_id == task_id))
|
|
243
|
+
self.session.commit()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class CritiqueFindingRepository:
|
|
247
|
+
def __init__(self, session: Session):
|
|
248
|
+
self.session = session
|
|
249
|
+
|
|
250
|
+
def get_all(self) -> List[CritiqueFinding]:
|
|
251
|
+
statement = select(CritiqueFindingModel)
|
|
252
|
+
models = self.session.exec(statement).all()
|
|
253
|
+
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
|
+
)
|
|
267
|
+
for m in models
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
def save(self, finding: CritiqueFinding):
|
|
271
|
+
model = CritiqueFindingModel(
|
|
272
|
+
id=finding.id,
|
|
273
|
+
source_agent=finding.source_agent,
|
|
274
|
+
target_plan_id=finding.target_plan_id,
|
|
275
|
+
severity=finding.severity,
|
|
276
|
+
finding_type=finding.finding_type,
|
|
277
|
+
claim=finding.claim,
|
|
278
|
+
linked_requirement_id=finding.linked_requirement_id,
|
|
279
|
+
suggested_requirement=finding.suggested_requirement,
|
|
280
|
+
suggested_task=finding.suggested_task,
|
|
281
|
+
falsifiable_check=finding.falsifiable_check,
|
|
282
|
+
status=finding.status,
|
|
283
|
+
)
|
|
284
|
+
self.session.merge(model)
|
|
285
|
+
self.session.commit()
|
|
286
|
+
|
|
287
|
+
class ArtifactGraphRepository:
|
|
288
|
+
def __init__(self, session: Session):
|
|
289
|
+
self.session = session
|
|
290
|
+
self.req_repo = RequirementRepository(session)
|
|
291
|
+
self.assumption_repo = AssumptionRepository(session)
|
|
292
|
+
self.task_repo = TaskRepository(session)
|
|
293
|
+
self.gap_repo = GapRepository(session)
|
|
294
|
+
self.evidence_repo = EvidenceRepository(session)
|
|
295
|
+
self.finding_repo = CritiqueFindingRepository(session)
|
|
296
|
+
|
|
297
|
+
def load_graph(self) -> ArtifactGraph:
|
|
298
|
+
graph = ArtifactGraph()
|
|
299
|
+
for req in self.req_repo.get_all():
|
|
300
|
+
graph.add_requirement(req)
|
|
301
|
+
for assumption in self.assumption_repo.get_all():
|
|
302
|
+
graph.add_assumption(assumption)
|
|
303
|
+
for task in self.task_repo.get_all():
|
|
304
|
+
graph.add_task(task)
|
|
305
|
+
for finding in self.finding_repo.get_all():
|
|
306
|
+
graph.add_finding(finding)
|
|
307
|
+
for gap in self.gap_repo.get_all():
|
|
308
|
+
graph.add_gap(gap)
|
|
309
|
+
for ev in self.evidence_repo.get_all():
|
|
310
|
+
if isinstance(ev, CommandResult):
|
|
311
|
+
graph.add_command_result(ev)
|
|
312
|
+
elif isinstance(ev, DiffEvidence):
|
|
313
|
+
graph.add_diff_evidence(ev)
|
|
314
|
+
elif isinstance(ev, TestEvidence):
|
|
315
|
+
graph.add_test_evidence(ev)
|
|
316
|
+
return graph
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class StateRepository:
|
|
320
|
+
def __init__(self, session: Session):
|
|
321
|
+
self.session = session
|
|
322
|
+
|
|
323
|
+
def get_state(self) -> Optional[ProjectStateModel]:
|
|
324
|
+
statement = select(ProjectStateModel).where(ProjectStateModel.id == "singleton")
|
|
325
|
+
return self.session.exec(statement).first()
|
|
326
|
+
|
|
327
|
+
def save_state(self, state: ProjectStateModel | str, history: Optional[List[str]] = None):
|
|
328
|
+
if isinstance(state, ProjectStateModel):
|
|
329
|
+
state.id = "singleton"
|
|
330
|
+
else:
|
|
331
|
+
state = ProjectStateModel(
|
|
332
|
+
id="singleton",
|
|
333
|
+
current_phase=state,
|
|
334
|
+
history_json=json.dumps(history or []),
|
|
335
|
+
)
|
|
336
|
+
self.session.merge(state)
|
|
337
|
+
self.session.commit()
|
|
338
|
+
|
|
339
|
+
def record_phase(self, phase: str):
|
|
340
|
+
current = self.get_state()
|
|
341
|
+
history = []
|
|
342
|
+
if current:
|
|
343
|
+
history = json.loads(current.history_json)
|
|
344
|
+
if not history or history[-1] != phase:
|
|
345
|
+
history.append(phase)
|
|
346
|
+
self.save_state(phase, history)
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Dict
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
class CostEstimator:
|
|
7
|
+
"""Estimates LLM usage cost based on provider pricing."""
|
|
8
|
+
|
|
9
|
+
# Rough estimates, update for production
|
|
10
|
+
PRICING = {
|
|
11
|
+
"anthropic/claude-3.5-sonnet": {"input": 0.000003, "output": 0.000015},
|
|
12
|
+
"anthropic/claude-3-opus": {"input": 0.000015, "output": 0.000075},
|
|
13
|
+
"anthropic/claude-sonnet-4": {"input": 0.000003, "output": 0.000015},
|
|
14
|
+
"google/gemini-pro-1.5": {"input": 0.00000125, "output": 0.000005},
|
|
15
|
+
"google/gemini-2.5-pro": {"input": 0.00000125, "output": 0.00001},
|
|
16
|
+
"openai/gpt-4o": {"input": 0.000005, "output": 0.000015},
|
|
17
|
+
"openai/o3-mini": {"input": 0.0000011, "output": 0.0000044},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# Conservative default for unknown models
|
|
21
|
+
DEFAULT_PRICING = {"input": 0.000005, "output": 0.000015}
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def estimate_cost(cls, model: str, usage: Dict[str, int]) -> float:
|
|
25
|
+
prices = cls.PRICING.get(model)
|
|
26
|
+
if not prices:
|
|
27
|
+
logger.debug("Unknown model for cost estimation: %s — using default pricing", model)
|
|
28
|
+
prices = cls.DEFAULT_PRICING
|
|
29
|
+
|
|
30
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
31
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
32
|
+
|
|
33
|
+
cost = (prompt_tokens * prices["input"]) + (completion_tokens * prices["output"])
|
|
34
|
+
return cost
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Iterable, Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
TRACE_SCHEMA_VERSION = "devcouncil.trace.v1"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TraceEvent(BaseModel):
|
|
16
|
+
"""Stable JSONL event shape for DevCouncil and external visualizers."""
|
|
17
|
+
|
|
18
|
+
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
|
19
|
+
|
|
20
|
+
schema_version: str = Field(TRACE_SCHEMA_VERSION, alias="schema")
|
|
21
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
22
|
+
timestamp: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
23
|
+
runtime: str = "devcouncil"
|
|
24
|
+
type: str
|
|
25
|
+
run_id: Optional[str] = None
|
|
26
|
+
task_id: Optional[str] = None
|
|
27
|
+
summary: str = ""
|
|
28
|
+
details: Dict[str, Any] = Field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_legacy(cls, raw: Dict[str, Any]) -> "TraceEvent":
|
|
32
|
+
if raw.get("schema") == TRACE_SCHEMA_VERSION:
|
|
33
|
+
return cls.model_validate(raw)
|
|
34
|
+
details = raw.get("details") if isinstance(raw.get("details"), dict) else {}
|
|
35
|
+
task_id = details.get("task_id") if isinstance(details.get("task_id"), str) else None
|
|
36
|
+
summary = details.get("summary") if isinstance(details.get("summary"), str) else ""
|
|
37
|
+
return cls(
|
|
38
|
+
type=str(raw.get("type", "legacy_event")),
|
|
39
|
+
run_id=raw.get("run_id"),
|
|
40
|
+
task_id=task_id,
|
|
41
|
+
summary=summary,
|
|
42
|
+
details=details,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TraceLogger:
|
|
47
|
+
"""Manages appending execution traces to a local log file."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, project_root: Path):
|
|
50
|
+
self.log_dir = project_root / ".devcouncil" / "logs"
|
|
51
|
+
self.trace_file = self.log_dir / "traces.jsonl"
|
|
52
|
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
def log_event(
|
|
55
|
+
self,
|
|
56
|
+
event_type: str,
|
|
57
|
+
details: Dict[str, Any],
|
|
58
|
+
run_id: Optional[str] = None,
|
|
59
|
+
task_id: Optional[str] = None,
|
|
60
|
+
summary: str = "",
|
|
61
|
+
) -> TraceEvent:
|
|
62
|
+
"""Append an orchestration event trace."""
|
|
63
|
+
trace = TraceEvent(
|
|
64
|
+
type=event_type,
|
|
65
|
+
details=details,
|
|
66
|
+
run_id=run_id,
|
|
67
|
+
task_id=task_id,
|
|
68
|
+
summary=summary,
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
with open(self.trace_file, "a", encoding="utf-8") as f:
|
|
72
|
+
f.write(trace.model_dump_json() + "\n")
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.debug("Failed to write trace event %s: %s", event_type, e)
|
|
75
|
+
return trace
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_trace_events(project_root: Path) -> Iterable[TraceEvent]:
|
|
79
|
+
trace_file = project_root / ".devcouncil" / "logs" / "traces.jsonl"
|
|
80
|
+
if not trace_file.exists():
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
events: list[TraceEvent] = []
|
|
84
|
+
for line in trace_file.read_text(encoding="utf-8").splitlines():
|
|
85
|
+
if not line.strip():
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
events.append(TraceEvent.from_legacy(json.loads(line)))
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
logger.debug("Skipping invalid trace line: %s", exc)
|
|
91
|
+
return events
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
|
|
5
|
+
COST_PER_1K_TOKENS = {
|
|
6
|
+
"anthropic/claude-3-opus": {"prompt": 0.015, "completion": 0.075},
|
|
7
|
+
"anthropic/claude-3.5-sonnet": {"prompt": 0.003, "completion": 0.015},
|
|
8
|
+
"openai/gpt-4o": {"prompt": 0.005, "completion": 0.015},
|
|
9
|
+
"google/gemini-pro-1.5": {"prompt": 0.00125, "completion": 0.00375},
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class TelemetryTracker:
|
|
13
|
+
def __init__(self, project_root: Path):
|
|
14
|
+
self.log_file = project_root / ".devcouncil" / "logs" / "telemetry.json"
|
|
15
|
+
self.stats = self._load()
|
|
16
|
+
|
|
17
|
+
def _load(self) -> Dict[str, Any]:
|
|
18
|
+
if self.log_file.exists():
|
|
19
|
+
try:
|
|
20
|
+
with open(self.log_file, "r") as f:
|
|
21
|
+
return json.load(f)
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
return {"total_cost": 0.0, "total_prompt_tokens": 0, "total_completion_tokens": 0, "models": {}}
|
|
25
|
+
|
|
26
|
+
def _save(self):
|
|
27
|
+
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
with open(self.log_file, "w") as f:
|
|
29
|
+
json.dump(self.stats, f, indent=2)
|
|
30
|
+
|
|
31
|
+
def log_usage(self, model: str, usage: Dict[str, int]):
|
|
32
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
33
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
34
|
+
|
|
35
|
+
rates = COST_PER_1K_TOKENS.get(model, {"prompt": 0.0, "completion": 0.0})
|
|
36
|
+
cost = (prompt_tokens / 1000.0) * rates["prompt"] + (completion_tokens / 1000.0) * rates["completion"]
|
|
37
|
+
|
|
38
|
+
self.stats["total_cost"] += cost
|
|
39
|
+
self.stats["total_prompt_tokens"] += prompt_tokens
|
|
40
|
+
self.stats["total_completion_tokens"] += completion_tokens
|
|
41
|
+
|
|
42
|
+
if model not in self.stats["models"]:
|
|
43
|
+
self.stats["models"][model] = {"cost": 0.0, "prompt_tokens": 0, "completion_tokens": 0}
|
|
44
|
+
|
|
45
|
+
self.stats["models"][model]["cost"] += cost
|
|
46
|
+
self.stats["models"][model]["prompt_tokens"] += prompt_tokens
|
|
47
|
+
self.stats["models"][model]["completion_tokens"] += completion_tokens
|
|
48
|
+
|
|
49
|
+
self._save()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utilities package: redaction, paths, hashing, subprocess helpers."""
|