devcouncil 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +197 -494
  3. package/package.json +9 -2
  4. package/pyproject.toml +62 -27
  5. package/src/devcouncil/__main__.py +4 -4
  6. package/src/devcouncil/app/__init__.py +28 -28
  7. package/src/devcouncil/app/config.py +297 -108
  8. package/src/devcouncil/app/errors.py +23 -23
  9. package/src/devcouncil/app/events.py +44 -44
  10. package/src/devcouncil/app/orchestrator.py +67 -67
  11. package/src/devcouncil/app/project_status.py +29 -0
  12. package/src/devcouncil/app/run_context.py +39 -39
  13. package/src/devcouncil/app/state_machine.py +108 -108
  14. package/src/devcouncil/artifacts/__init__.py +1 -1
  15. package/src/devcouncil/artifacts/coverage.py +96 -96
  16. package/src/devcouncil/artifacts/graph.py +163 -143
  17. package/src/devcouncil/artifacts/migrations.py +20 -20
  18. package/src/devcouncil/artifacts/schemas.py +23 -23
  19. package/src/devcouncil/artifacts/serializer.py +21 -21
  20. package/src/devcouncil/artifacts/validators.py +27 -27
  21. package/src/devcouncil/assets/__init__.py +1 -0
  22. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  23. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  24. package/src/devcouncil/cli/commands/agents.py +292 -0
  25. package/src/devcouncil/cli/commands/artifacts.py +54 -48
  26. package/src/devcouncil/cli/commands/ast.py +22 -0
  27. package/src/devcouncil/cli/commands/baseline.py +35 -32
  28. package/src/devcouncil/cli/commands/check.py +209 -0
  29. package/src/devcouncil/cli/commands/config.py +115 -54
  30. package/src/devcouncil/cli/commands/cost.py +57 -0
  31. package/src/devcouncil/cli/commands/dashboard.py +31 -0
  32. package/src/devcouncil/cli/commands/doctor.py +291 -47
  33. package/src/devcouncil/cli/commands/evidence.py +48 -0
  34. package/src/devcouncil/cli/commands/go.py +656 -0
  35. package/src/devcouncil/cli/commands/handoff.py +69 -0
  36. package/src/devcouncil/cli/commands/hook.py +209 -33
  37. package/src/devcouncil/cli/commands/init.py +204 -57
  38. package/src/devcouncil/cli/commands/integrate.py +1171 -76
  39. package/src/devcouncil/cli/commands/lsp.py +20 -0
  40. package/src/devcouncil/cli/commands/map.py +96 -22
  41. package/src/devcouncil/cli/commands/plan.py +422 -210
  42. package/src/devcouncil/cli/commands/prompt.py +48 -34
  43. package/src/devcouncil/cli/commands/repair.py +89 -69
  44. package/src/devcouncil/cli/commands/report.py +120 -54
  45. package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
  46. package/src/devcouncil/cli/commands/rollback.py +55 -54
  47. package/src/devcouncil/cli/commands/run.py +285 -220
  48. package/src/devcouncil/cli/commands/runs.py +223 -0
  49. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  50. package/src/devcouncil/cli/commands/semantic.py +47 -0
  51. package/src/devcouncil/cli/commands/setup.py +300 -20
  52. package/src/devcouncil/cli/commands/shell.py +73 -0
  53. package/src/devcouncil/cli/commands/show.py +76 -57
  54. package/src/devcouncil/cli/commands/skills.py +88 -0
  55. package/src/devcouncil/cli/commands/status.py +141 -105
  56. package/src/devcouncil/cli/commands/tasks.py +55 -41
  57. package/src/devcouncil/cli/commands/trace.py +49 -4
  58. package/src/devcouncil/cli/commands/verify.py +293 -128
  59. package/src/devcouncil/cli/commands/version.py +20 -20
  60. package/src/devcouncil/cli/commands/watch.py +574 -0
  61. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  62. package/src/devcouncil/cli/main.py +92 -25
  63. package/src/devcouncil/council/prompts/arbiter.md +19 -19
  64. package/src/devcouncil/council/prompts/critic_a.md +10 -10
  65. package/src/devcouncil/council/prompts/critic_b.md +10 -10
  66. package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
  67. package/src/devcouncil/council/prompts/planner_a.md +16 -16
  68. package/src/devcouncil/council/prompts/planner_b.md +16 -16
  69. package/src/devcouncil/council/prompts/rebuttal.md +10 -10
  70. package/src/devcouncil/council/prompts/spec_writer.md +12 -12
  71. package/src/devcouncil/domain/assumption.py +17 -17
  72. package/src/devcouncil/domain/critique.py +32 -32
  73. package/src/devcouncil/domain/evidence.py +47 -27
  74. package/src/devcouncil/domain/gap.py +52 -26
  75. package/src/devcouncil/domain/requirement.py +22 -22
  76. package/src/devcouncil/domain/task.py +55 -26
  77. package/src/devcouncil/execution/__init__.py +1 -1
  78. package/src/devcouncil/execution/checkpoints.py +246 -0
  79. package/src/devcouncil/execution/context_builder.py +54 -54
  80. package/src/devcouncil/execution/executor.py +15 -15
  81. package/src/devcouncil/execution/fs_watcher.py +180 -0
  82. package/src/devcouncil/execution/handoff.py +102 -0
  83. package/src/devcouncil/execution/hook_policy.py +186 -77
  84. package/src/devcouncil/execution/patch.py +77 -28
  85. package/src/devcouncil/execution/permissions.py +52 -59
  86. package/src/devcouncil/execution/policy_engine.py +343 -0
  87. package/src/devcouncil/execution/prompt_builder.py +650 -38
  88. package/src/devcouncil/execution/shell_session.py +225 -0
  89. package/src/devcouncil/execution/task_runner.py +68 -64
  90. package/src/devcouncil/executors/__init__.py +1 -1
  91. package/src/devcouncil/executors/agent_registry.py +575 -0
  92. package/src/devcouncil/executors/coding_cli.py +736 -0
  93. package/src/devcouncil/executors/mini_swe.py +63 -63
  94. package/src/devcouncil/executors/native/agent.py +186 -85
  95. package/src/devcouncil/executors/openhands.py +56 -56
  96. package/src/devcouncil/gating/__init__.py +1 -1
  97. package/src/devcouncil/gating/checks/clean_git.py +52 -45
  98. package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
  99. package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
  100. package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
  101. package/src/devcouncil/gating/policy.py +315 -167
  102. package/src/devcouncil/hardware.py +184 -0
  103. package/src/devcouncil/indexing/__init__.py +1 -1
  104. package/src/devcouncil/indexing/ast_matcher.py +168 -0
  105. package/src/devcouncil/indexing/graph_index.py +48 -48
  106. package/src/devcouncil/indexing/lsp.py +161 -0
  107. package/src/devcouncil/indexing/repo_mapper.py +1455 -204
  108. package/src/devcouncil/indexing/semantic_index.py +205 -0
  109. package/src/devcouncil/integrations/actions.py +146 -0
  110. package/src/devcouncil/integrations/check.py +423 -0
  111. package/src/devcouncil/integrations/github.py +35 -35
  112. package/src/devcouncil/integrations/github_intent.py +142 -0
  113. package/src/devcouncil/integrations/gitnexus.py +62 -27
  114. package/src/devcouncil/integrations/graphify.py +34 -34
  115. package/src/devcouncil/integrations/mcp/server.py +2072 -96
  116. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  117. package/src/devcouncil/integrations/pr_comments.py +62 -0
  118. package/src/devcouncil/live/__init__.py +2 -0
  119. package/src/devcouncil/live/cards.py +349 -0
  120. package/src/devcouncil/live/models.py +63 -0
  121. package/src/devcouncil/live/repair_prompt.py +83 -0
  122. package/src/devcouncil/live/reviewer.py +70 -0
  123. package/src/devcouncil/live/signals.py +135 -0
  124. package/src/devcouncil/live/summary.py +34 -0
  125. package/src/devcouncil/live/tasks.py +18 -0
  126. package/src/devcouncil/live/transcripts.py +141 -0
  127. package/src/devcouncil/llm/__init__.py +1 -1
  128. package/src/devcouncil/llm/cache.py +42 -38
  129. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  130. package/src/devcouncil/llm/provider.py +627 -125
  131. package/src/devcouncil/llm/router.py +303 -118
  132. package/src/devcouncil/optimization/__init__.py +1 -0
  133. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  134. package/src/devcouncil/planning/__init__.py +1 -1
  135. package/src/devcouncil/planning/arbiter_service.py +57 -57
  136. package/src/devcouncil/planning/correction_manifest.py +303 -0
  137. package/src/devcouncil/planning/critique_service.py +71 -66
  138. package/src/devcouncil/planning/plan_service.py +60 -46
  139. package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
  140. package/src/devcouncil/planning/repair_service.py +39 -39
  141. package/src/devcouncil/planning/spec_service.py +70 -44
  142. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  143. package/src/devcouncil/repo/gitignore.py +123 -0
  144. package/src/devcouncil/repo/sca.py +374 -0
  145. package/src/devcouncil/reporting/github_check.py +32 -32
  146. package/src/devcouncil/reporting/json_report.py +30 -17
  147. package/src/devcouncil/reporting/markdown_report.py +83 -46
  148. package/src/devcouncil/reporting/report_builder.py +14 -14
  149. package/src/devcouncil/skills/__init__.py +19 -0
  150. package/src/devcouncil/skills/library/README.md +46 -0
  151. package/src/devcouncil/skills/library/ai-training.md +50 -0
  152. package/src/devcouncil/skills/library/android.md +50 -0
  153. package/src/devcouncil/skills/library/backend.md +52 -0
  154. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  155. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  156. package/src/devcouncil/skills/library/desktop.md +46 -0
  157. package/src/devcouncil/skills/library/devops.md +48 -0
  158. package/src/devcouncil/skills/library/game-dev.md +46 -0
  159. package/src/devcouncil/skills/library/ios.md +48 -0
  160. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  161. package/src/devcouncil/skills/library/security.md +48 -0
  162. package/src/devcouncil/skills/library/systems.md +48 -0
  163. package/src/devcouncil/skills/library/web.md +47 -0
  164. package/src/devcouncil/skills/library/windows.md +47 -0
  165. package/src/devcouncil/skills/registry.py +330 -0
  166. package/src/devcouncil/storage/db.py +147 -66
  167. package/src/devcouncil/storage/models.py +204 -83
  168. package/src/devcouncil/storage/native.py +557 -0
  169. package/src/devcouncil/storage/repositories.py +388 -249
  170. package/src/devcouncil/telemetry/cost.py +140 -34
  171. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  172. package/src/devcouncil/telemetry/pricing.py +28 -0
  173. package/src/devcouncil/telemetry/traces.py +62 -7
  174. package/src/devcouncil/telemetry/tracker.py +52 -49
  175. package/src/devcouncil/ui/__init__.py +1 -0
  176. package/src/devcouncil/ui/dashboard.py +423 -0
  177. package/src/devcouncil/utils/__init__.py +1 -1
  178. package/src/devcouncil/utils/redaction.py +147 -141
  179. package/src/devcouncil/utils/subprocess_env.py +69 -0
  180. package/src/devcouncil/verification/__init__.py +1 -1
  181. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  182. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  183. package/src/devcouncil/verification/diff_coverage.py +353 -0
  184. package/src/devcouncil/verification/implementation_reviewer.py +55 -55
  185. package/src/devcouncil/verification/next_actions.py +189 -0
  186. package/src/devcouncil/verification/sandbox.py +178 -0
  187. package/src/devcouncil/verification/test_resolver.py +91 -0
  188. package/src/devcouncil/verification/verifier.py +1342 -307
  189. package/uv.lock +205 -64
  190. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,6 +1,6 @@
