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
@@ -0,0 +1,167 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from devcouncil.llm.router import ModelRouter
6
+
7
+ # Cap how much skill text we feed the enhancer so a repo matching many skills
8
+ # can't blow up the planning prompt. Domain skills are ~50 lines each.
9
+ _MAX_SKILLS_FOR_INTAKE = 4
10
+ _MAX_INTAKE_CHARS = 8000
11
+
12
+
13
+ class PromptEnhancement(BaseModel):
14
+ original_goal: str
15
+ enhanced_goal: str
16
+ codebase_context: list[str] = Field(default_factory=list)
17
+ debate_focus: list[str] = Field(default_factory=list)
18
+ constraints: list[str] = Field(default_factory=list)
19
+ # Senior-level domain intake folded in from the skills library (android, ios,
20
+ # web, ...). ``applied_skills`` are the matched skill names; ``skills_brief`` is
21
+ # the compact title+description block the council debates with. Both are set
22
+ # deterministically after the model call — the LLM does not populate them.
23
+ applied_skills: list[str] = Field(default_factory=list)
24
+ skills_brief: str = ""
25
+
26
+ def normalized(self, original_goal: str) -> "PromptEnhancement":
27
+ enhanced_goal = self.enhanced_goal.strip() or original_goal
28
+ return self.model_copy(
29
+ update={
30
+ "original_goal": original_goal,
31
+ "enhanced_goal": enhanced_goal,
32
+ "codebase_context": _clean_items(self.codebase_context),
33
+ "debate_focus": _clean_items(self.debate_focus),
34
+ "constraints": _clean_items(self.constraints),
35
+ }
36
+ )
37
+
38
+ def debate_prompt(self) -> str:
39
+ sections = [
40
+ "# Enhanced Planning Prompt",
41
+ "",
42
+ "## Original user goal",
43
+ self.original_goal,
44
+ "",
45
+ "## Codebase-specific goal",
46
+ self.enhanced_goal,
47
+ ]
48
+ if self.codebase_context:
49
+ sections.extend(["", "## Relevant codebase context"])
50
+ sections.extend(f"- {item}" for item in self.codebase_context)
51
+ if self.constraints:
52
+ sections.extend(["", "## Constraints to preserve"])
53
+ sections.extend(f"- {item}" for item in self.constraints)
54
+ if self.debate_focus:
55
+ sections.extend(["", "## Debate focus"])
56
+ sections.extend(f"- {item}" for item in self.debate_focus)
57
+ if self.skills_brief:
58
+ sections.extend([
59
+ "",
60
+ "## Domain engineering intake (apply current senior-level practices)",
61
+ "Plan to the *current* state of these domains — recommended libraries, "
62
+ "deprecations to avoid, and the right build/test CLI commands. The coding "
63
+ "agent receives the full skill text; the plan must already assume it.",
64
+ self.skills_brief,
65
+ ])
66
+ return "\n".join(sections)
67
+
68
+
69
+ class PromptEnhancerService:
70
+ def __init__(self, router: ModelRouter):
71
+ self.router = router
72
+
73
+ async def enhance_prompt(
74
+ self,
75
+ goal: str,
76
+ repo_map_json: str,
77
+ graph_context_json: str | None = None,
78
+ project_root: Path | None = None,
79
+ ) -> PromptEnhancement:
80
+ skills = _select_skills(goal, project_root)
81
+ skills_intake = _full_intake(skills)
82
+ skills_brief = _compact_brief(skills)
83
+
84
+ prompt = f"""
85
+ Original user goal:
86
+ {goal}
87
+
88
+ Repository map:
89
+ {repo_map_json}
90
+
91
+ Code review graph context:
92
+ {graph_context_json or "{}"}
93
+
94
+ Applicable engineering skills (senior-level domain intake for this codebase/goal):
95
+ {skills_intake or "(no domain skills matched; rely on general engineering judgment)"}
96
+
97
+ You are DevCouncil's codebase-specific prompt enhancer.
98
+ Rewrite the user goal into a better planning prompt before it is sent to the council debate.
99
+
100
+ Requirements:
101
+ - Preserve the user's intent exactly; do not add unrelated features.
102
+ - Make the goal specific to the mapped repository architecture, languages, tests, and likely ownership boundaries.
103
+ - Fold the relevant skill intake into the goal and constraints like a senior engineer who
104
+ just briefed themselves: name the *current* recommended libraries/APIs, the deprecated
105
+ ones to avoid, the platform/SDK/toolchain versions to target, and the exact build/test
106
+ CLI commands that will prove the change. Only include skill points relevant to THIS goal.
107
+ - Identify constraints the planners and critics must preserve.
108
+ - Identify debate focus areas that should force useful disagreement between pragmatic and production-readiness plans.
109
+ - Keep the enhanced_goal concise enough to be used as the goal for spec, planning, critique, and arbitration.
110
+ """
111
+ enhancement = await self.router.complete_structured(
112
+ role="prompt_enhancer",
113
+ messages=[{"role": "user", "content": prompt}],
114
+ schema=PromptEnhancement,
115
+ # If enhancement fails on a weak model, fall back to the raw goal —
116
+ # planning proceeds with the user's original intent unchanged.
117
+ fallback=PromptEnhancement(original_goal=goal, enhanced_goal=goal),
118
+ )
119
+ # Skill provenance is deterministic, not model-decided: stamp it after the call
120
+ # so the artifact/report shows exactly which skills shaped this plan.
121
+ return enhancement.normalized(goal).model_copy(
122
+ update={
123
+ "applied_skills": [skill.name for skill in skills],
124
+ "skills_brief": skills_brief,
125
+ }
126
+ )
127
+
128
+
129
+ def _select_skills(goal: str, project_root: Path | None):
130
+ """Codebase-aware skill selection; never raises (skills are best-effort)."""
131
+ try:
132
+ from devcouncil.skills.registry import select_skills
133
+
134
+ return select_skills(goal=goal, project_root=project_root)
135
+ except Exception:
136
+ return []
137
+
138
+
139
+ def _full_intake(skills: list) -> str:
140
+ """Full skill bodies (capped) for the one-shot enhancer call."""
141
+ if not skills:
142
+ return ""
143
+ blocks: list[str] = []
144
+ total = 0
145
+ for skill in skills[:_MAX_SKILLS_FOR_INTAKE]:
146
+ body = (getattr(skill, "body", "") or "").strip()
147
+ if not body:
148
+ continue
149
+ block = f"### Skill: {skill.name}\n{body}"
150
+ total += len(block)
151
+ if total > _MAX_INTAKE_CHARS:
152
+ break
153
+ blocks.append(block)
154
+ return "\n\n".join(blocks).strip()
155
+
156
+
157
+ def _compact_brief(skills: list) -> str:
158
+ """One line per skill (name + description) for the council debate prompt."""
159
+ lines = []
160
+ for skill in skills:
161
+ description = (getattr(skill, "description", "") or "").strip()
162
+ lines.append(f"- **{skill.name}** — {description}" if description else f"- **{skill.name}**")
163
+ return "\n".join(lines).strip()
164
+
165
+
166
+ def _clean_items(items: list[str]) -> list[str]:
167
+ return [item.strip() for item in items if item.strip()]
@@ -1,39 +1,39 @@
1
- from typing import List
2
- import json
3
- from pydantic import BaseModel
4
- from devcouncil.domain.gap import Gap
5
- from devcouncil.domain.task import Task
6
- from devcouncil.llm.router import ModelRouter
7
-
8
- class RepairOutput(BaseModel):
9
- suggested_tasks: List[Task]
10
-
11
- class RepairService:
12
- """Uses LLM to infer focused repair tasks from blocking gaps."""
13
-
14
- def __init__(self, router: ModelRouter):
15
- self.router = router
16
-
17
- async def generate_repair_plan(self, gaps: List[Gap], project_context: str) -> RepairOutput:
18
- prompt = f"""
19
- The following blocking gaps were detected during verification.
20
- Gaps:
21
- {json.dumps([g.model_dump() for g in gaps], indent=2)}
22
-
23
- Project Context:
24
- {project_context}
25
-
26
- Your task is to generate focused implementation tasks to fix these gaps.
27
- - Each task must have a clear description and recommended fix.
28
- - Specify 'planned_files' that need modification (infer from gap evidence).
29
- - Link each task to the relevant 'requirement_id' mentioned in the gap.
30
-
31
- Return a JSON object with 'suggested_tasks'.
32
- """
33
- messages = [{"role": "user", "content": prompt}]
34
-
35
- return await self.router.complete_structured(
36
- role="planner_a", # Pragmatic tech lead is best suited for repair task generation
37
- messages=messages,
38
- schema=RepairOutput
39
- )
1
+ from typing import List
2
+ import json
3
+ from pydantic import BaseModel
4
+ from devcouncil.domain.gap import Gap
5
+ from devcouncil.domain.task import Task
6
+ from devcouncil.llm.router import ModelRouter
7
+
8
+ class RepairOutput(BaseModel):
9
+ suggested_tasks: List[Task]
10
+
11
+ class RepairService:
12
+ """Uses LLM to infer focused repair tasks from blocking gaps."""
13
+
14
+ def __init__(self, router: ModelRouter):
15
+ self.router = router
16
+
17
+ async def generate_repair_plan(self, gaps: List[Gap], project_context: str) -> RepairOutput:
18
+ prompt = f"""
19
+ The following blocking gaps were detected during verification.
20
+ Gaps:
21
+ {json.dumps([g.model_dump() for g in gaps], indent=2)}
22
+
23
+ Project Context:
24
+ {project_context}
25
+
26
+ Your task is to generate focused implementation tasks to fix these gaps.
27
+ - Each task must have a clear description and recommended fix.
28
+ - Specify 'planned_files' that need modification (infer from gap evidence).
29
+ - Link each task to the relevant 'requirement_id' mentioned in the gap.
30
+
31
+ Return a JSON object with 'suggested_tasks'.
32
+ """
33
+ messages = [{"role": "user", "content": prompt}]
34
+
35
+ return await self.router.complete_structured(
36
+ role="planner_a", # Pragmatic tech lead is best suited for repair task generation
37
+ messages=messages,
38
+ schema=RepairOutput
39
+ )
@@ -1,44 +1,70 @@
1
- from typing import List
2
- from pydantic import BaseModel
3
- from devcouncil.domain.requirement import Requirement
4
- from devcouncil.domain.assumption import Assumption
5
- from devcouncil.llm.router import ModelRouter
6
-
7
- class BlockingQuestion(BaseModel):
8
- id: str
9
- question: str
10
- reason: str
11
-
12
- class SpecOutput(BaseModel):
13
- requirements: List[Requirement]
14
- assumptions: List[Assumption]
15
- blocking_questions: List[BlockingQuestion]
16
-
17
- class SpecService:
18
- def __init__(self, router: ModelRouter):
19
- self.router = router
20
-
21
- async def generate_spec(self, goal: str, repo_map_json: str) -> SpecOutput:
22
- prompt = f"""
23
- Goal: {goal}
24
-
25
- Repository Map:
26
- {repo_map_json}
27
-
28
- Your task is to draft the initial software specification for this goal.
29
- 1. Identify functional and non-functional requirements.
30
- 2. Extract any assumptions you are making about the codebase or architecture.
31
- 3. List any blocking questions that the user must answer before implementation can proceed.
32
-
33
- Each requirement MUST have clear acceptance criteria with verification methods.
34
- Each assumption MUST have a confidence and impact level.
35
- """
36
- messages = [
37
- {"role": "user", "content": prompt}
38
- ]
39
-
40
- return await self.router.complete_structured(
41
- role="spec_writer",
42
- messages=messages,
43
- schema=SpecOutput
44
- )
1
+ from typing import List
2
+ from pydantic import BaseModel
3
+ from devcouncil.domain.requirement import Requirement
4
+ from devcouncil.domain.assumption import Assumption
5
+ from devcouncil.llm.router import ModelRouter
6
+
7
+ class BlockingQuestion(BaseModel):
8
+ id: str
9
+ question: str
10
+ reason: str
11
+
12
+ class SpecOutput(BaseModel):
13
+ requirements: List[Requirement]
14
+ assumptions: List[Assumption]
15
+ blocking_questions: List[BlockingQuestion]
16
+
17
+ class SpecService:
18
+ def __init__(self, router: ModelRouter):
19
+ self.router = router
20
+
21
+ async def generate_spec(self, goal: str, repo_map_json: str) -> SpecOutput:
22
+ prompt = f"""
23
+ Goal: {goal}
24
+
25
+ Repository Map:
26
+ {repo_map_json}
27
+
28
+ Your task is to draft the initial software specification for this goal.
29
+ 1. Identify functional and non-functional requirements.
30
+ 2. Extract any assumptions you are making about the codebase or architecture.
31
+ 3. List any blocking questions that the user must answer before implementation can proceed.
32
+
33
+ Each requirement MUST have clear, testable acceptance criteria with verification methods.
34
+ Be RIGOROUS about edge cases — a terse goal hides most of the real requirements.
35
+ For every behavior, add explicit acceptance criteria covering, where applicable:
36
+ - the normal/happy path with concrete example inputs and expected outputs;
37
+ - boundary and degenerate inputs (empty, single element, zero, negative, very large,
38
+ duplicate, already-sorted vs. unsorted, min/max);
39
+ - invalid or malformed inputs and the EXACT expected error behavior (e.g. raises
40
+ ValueError/TypeError) rather than silent or undefined behavior;
41
+ - non-mutation / no-unexpected-side-effects on inputs when the behavior is a pure
42
+ transformation;
43
+ - correct result TYPE (e.g. float vs int) when it matters.
44
+ Prefer several small, individually-verifiable acceptance criteria over one vague one.
45
+
46
+ Acceptance criteria MUST assert observable BEHAVIOR — return values, raised exceptions,
47
+ output, or side effects on supplied data — not repository state or tooling. DevCouncil's
48
+ own gates enforce file scope, clean diffs, and planned-file limits, so do NOT write
49
+ criteria about `git status`/`--porcelain` output, the exact set of changed/created files,
50
+ `git show HEAD` byte/append-only contents, commit shape, or whether flake8/mypy/ruff/
51
+ eslint/tsc/npm pass. Never require a tool the repo is not already configured for. Use the
52
+ `static_check` verification method ONLY for behavior expressible as a runnable assertion
53
+ (an importable function's result or raised exception), never to mean "a linter runs clean"
54
+ or "these files exist". If a criterion genuinely cannot be proven by running code
55
+ (architecture choices, repo scope, "works without extra configuration", subjective
56
+ quality), give it verification_method "manual" — it will be surfaced for human review
57
+ rather than block the automated gate. Prefer rewriting such a criterion as a concrete
58
+ behavioral one whenever possible.
59
+
60
+ Each assumption MUST have a confidence and impact level.
61
+ """
62
+ messages = [
63
+ {"role": "user", "content": prompt}
64
+ ]
65
+
66
+ return await self.router.complete_structured(
67
+ role="spec_writer",
68
+ messages=messages,
69
+ schema=SpecOutput
70
+ )
@@ -0,0 +1,157 @@
1
+ """Scaffold a starter GitHub Actions workflow for a target repository.
2
+
3
+ DevCouncil already knows a project's test/lint/typecheck commands (config.yaml), so
4
+ it can emit a sensible CI starter that runs them. The workflow is a *template* the
5
+ user can adjust; scaffolding never overwrites an existing workflow unless forced.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from devcouncil.app.config import load_config
13
+
14
+ WORKFLOW_RELPATH = Path(".github") / "workflows" / "devcouncil.yml"
15
+
16
+ _PYTHON_TOOLS = {
17
+ "pytest", "flake8", "ruff", "mypy", "tox", "python", "python3", "uv",
18
+ "poetry", "black", "isort", "pyright",
19
+ }
20
+ _NODE_TOOLS = {
21
+ "npm", "npx", "pnpm", "yarn", "bun", "eslint", "tsc", "jest", "vitest", "node",
22
+ }
23
+ _PYTHON_MARKERS = ("pyproject.toml", "requirements.txt", "setup.py", "setup.cfg", "Pipfile")
24
+
25
+
26
+ def detect_stacks(project_root: Path) -> set[str]:
27
+ """Best-effort detection of the language stacks present in the repo."""
28
+ stacks: set[str] = set()
29
+ if any((project_root / marker).exists() for marker in _PYTHON_MARKERS):
30
+ stacks.add("python")
31
+ if (project_root / "package.json").exists():
32
+ stacks.add("node")
33
+ return stacks
34
+
35
+
36
+ def _command_stack(command: str) -> str | None:
37
+ tool = command.split()[0] if command.strip() else ""
38
+ if tool in _PYTHON_TOOLS:
39
+ return "python"
40
+ if tool in _NODE_TOOLS:
41
+ return "node"
42
+ return None
43
+
44
+
45
+ def _applicable_commands(commands: list[str], stacks: set[str]) -> list[str]:
46
+ """Keep commands whose tool matches a detected stack; if none detected, keep all."""
47
+ if not stacks:
48
+ return list(commands)
49
+ kept = []
50
+ for command in commands:
51
+ stack = _command_stack(command)
52
+ if stack is None or stack in stacks:
53
+ kept.append(command)
54
+ return kept
55
+
56
+
57
+ # Optional dependency-audit step per stack. Emitted only when the matching stack is
58
+ # detected, so a Python-only repo never gets an npm audit (and vice versa). These are
59
+ # non-blocking (continue-on-error) starters the user can tighten.
60
+ _AUDIT_STEPS: dict[str, list[str]] = {
61
+ "python": [
62
+ " - name: Dependency audit (pip-audit)",
63
+ " continue-on-error: true",
64
+ " run: pip-audit",
65
+ ],
66
+ "node": [
67
+ " - name: Dependency audit (npm audit)",
68
+ " continue-on-error: true",
69
+ " run: npm audit --audit-level=high",
70
+ ],
71
+ }
72
+
73
+
74
+ def _add_audit_steps(steps: list[str], stacks: set[str]) -> None:
75
+ """Append an optional SCA audit step for each detected stack (only)."""
76
+ for stack in sorted(stacks):
77
+ steps.extend(_AUDIT_STEPS.get(stack, []))
78
+
79
+
80
+ def _python_version(project_root: Path) -> str:
81
+ version_file = project_root / ".python-version"
82
+ if version_file.exists():
83
+ first = version_file.read_text(encoding="utf-8").strip().splitlines()
84
+ if first and first[0].strip():
85
+ return first[0].strip()
86
+ return "3.12"
87
+
88
+
89
+ def render_workflow(project_root: Path, default_branch: str = "main") -> str:
90
+ """Render the workflow YAML text deterministically from config + detected stacks."""
91
+ config = load_config(project_root)
92
+ stacks = detect_stacks(project_root)
93
+ commands = config.commands
94
+
95
+ steps: list[str] = [
96
+ " - name: Checkout",
97
+ " uses: actions/checkout@v4",
98
+ ]
99
+ if "python" in stacks:
100
+ steps += [
101
+ " - name: Set up Python",
102
+ " uses: actions/setup-python@v5",
103
+ " with:",
104
+ f' python-version: "{_python_version(project_root)}"',
105
+ ]
106
+ if "node" in stacks:
107
+ steps += [
108
+ " - name: Set up Node",
109
+ " uses: actions/setup-node@v4",
110
+ " with:",
111
+ ' node-version: "20"',
112
+ ]
113
+
114
+ def add_command_steps(label: str, raw_commands: list[str]) -> None:
115
+ for command in _applicable_commands(raw_commands, stacks):
116
+ steps.append(f" - name: {label} ({command.split()[0]})")
117
+ steps.append(f" run: {command}")
118
+
119
+ add_command_steps("Lint", commands.lint)
120
+ add_command_steps("Typecheck", commands.typecheck)
121
+ add_command_steps("Test", commands.test)
122
+ _add_audit_steps(steps, stacks)
123
+
124
+ body = "\n".join(steps)
125
+ return (
126
+ "# Starter CI workflow generated by DevCouncil from .devcouncil/config.yaml.\n"
127
+ "# Adjust the setup steps, dependency install, and commands for your stack.\n"
128
+ "name: DevCouncil CI\n"
129
+ "\n"
130
+ "on:\n"
131
+ " push:\n"
132
+ f' branches: ["{default_branch}"]\n'
133
+ " pull_request:\n"
134
+ f' branches: ["{default_branch}"]\n'
135
+ "\n"
136
+ "jobs:\n"
137
+ " checks:\n"
138
+ " runs-on: ubuntu-latest\n"
139
+ " steps:\n"
140
+ f"{body}\n"
141
+ )
142
+
143
+
144
+ def scaffold_ci(project_root: Path, force: bool = False) -> Path | None:
145
+ """Write the starter workflow. Returns the path, or None if one already exists.
146
+
147
+ Does not overwrite an existing ``.github/workflows/devcouncil.yml`` unless
148
+ ``force`` is set, so re-running is safe and user edits are preserved.
149
+ """
150
+ project_root = project_root.resolve()
151
+ target = project_root / WORKFLOW_RELPATH
152
+ if target.exists() and not force:
153
+ return None
154
+ default_branch = load_config(project_root).project.default_branch or "main"
155
+ target.parent.mkdir(parents=True, exist_ok=True)
156
+ target.write_text(render_workflow(project_root, default_branch), encoding="utf-8")
157
+ return target
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ GITIGNORE_SECTIONS: tuple[tuple[str, tuple[str, ...]], ...] = (
7
+ (
8
+ "DevCouncil local state",
9
+ (
10
+ ".devcouncil/*",
11
+ "!.devcouncil/",
12
+ "!.devcouncil/config.yaml",
13
+ "!.devcouncil/graphify.yaml",
14
+ ),
15
+ ),
16
+ (
17
+ "Local AI coding agents",
18
+ (
19
+ ".agents/",
20
+ ".codex/",
21
+ ".aider*",
22
+ ".gemini/",
23
+ ".claude*",
24
+ ".cursor/",
25
+ ".openhands/",
26
+ ".opencode/",
27
+ ".conductor/",
28
+ ".conducor/",
29
+ ".antigravity/",
30
+ ".warp/",
31
+ ".gitnexus",
32
+ ),
33
+ ),
34
+ (
35
+ "Generated workspace guides (regenerated by 'dev map')",
36
+ (
37
+ "AGENTS.md",
38
+ "CLAUDE.md",
39
+ ),
40
+ ),
41
+ (
42
+ "Secrets and local databases",
43
+ (
44
+ "*.sqlite",
45
+ "*.sqlite-wal",
46
+ "*.sqlite-shm",
47
+ "*.db",
48
+ ),
49
+ ),
50
+ (
51
+ "Temporary, log, and dump artifacts",
52
+ (
53
+ "logs/",
54
+ "log/",
55
+ "tmp/",
56
+ "temp/",
57
+ ".tmp/",
58
+ ".temp/",
59
+ "scratch/",
60
+ "dumps/",
61
+ "dump/",
62
+ "*.tmp",
63
+ "*.temp",
64
+ "*.log",
65
+ "*.dmp",
66
+ "*.dump",
67
+ "*.bak",
68
+ "*.swp",
69
+ "*_results.txt",
70
+ "*_log.txt",
71
+ "*_output.txt",
72
+ ),
73
+ ),
74
+ (
75
+ "Environment, dependency, and cache directories",
76
+ (
77
+ "__pycache__/",
78
+ "*.py[cod]",
79
+ ".venv/",
80
+ "venv/",
81
+ "node_modules/",
82
+ ".env",
83
+ ".env.local",
84
+ ".env.*",
85
+ "!.env.example",
86
+ ".pytest_cache/",
87
+ ".mypy_cache/",
88
+ ".ruff_cache/",
89
+ ".DS_Store",
90
+ "Thumbs.db",
91
+ ),
92
+ ),
93
+ )
94
+
95
+
96
+ def ensure_gitignore(project_root: Path) -> None:
97
+ gitignore_path = project_root / ".gitignore"
98
+ content = ""
99
+ if gitignore_path.exists():
100
+ try:
101
+ content = gitignore_path.read_text(encoding="utf-8")
102
+ except OSError:
103
+ return
104
+
105
+ existing_rules = {line.strip() for line in content.splitlines() if line.strip()}
106
+ chunks: list[str] = []
107
+ for heading, rules in GITIGNORE_SECTIONS:
108
+ missing_rules = [rule for rule in rules if rule not in existing_rules]
109
+ if missing_rules:
110
+ chunks.append("\n".join([f"# {heading}", *missing_rules]))
111
+
112
+ if not chunks:
113
+ return
114
+
115
+ prefix = ""
116
+ if content:
117
+ prefix = "" if content.endswith("\n") else "\n"
118
+ prefix += "\n"
119
+
120
+ try:
121
+ gitignore_path.write_text(content + prefix + "\n\n".join(chunks) + "\n", encoding="utf-8")
122
+ except OSError:
123
+ return