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.
Files changed (125) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +643 -0
  3. package/bin/devcouncil.js +62 -0
  4. package/package.json +47 -0
  5. package/pyproject.toml +31 -0
  6. package/src/devcouncil/__init__.py +0 -0
  7. package/src/devcouncil/__main__.py +4 -0
  8. package/src/devcouncil/app/__init__.py +28 -0
  9. package/src/devcouncil/app/config.py +131 -0
  10. package/src/devcouncil/app/errors.py +23 -0
  11. package/src/devcouncil/app/events.py +44 -0
  12. package/src/devcouncil/app/orchestrator.py +92 -0
  13. package/src/devcouncil/app/run_context.py +39 -0
  14. package/src/devcouncil/app/state_machine.py +108 -0
  15. package/src/devcouncil/artifacts/__init__.py +1 -0
  16. package/src/devcouncil/artifacts/coverage.py +96 -0
  17. package/src/devcouncil/artifacts/graph.py +143 -0
  18. package/src/devcouncil/artifacts/migrations.py +20 -0
  19. package/src/devcouncil/artifacts/schemas.py +23 -0
  20. package/src/devcouncil/artifacts/serializer.py +21 -0
  21. package/src/devcouncil/artifacts/validators.py +27 -0
  22. package/src/devcouncil/cli/__init__.py +0 -0
  23. package/src/devcouncil/cli/commands/__init__.py +0 -0
  24. package/src/devcouncil/cli/commands/artifacts.py +48 -0
  25. package/src/devcouncil/cli/commands/baseline.py +32 -0
  26. package/src/devcouncil/cli/commands/config.py +54 -0
  27. package/src/devcouncil/cli/commands/doctor.py +96 -0
  28. package/src/devcouncil/cli/commands/hook.py +61 -0
  29. package/src/devcouncil/cli/commands/init.py +142 -0
  30. package/src/devcouncil/cli/commands/integrate.py +420 -0
  31. package/src/devcouncil/cli/commands/map.py +38 -0
  32. package/src/devcouncil/cli/commands/mcp_server.py +18 -0
  33. package/src/devcouncil/cli/commands/plan.py +276 -0
  34. package/src/devcouncil/cli/commands/prompt.py +47 -0
  35. package/src/devcouncil/cli/commands/repair.py +69 -0
  36. package/src/devcouncil/cli/commands/report.py +71 -0
  37. package/src/devcouncil/cli/commands/reset_demo_state.py +28 -0
  38. package/src/devcouncil/cli/commands/rollback.py +58 -0
  39. package/src/devcouncil/cli/commands/run.py +224 -0
  40. package/src/devcouncil/cli/commands/setup.py +82 -0
  41. package/src/devcouncil/cli/commands/show.py +57 -0
  42. package/src/devcouncil/cli/commands/status.py +105 -0
  43. package/src/devcouncil/cli/commands/tasks.py +41 -0
  44. package/src/devcouncil/cli/commands/trace.py +43 -0
  45. package/src/devcouncil/cli/commands/verify.py +163 -0
  46. package/src/devcouncil/cli/commands/version.py +20 -0
  47. package/src/devcouncil/cli/main.py +70 -0
  48. package/src/devcouncil/council/__init__.py +0 -0
  49. package/src/devcouncil/council/prompts/__init__.py +0 -0
  50. package/src/devcouncil/council/prompts/arbiter.md +19 -0
  51. package/src/devcouncil/council/prompts/critic_a.md +10 -0
  52. package/src/devcouncil/council/prompts/critic_b.md +10 -0
  53. package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -0
  54. package/src/devcouncil/council/prompts/planner_a.md +16 -0
  55. package/src/devcouncil/council/prompts/planner_b.md +16 -0
  56. package/src/devcouncil/council/prompts/rebuttal.md +10 -0
  57. package/src/devcouncil/council/prompts/spec_writer.md +12 -0
  58. package/src/devcouncil/domain/__init__.py +0 -0
  59. package/src/devcouncil/domain/assumption.py +17 -0
  60. package/src/devcouncil/domain/critique.py +32 -0
  61. package/src/devcouncil/domain/evidence.py +27 -0
  62. package/src/devcouncil/domain/gap.py +26 -0
  63. package/src/devcouncil/domain/requirement.py +22 -0
  64. package/src/devcouncil/domain/task.py +26 -0
  65. package/src/devcouncil/execution/__init__.py +1 -0
  66. package/src/devcouncil/execution/context_builder.py +60 -0
  67. package/src/devcouncil/execution/executor.py +15 -0
  68. package/src/devcouncil/execution/hook_policy.py +144 -0
  69. package/src/devcouncil/execution/patch.py +28 -0
  70. package/src/devcouncil/execution/paths.py +14 -0
  71. package/src/devcouncil/execution/permissions.py +92 -0
  72. package/src/devcouncil/execution/prompt_builder.py +59 -0
  73. package/src/devcouncil/execution/task_runner.py +166 -0
  74. package/src/devcouncil/executors/__init__.py +1 -0
  75. package/src/devcouncil/executors/mini_swe.py +73 -0
  76. package/src/devcouncil/executors/native/__init__.py +0 -0
  77. package/src/devcouncil/executors/native/agent.py +107 -0
  78. package/src/devcouncil/executors/openhands.py +71 -0
  79. package/src/devcouncil/gating/__init__.py +1 -0
  80. package/src/devcouncil/gating/checks/__init__.py +0 -0
  81. package/src/devcouncil/gating/checks/clean_git.py +45 -0
  82. package/src/devcouncil/gating/checks/planned_files_check.py +32 -0
  83. package/src/devcouncil/gating/checks/requirement_coverage.py +26 -0
  84. package/src/devcouncil/gating/checks/secret_scan_check.py +34 -0
  85. package/src/devcouncil/gating/policy.py +190 -0
  86. package/src/devcouncil/indexing/__init__.py +1 -0
  87. package/src/devcouncil/indexing/graph_index.py +48 -0
  88. package/src/devcouncil/indexing/repo_mapper.py +204 -0
  89. package/src/devcouncil/indexing/symbol_index.py +0 -0
  90. package/src/devcouncil/integrations/code_review_graph.py +163 -0
  91. package/src/devcouncil/integrations/github.py +39 -0
  92. package/src/devcouncil/integrations/gitnexus.py +27 -0
  93. package/src/devcouncil/integrations/graphify.py +34 -0
  94. package/src/devcouncil/integrations/mcp/__init__.py +0 -0
  95. package/src/devcouncil/integrations/mcp/server.py +146 -0
  96. package/src/devcouncil/llm/__init__.py +1 -0
  97. package/src/devcouncil/llm/cache.py +38 -0
  98. package/src/devcouncil/llm/provider.py +125 -0
  99. package/src/devcouncil/llm/router.py +125 -0
  100. package/src/devcouncil/planning/__init__.py +1 -0
  101. package/src/devcouncil/planning/arbiter_service.py +57 -0
  102. package/src/devcouncil/planning/critique_service.py +66 -0
  103. package/src/devcouncil/planning/plan_service.py +46 -0
  104. package/src/devcouncil/planning/repair_service.py +39 -0
  105. package/src/devcouncil/planning/spec_service.py +44 -0
  106. package/src/devcouncil/repo/__init__.py +0 -0
  107. package/src/devcouncil/reporting/__init__.py +0 -0
  108. package/src/devcouncil/reporting/github_check.py +32 -0
  109. package/src/devcouncil/reporting/json_report.py +17 -0
  110. package/src/devcouncil/reporting/markdown_report.py +46 -0
  111. package/src/devcouncil/reporting/report_builder.py +14 -0
  112. package/src/devcouncil/storage/__init__.py +0 -0
  113. package/src/devcouncil/storage/db.py +66 -0
  114. package/src/devcouncil/storage/models.py +83 -0
  115. package/src/devcouncil/storage/repositories.py +346 -0
  116. package/src/devcouncil/telemetry/__init__.py +0 -0
  117. package/src/devcouncil/telemetry/cost.py +34 -0
  118. package/src/devcouncil/telemetry/traces.py +91 -0
  119. package/src/devcouncil/telemetry/tracker.py +49 -0
  120. package/src/devcouncil/utils/__init__.py +1 -0
  121. package/src/devcouncil/utils/redaction.py +141 -0
  122. package/src/devcouncil/verification/__init__.py +1 -0
  123. package/src/devcouncil/verification/implementation_reviewer.py +55 -0
  124. package/src/devcouncil/verification/verifier.py +513 -0
  125. 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."""