1
- from sqlmodel import Session, select, delete
2
- from typing import List, Optional, Any
3
- import json
1
+ from sqlmodel import Session, col, delete, select
2
+ from typing import List, Optional, Any
3
+ import json
4
4
  from devcouncil.storage.models import (
5
5
  RequirementModel,
6
6
  AssumptionModel,
@@ -14,39 +14,39 @@ 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
+
21
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
- )
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.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
+ 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
50
  self.session.merge(model)
51
51
  self.session.commit()
52
52
 
@@ -59,16 +59,16 @@ class AssumptionRepository:
59
59
  statement = select(AssumptionModel)
60
60
  models = self.session.exec(statement).all()
61
61
  return [
62
- Assumption(
63
- id=m.id,
64
- statement=m.statement,
65
- confidence=m.confidence,
66
- impact=m.impact,
67
- reversible=m.reversible,
68
- requires_user_confirmation=m.requires_user_confirmation,
69
- linked_requirement_ids=json.loads(m.linked_requirement_ids_json),
70
- status=m.status,
71
- )
62
+ Assumption.model_validate({
63
+ "id": m.id,
64
+ "statement": m.statement,
65
+ "confidence": m.confidence,
66
+ "impact": m.impact,
67
+ "reversible": m.reversible,
68
+ "requires_user_confirmation": m.requires_user_confirmation,
69
+ "linked_requirement_ids": json.loads(m.linked_requirement_ids_json),
70
+ "status": m.status,
71
+ })
72
72
  for m in models
73
73
  ]
74
74
 
@@ -85,161 +85,187 @@ class AssumptionRepository:
85
85
  )
86
86
  self.session.merge(model)
87
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
-
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.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
+
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.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
+ 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.model_validate({
159
+ "id": m.id,
160
+ "severity": m.severity,
161
+ "gap_type": m.gap_type,
162
+ "requirement_id": m.requirement_id,
163
+ "task_id": m.task_id,
164
+ "description": m.description,
165
+ "evidence": json.loads(m.evidence_json),
166
+ "recommended_fix": m.recommended_fix,
167
+ "blocking": m.blocking,
168
+ "file": m.file,
169
+ "line": m.line,
170
+ "suggested_command": m.suggested_command,
171
+ "acceptance_criterion_id": m.acceptance_criterion_id,
172
+ }))
173
+ return results
174
+
175
+ def save(self, gap: Gap):
176
+ model = GapModel(
177
+ id=gap.id,
178
+ severity=gap.severity,
179
+ gap_type=gap.gap_type,
180
+ requirement_id=gap.requirement_id,
181
+ task_id=gap.task_id,
182
+ description=gap.description,
183
+ evidence_json=json.dumps(gap.evidence),
184
+ recommended_fix=gap.recommended_fix,
185
+ blocking=gap.blocking,
186
+ file=gap.file,
187
+ line=gap.line,
188
+ suggested_command=gap.suggested_command,
189
+ acceptance_criterion_id=gap.acceptance_criterion_id,
190
+ )
191
+ self.session.merge(model)
192
+ self.session.commit()
193
+
186
194
  def delete_for_task(self, task_id: str):
187
- self.session.exec(delete(GapModel).where(GapModel.task_id == task_id))
195
+ self.session.exec(delete(GapModel).where(col(GapModel.task_id) == task_id))
188
196
  self.session.commit()
189
197
 
190
198
  def delete_plan_gaps(self):
191
- self.session.exec(delete(GapModel).where(GapModel.id.like("GAP-PLAN-%")))
199
+ self.session.exec(delete(GapModel).where(col(GapModel.id).like("GAP-PLAN-%")))
192
200
  self.session.commit()
193
-
201
+
194
202
  class EvidenceRepository:
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
-
203
+ def __init__(self, session: Session):
204
+ self.session = session
205
+
206
+ def save_command_result(self, task_id: str, result: CommandResult):
207
+ model = EvidenceModel(
208
+ type="command",
209
+ task_id=task_id,
210
+ data_json=result.model_dump_json()
211
+ )
212
+ self.session.add(model)
213
+ self.session.commit()
214
+
215
+ def save_diff_evidence(self, ev: DiffEvidence):
216
+ model = EvidenceModel(
217
+ type="diff",
218
+ task_id=ev.task_id,
219
+ data_json=ev.model_dump_json()
220
+ )
221
+ self.session.add(model)
222
+ self.session.commit()
223
+
224
+ def save_test_evidence(self, ev: TestEvidence, task_id: Optional[str] = None):
225
+ model = EvidenceModel(
226
+ type="test",
227
+ task_id=task_id,
228
+ requirement_id=ev.requirement_id,
229
+ acceptance_criterion_id=ev.acceptance_criterion_id,
230
+ data_json=ev.model_dump_json()
231
+ )
232
+ self.session.add(model)
233
+ self.session.commit()
234
+
235
+ def save_diff_coverage_evidence(self, ev: DiffCoverageEvidence):
236
+ model = EvidenceModel(
237
+ type="diff_coverage",
238
+ task_id=ev.task_id,
239
+ data_json=ev.model_dump_json()
240
+ )
241
+ self.session.add(model)
242
+ self.session.commit()
243
+
244
+ def get_command_results_for_task(self, task_id: str) -> List[CommandResult]:
245
+ statement = select(EvidenceModel).where(EvidenceModel.task_id == task_id).where(
246
+ EvidenceModel.type == "command"
247
+ )
248
+ models = self.session.exec(statement).all()
249
+ return [CommandResult.model_validate(json.loads(m.data_json)) for m in models]
250
+
251
+ def get_all(self) -> List[Any]:
252
+ statement = select(EvidenceModel)
253
+ models = self.session.exec(statement).all()
254
+ results: List[Any] = []
255
+ for m in models:
256
+ data = json.loads(m.data_json)
257
+ if m.type == "command":
258
+ results.append(CommandResult.model_validate(data))
259
+ elif m.type == "diff":
260
+ results.append(DiffEvidence.model_validate(data))
261
+ elif m.type == "diff_coverage":
262
+ results.append(DiffCoverageEvidence.model_validate(data))
263
+ elif m.type == "test":
264
+ results.append(TestEvidence.model_validate(data))
265
+ return results
266
+
241
267
  def delete_for_task(self, task_id: str):
242
- self.session.exec(delete(EvidenceModel).where(EvidenceModel.task_id == task_id))
268
+ self.session.exec(delete(EvidenceModel).where(col(EvidenceModel.task_id) == task_id))
243
269
  self.session.commit()
244
270
 
245
271
 
@@ -251,19 +277,19 @@ class CritiqueFindingRepository:
251
277
  statement = select(CritiqueFindingModel)
252
278
  models = self.session.exec(statement).all()
253
279
  return [
254
- CritiqueFinding(
255
- id=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
- )
280
+ CritiqueFinding.model_validate({
281
+ "id": m.id,
282
+ "source_agent": m.source_agent,
283
+ "target_plan_id": m.target_plan_id,
284
+ "severity": m.severity,
285
+ "finding_type": m.finding_type,
286
+ "claim": m.claim,
287
+ "linked_requirement_id": m.linked_requirement_id,
288
+ "suggested_requirement": m.suggested_requirement,
289
+ "suggested_task": m.suggested_task,
290
+ "falsifiable_check": m.falsifiable_check,
291
+ "status": m.status,
292
+ })
267
293
  for m in models
268
294
  ]
269
295
 
@@ -283,19 +309,96 @@ class CritiqueFindingRepository:
283
309
  )
284
310
  self.session.merge(model)
285
311
  self.session.commit()
286
-
287
- class ArtifactGraphRepository:
288
- def __init__(self, session: Session):
289
- self.session = session
312
+
313
+
314
+ class PlanningStateRepository:
315
+ """Manage the persisted active plan graph."""
316
+
317
+ def __init__(self, session: Session):
318
+ self.session = session
319
+
320
+ def clear_active_plan(self):
321
+ for model in (
322
+ EvidenceModel,
323
+ GapModel,
324
+ CritiqueFindingModel,
325
+ TaskModel,
326
+ AssumptionModel,
327
+ RequirementModel,
328
+ ):
329
+ self.session.exec(delete(model))
330
+
331
+ def replace_active_plan(
332
+ self,
333
+ requirements: list[Requirement],
334
+ assumptions: list[Assumption],
335
+ tasks: list[Task],
336
+ findings: list[CritiqueFinding],
337
+ ):
338
+ self.clear_active_plan()
339
+ for req in requirements:
340
+ self.session.merge(RequirementModel(
341
+ id=req.id,
342
+ title=req.title,
343
+ description=req.description,
344
+ priority=req.priority,
345
+ source=req.source,
346
+ acceptance_criteria_json=json.dumps([ac.model_dump() for ac in req.acceptance_criteria]),
347
+ ))
348
+
349
+ for assumption in assumptions:
350
+ self.session.merge(AssumptionModel(
351
+ id=assumption.id,
352
+ statement=assumption.statement,
353
+ confidence=assumption.confidence,
354
+ impact=assumption.impact,
355
+ reversible=assumption.reversible,
356
+ requires_user_confirmation=assumption.requires_user_confirmation,
357
+ linked_requirement_ids_json=json.dumps(assumption.linked_requirement_ids),
358
+ status=assumption.status,
359
+ ))
360
+
361
+ for task in tasks:
362
+ self.session.merge(TaskModel(
363
+ id=task.id,
364
+ title=task.title,
365
+ description=task.description,
366
+ requirement_ids_json=json.dumps(task.requirement_ids),
367
+ acceptance_criterion_ids_json=json.dumps(task.acceptance_criterion_ids),
368
+ planned_files_json=json.dumps([pf.model_dump() for pf in task.planned_files]),
369
+ expected_tests_json=json.dumps(task.expected_tests),
370
+ allowed_commands_json=json.dumps(task.allowed_commands),
371
+ forbidden_changes_json=json.dumps(task.forbidden_changes),
372
+ status=task.status,
373
+ ))
374
+
375
+ for finding in findings:
376
+ self.session.merge(CritiqueFindingModel(
377
+ id=finding.id,
378
+ source_agent=finding.source_agent,
379
+ target_plan_id=finding.target_plan_id,
380
+ severity=finding.severity,
381
+ finding_type=finding.finding_type,
382
+ claim=finding.claim,
383
+ linked_requirement_id=finding.linked_requirement_id,
384
+ suggested_requirement=finding.suggested_requirement,
385
+ suggested_task=finding.suggested_task,
386
+ falsifiable_check=finding.falsifiable_check,
387
+ status=finding.status,
388
+ ))
389
+
390
+ class ArtifactGraphRepository:
391
+ def __init__(self, session: Session):
392
+ self.session = session
290
393
  self.req_repo = RequirementRepository(session)
291
394
  self.assumption_repo = AssumptionRepository(session)
292
395
  self.task_repo = TaskRepository(session)
293
396
  self.gap_repo = GapRepository(session)
294
397
  self.evidence_repo = EvidenceRepository(session)
295
398
  self.finding_repo = CritiqueFindingRepository(session)
296
-
297
- def load_graph(self) -> ArtifactGraph:
298
- graph = ArtifactGraph()
399
+
400
+ def load_graph(self) -> ArtifactGraph:
401
+ graph = ArtifactGraph()
299
402
  for req in self.req_repo.get_all():
300
403
  graph.add_requirement(req)
301
404
  for assumption in self.assumption_repo.get_all():
@@ -306,41 +409,77 @@ class ArtifactGraphRepository:
306
409
  graph.add_finding(finding)
307
410
  for gap in self.gap_repo.get_all():
308
411
  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)
412
+ for ev in self.evidence_repo.get_all():
413
+ if isinstance(ev, CommandResult):
414
+ graph.add_command_result(ev)
415
+ elif isinstance(ev, DiffCoverageEvidence):
416
+ # Must precede DiffEvidence: distinct type, and the diff↔coverage proof
417
+ # was silently dropped on reload before this branch existed.
418
+ graph.add_diff_coverage_evidence(ev)
419
+ elif isinstance(ev, DiffEvidence):
420
+ graph.add_diff_evidence(ev)
421
+ elif isinstance(ev, TestEvidence):
422
+ graph.add_test_evidence(ev)
423
+ return graph
424
+
425
+
426
+ class StateRepository:
427
+ def __init__(self, session: Session):
428
+ self.session = session
429
+
430
+ def get_state(self) -> Optional[ProjectStateModel]:
431
+ statement = select(ProjectStateModel).where(ProjectStateModel.id == "singleton")
432
+ return self.session.exec(statement).first()
433
+
434
+ def save_state(self, state: ProjectStateModel | str, history: Optional[List[str]] = None):
435
+ if isinstance(state, ProjectStateModel):
436
+ state.id = "singleton"
437
+ else:
438
+ state = ProjectStateModel(
439
+ id="singleton",
440
+ current_phase=state,
441
+ history_json=json.dumps(history or []),
442
+ )
443
+ self.session.merge(state)
444
+ self.session.commit()
445
+
446
+ def record_phase(self, phase: str):
447
+ current = self.get_state()
448
+ history = []
449
+ prev = None
450
+ if current:
451
+ history = json.loads(current.history_json)
452
+ prev = current.current_phase
453
+ # Keep the persisted history a LEGAL transition sequence. A repair re-run records
454
+ # TASK_EXECUTING straight after a terminal TASK_BLOCKED/TASK_VERIFIED, which is not
455
+ # a direct transition; bridge it through the intermediate phase (TASK_READY) so the
456
+ # state machine's invariant holds on reload. Fail-soft: an unbridgeable jump is
457
+ # still recorded verbatim rather than lost.
458
+ for step in self._bridge_phases(prev, phase):
459
+ if not history or history[-1] != step:
460
+ history.append(step)
461
+ self.save_state(phase, history)
462
+
463
+ @staticmethod
464
+ def _bridge_phases(prev: Optional[str], target: str) -> List[str]:
465
+ """Return the phases to append so prev -> ... -> target is a legal path.
466
+
467
+ Inserts a single intermediate phase when prev cannot transition directly to
468
+ target but a one-hop bridge exists (the repair loop's
469
+ TASK_BLOCKED/TASK_VERIFIED -> TASK_READY -> TASK_EXECUTING). Returns ``[target]``
470
+ when prev is unknown, equal, already-legal, or no one-hop bridge exists."""
471
+ if prev is None or prev == target:
472
+ return [target]
473
+ try:
474
+ from devcouncil.app.state_machine import TRANSITIONS, ProjectPhase
475
+
476
+ prev_phase = ProjectPhase(prev)
477
+ target_phase = ProjectPhase(target)
478
+ except (ImportError, ValueError):
479
+ return [target]
480
+ if target_phase in TRANSITIONS.get(prev_phase, set()):
481
+ return [target]
482
+ for mid in TRANSITIONS.get(prev_phase, set()):
483
+ if target_phase in TRANSITIONS.get(mid, set()):
484
+ return [mid.value, target]
485
+ return [target]