devcouncil 0.2.0 → 0.2.2

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 (205) hide show
  1. package/README.md +144 -308
  2. package/bin/devcouncil.js +130 -32
  3. package/package.json +22 -19
  4. package/pyproject.toml +0 -66
  5. package/src/devcouncil/__init__.py +0 -0
  6. package/src/devcouncil/__main__.py +0 -4
  7. package/src/devcouncil/app/__init__.py +0 -28
  8. package/src/devcouncil/app/config.py +0 -320
  9. package/src/devcouncil/app/errors.py +0 -23
  10. package/src/devcouncil/app/events.py +0 -44
  11. package/src/devcouncil/app/orchestrator.py +0 -92
  12. package/src/devcouncil/app/project_status.py +0 -29
  13. package/src/devcouncil/app/run_context.py +0 -39
  14. package/src/devcouncil/app/state_machine.py +0 -108
  15. package/src/devcouncil/artifacts/__init__.py +0 -1
  16. package/src/devcouncil/artifacts/coverage.py +0 -96
  17. package/src/devcouncil/artifacts/graph.py +0 -163
  18. package/src/devcouncil/artifacts/migrations.py +0 -20
  19. package/src/devcouncil/artifacts/schemas.py +0 -23
  20. package/src/devcouncil/artifacts/serializer.py +0 -21
  21. package/src/devcouncil/artifacts/validators.py +0 -27
  22. package/src/devcouncil/assets/__init__.py +0 -1
  23. package/src/devcouncil/assets/devcouncil-logo.svg +0 -60
  24. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  25. package/src/devcouncil/cli/__init__.py +0 -0
  26. package/src/devcouncil/cli/commands/__init__.py +0 -0
  27. package/src/devcouncil/cli/commands/agents.py +0 -292
  28. package/src/devcouncil/cli/commands/artifacts.py +0 -54
  29. package/src/devcouncil/cli/commands/ast.py +0 -22
  30. package/src/devcouncil/cli/commands/baseline.py +0 -35
  31. package/src/devcouncil/cli/commands/check.py +0 -209
  32. package/src/devcouncil/cli/commands/config.py +0 -115
  33. package/src/devcouncil/cli/commands/cost.py +0 -57
  34. package/src/devcouncil/cli/commands/dashboard.py +0 -31
  35. package/src/devcouncil/cli/commands/doctor.py +0 -340
  36. package/src/devcouncil/cli/commands/evidence.py +0 -48
  37. package/src/devcouncil/cli/commands/go.py +0 -656
  38. package/src/devcouncil/cli/commands/handoff.py +0 -69
  39. package/src/devcouncil/cli/commands/hook.py +0 -237
  40. package/src/devcouncil/cli/commands/init.py +0 -289
  41. package/src/devcouncil/cli/commands/integrate.py +0 -1515
  42. package/src/devcouncil/cli/commands/lsp.py +0 -20
  43. package/src/devcouncil/cli/commands/map.py +0 -112
  44. package/src/devcouncil/cli/commands/mcp_server.py +0 -18
  45. package/src/devcouncil/cli/commands/plan.py +0 -488
  46. package/src/devcouncil/cli/commands/prompt.py +0 -61
  47. package/src/devcouncil/cli/commands/repair.py +0 -89
  48. package/src/devcouncil/cli/commands/report.py +0 -137
  49. package/src/devcouncil/cli/commands/reset_demo_state.py +0 -33
  50. package/src/devcouncil/cli/commands/rollback.py +0 -59
  51. package/src/devcouncil/cli/commands/run.py +0 -289
  52. package/src/devcouncil/cli/commands/runs.py +0 -223
  53. package/src/devcouncil/cli/commands/scaffold.py +0 -32
  54. package/src/devcouncil/cli/commands/semantic.py +0 -47
  55. package/src/devcouncil/cli/commands/setup.py +0 -362
  56. package/src/devcouncil/cli/commands/shell.py +0 -73
  57. package/src/devcouncil/cli/commands/show.py +0 -76
  58. package/src/devcouncil/cli/commands/skills.py +0 -88
  59. package/src/devcouncil/cli/commands/status.py +0 -141
  60. package/src/devcouncil/cli/commands/tasks.py +0 -55
  61. package/src/devcouncil/cli/commands/trace.py +0 -88
  62. package/src/devcouncil/cli/commands/verify.py +0 -328
  63. package/src/devcouncil/cli/commands/version.py +0 -20
  64. package/src/devcouncil/cli/commands/watch.py +0 -574
  65. package/src/devcouncil/cli/commands/watch_fs.py +0 -40
  66. package/src/devcouncil/cli/main.py +0 -137
  67. package/src/devcouncil/council/__init__.py +0 -0
  68. package/src/devcouncil/council/prompts/__init__.py +0 -0
  69. package/src/devcouncil/council/prompts/arbiter.md +0 -19
  70. package/src/devcouncil/council/prompts/critic_a.md +0 -10
  71. package/src/devcouncil/council/prompts/critic_b.md +0 -10
  72. package/src/devcouncil/council/prompts/implementation_reviewer.md +0 -16
  73. package/src/devcouncil/council/prompts/planner_a.md +0 -16
  74. package/src/devcouncil/council/prompts/planner_b.md +0 -16
  75. package/src/devcouncil/council/prompts/rebuttal.md +0 -10
  76. package/src/devcouncil/council/prompts/spec_writer.md +0 -12
  77. package/src/devcouncil/domain/__init__.py +0 -0
  78. package/src/devcouncil/domain/assumption.py +0 -17
  79. package/src/devcouncil/domain/critique.py +0 -32
  80. package/src/devcouncil/domain/evidence.py +0 -47
  81. package/src/devcouncil/domain/gap.py +0 -52
  82. package/src/devcouncil/domain/requirement.py +0 -22
  83. package/src/devcouncil/domain/task.py +0 -55
  84. package/src/devcouncil/execution/__init__.py +0 -1
  85. package/src/devcouncil/execution/checkpoints.py +0 -246
  86. package/src/devcouncil/execution/context_builder.py +0 -60
  87. package/src/devcouncil/execution/executor.py +0 -15
  88. package/src/devcouncil/execution/fs_watcher.py +0 -180
  89. package/src/devcouncil/execution/handoff.py +0 -102
  90. package/src/devcouncil/execution/hook_policy.py +0 -253
  91. package/src/devcouncil/execution/patch.py +0 -77
  92. package/src/devcouncil/execution/paths.py +0 -14
  93. package/src/devcouncil/execution/permissions.py +0 -85
  94. package/src/devcouncil/execution/policy_engine.py +0 -343
  95. package/src/devcouncil/execution/prompt_builder.py +0 -671
  96. package/src/devcouncil/execution/shell_session.py +0 -225
  97. package/src/devcouncil/execution/task_runner.py +0 -170
  98. package/src/devcouncil/executors/__init__.py +0 -1
  99. package/src/devcouncil/executors/agent_registry.py +0 -575
  100. package/src/devcouncil/executors/coding_cli.py +0 -736
  101. package/src/devcouncil/executors/mini_swe.py +0 -73
  102. package/src/devcouncil/executors/native/__init__.py +0 -0
  103. package/src/devcouncil/executors/native/agent.py +0 -208
  104. package/src/devcouncil/executors/openhands.py +0 -71
  105. package/src/devcouncil/gating/__init__.py +0 -1
  106. package/src/devcouncil/gating/checks/__init__.py +0 -0
  107. package/src/devcouncil/gating/checks/clean_git.py +0 -52
  108. package/src/devcouncil/gating/checks/planned_files_check.py +0 -32
  109. package/src/devcouncil/gating/checks/requirement_coverage.py +0 -26
  110. package/src/devcouncil/gating/checks/secret_scan_check.py +0 -53
  111. package/src/devcouncil/gating/policy.py +0 -338
  112. package/src/devcouncil/hardware.py +0 -184
  113. package/src/devcouncil/indexing/__init__.py +0 -1
  114. package/src/devcouncil/indexing/ast_matcher.py +0 -168
  115. package/src/devcouncil/indexing/graph_index.py +0 -48
  116. package/src/devcouncil/indexing/lsp.py +0 -161
  117. package/src/devcouncil/indexing/repo_mapper.py +0 -1455
  118. package/src/devcouncil/indexing/semantic_index.py +0 -205
  119. package/src/devcouncil/integrations/actions.py +0 -146
  120. package/src/devcouncil/integrations/check.py +0 -423
  121. package/src/devcouncil/integrations/code_review_graph.py +0 -163
  122. package/src/devcouncil/integrations/github.py +0 -39
  123. package/src/devcouncil/integrations/github_intent.py +0 -142
  124. package/src/devcouncil/integrations/gitnexus.py +0 -62
  125. package/src/devcouncil/integrations/graphify.py +0 -34
  126. package/src/devcouncil/integrations/mcp/__init__.py +0 -0
  127. package/src/devcouncil/integrations/mcp/server.py +0 -2122
  128. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +0 -24
  129. package/src/devcouncil/integrations/pr_comments.py +0 -62
  130. package/src/devcouncil/live/__init__.py +0 -2
  131. package/src/devcouncil/live/cards.py +0 -349
  132. package/src/devcouncil/live/models.py +0 -63
  133. package/src/devcouncil/live/repair_prompt.py +0 -83
  134. package/src/devcouncil/live/reviewer.py +0 -70
  135. package/src/devcouncil/live/signals.py +0 -135
  136. package/src/devcouncil/live/summary.py +0 -34
  137. package/src/devcouncil/live/tasks.py +0 -18
  138. package/src/devcouncil/live/transcripts.py +0 -141
  139. package/src/devcouncil/llm/__init__.py +0 -1
  140. package/src/devcouncil/llm/cache.py +0 -42
  141. package/src/devcouncil/llm/model_defaults.yaml +0 -44
  142. package/src/devcouncil/llm/provider.py +0 -627
  143. package/src/devcouncil/llm/router.py +0 -310
  144. package/src/devcouncil/optimization/__init__.py +0 -1
  145. package/src/devcouncil/optimization/gepa_agent.py +0 -318
  146. package/src/devcouncil/planning/__init__.py +0 -1
  147. package/src/devcouncil/planning/arbiter_service.py +0 -57
  148. package/src/devcouncil/planning/correction_manifest.py +0 -303
  149. package/src/devcouncil/planning/critique_service.py +0 -71
  150. package/src/devcouncil/planning/plan_service.py +0 -60
  151. package/src/devcouncil/planning/prompt_enhancer_service.py +0 -167
  152. package/src/devcouncil/planning/repair_service.py +0 -39
  153. package/src/devcouncil/planning/spec_service.py +0 -70
  154. package/src/devcouncil/repo/__init__.py +0 -0
  155. package/src/devcouncil/repo/ci_scaffold.py +0 -157
  156. package/src/devcouncil/repo/gitignore.py +0 -123
  157. package/src/devcouncil/repo/sca.py +0 -374
  158. package/src/devcouncil/reporting/__init__.py +0 -0
  159. package/src/devcouncil/reporting/github_check.py +0 -32
  160. package/src/devcouncil/reporting/json_report.py +0 -30
  161. package/src/devcouncil/reporting/markdown_report.py +0 -83
  162. package/src/devcouncil/reporting/report_builder.py +0 -14
  163. package/src/devcouncil/skills/__init__.py +0 -19
  164. package/src/devcouncil/skills/library/README.md +0 -46
  165. package/src/devcouncil/skills/library/ai-training.md +0 -50
  166. package/src/devcouncil/skills/library/android.md +0 -50
  167. package/src/devcouncil/skills/library/backend.md +0 -52
  168. package/src/devcouncil/skills/library/core-engineering.md +0 -95
  169. package/src/devcouncil/skills/library/data-engineering.md +0 -47
  170. package/src/devcouncil/skills/library/desktop.md +0 -46
  171. package/src/devcouncil/skills/library/devops.md +0 -48
  172. package/src/devcouncil/skills/library/game-dev.md +0 -46
  173. package/src/devcouncil/skills/library/ios.md +0 -48
  174. package/src/devcouncil/skills/library/mobile-cross-platform.md +0 -46
  175. package/src/devcouncil/skills/library/security.md +0 -48
  176. package/src/devcouncil/skills/library/systems.md +0 -48
  177. package/src/devcouncil/skills/library/web.md +0 -47
  178. package/src/devcouncil/skills/library/windows.md +0 -47
  179. package/src/devcouncil/skills/registry.py +0 -330
  180. package/src/devcouncil/storage/__init__.py +0 -0
  181. package/src/devcouncil/storage/db.py +0 -147
  182. package/src/devcouncil/storage/models.py +0 -204
  183. package/src/devcouncil/storage/native.py +0 -557
  184. package/src/devcouncil/storage/repositories.py +0 -485
  185. package/src/devcouncil/telemetry/__init__.py +0 -0
  186. package/src/devcouncil/telemetry/cost.py +0 -140
  187. package/src/devcouncil/telemetry/model_pricing.yaml +0 -48
  188. package/src/devcouncil/telemetry/pricing.py +0 -28
  189. package/src/devcouncil/telemetry/traces.py +0 -146
  190. package/src/devcouncil/telemetry/tracker.py +0 -52
  191. package/src/devcouncil/ui/__init__.py +0 -1
  192. package/src/devcouncil/ui/dashboard.py +0 -423
  193. package/src/devcouncil/utils/__init__.py +0 -1
  194. package/src/devcouncil/utils/redaction.py +0 -147
  195. package/src/devcouncil/utils/subprocess_env.py +0 -69
  196. package/src/devcouncil/verification/__init__.py +0 -1
  197. package/src/devcouncil/verification/acceptance_compiler.py +0 -125
  198. package/src/devcouncil/verification/ad_hoc_check.py +0 -129
  199. package/src/devcouncil/verification/diff_coverage.py +0 -353
  200. package/src/devcouncil/verification/implementation_reviewer.py +0 -55
  201. package/src/devcouncil/verification/next_actions.py +0 -189
  202. package/src/devcouncil/verification/sandbox.py +0 -178
  203. package/src/devcouncil/verification/test_resolver.py +0 -91
  204. package/src/devcouncil/verification/verifier.py +0 -1548
  205. package/uv.lock +0 -1226
@@ -1,338 +0,0 @@
1
- from pydantic import BaseModel
2
- from typing import Any, List, Optional
3
- from pathlib import Path
4
-
5
- from devcouncil.domain.requirement import Requirement
6
- from devcouncil.domain.task import Task
7
- from devcouncil.domain.gap import Gap
8
- from devcouncil.domain.assumption import Assumption
9
- from devcouncil.domain.critique import CritiqueFinding
10
- from devcouncil.gating.checks.requirement_coverage import RequirementCoverageCheck
11
- from devcouncil.gating.checks.planned_files_check import PlannedFilesCheck
12
- from devcouncil.gating.checks.clean_git import CleanGitCheck
13
-
14
- class GateResult(BaseModel):
15
- passed: bool
16
- gaps: List[Gap]
17
-
18
-
19
- def _find_dependency_cycle(tasks: List[Task]) -> Optional[List[str]]:
20
- """Return one dependency cycle as an id path (e.g. [A, B, A]), or None. Only edges
21
- to known task ids are followed; unknown deps are reported separately."""
22
- ids = {t.id for t in tasks}
23
- graph = {t.id: [d for d in t.depends_on if d in ids] for t in tasks}
24
- WHITE, GREY, BLACK = 0, 1, 2
25
- color = {tid: WHITE for tid in graph}
26
- stack: List[str] = []
27
-
28
- def visit(node: str) -> Optional[List[str]]:
29
- color[node] = GREY
30
- stack.append(node)
31
- for nxt in graph.get(node, []):
32
- if color[nxt] == GREY:
33
- return stack[stack.index(nxt):] + [nxt]
34
- if color[nxt] == WHITE:
35
- found = visit(nxt)
36
- if found:
37
- return found
38
- stack.pop()
39
- color[node] = BLACK
40
- return None
41
-
42
- for tid in graph:
43
- if color[tid] == WHITE:
44
- found = visit(tid)
45
- if found:
46
- return found
47
- return None
48
-
49
-
50
- def topological_order(tasks: List[Task]) -> List[Task]:
51
- """Order tasks so every task follows the ones it depends on. Stable: preserves the
52
- given order among independent tasks. Falls back to the original order if a cycle
53
- makes a full ordering impossible (the plan gate blocks cycles separately)."""
54
- by_id = {t.id: t for t in tasks}
55
- indegree = {t.id: 0 for t in tasks}
56
- dependents: dict[str, List[str]] = {t.id: [] for t in tasks}
57
- for task in tasks:
58
- for dep in task.depends_on:
59
- if dep in by_id:
60
- indegree[task.id] += 1
61
- dependents[dep].append(task.id)
62
- # Kahn's algorithm, seeded in original order for stability.
63
- ready = [t.id for t in tasks if indegree[t.id] == 0]
64
- ordered: List[str] = []
65
- while ready:
66
- current = ready.pop(0)
67
- ordered.append(current)
68
- for child in dependents[current]:
69
- indegree[child] -= 1
70
- if indegree[child] == 0:
71
- ready.append(child)
72
- if len(ordered) != len(tasks): # cycle — fall back to original order
73
- return list(tasks)
74
- return [by_id[tid] for tid in ordered]
75
-
76
- class GatePolicy:
77
- """Central engine for executing project and task level quality gates."""
78
-
79
- def __init__(self):
80
- self.req_coverage = RequirementCoverageCheck()
81
- self.planned_files = PlannedFilesCheck()
82
- self.clean_git = CleanGitCheck()
83
-
84
- def check_plan_approval(
85
- self,
86
- requirements: List[Requirement],
87
- tasks: List[Task],
88
- assumptions: Optional[List[Assumption]] = None,
89
- findings: Optional[List[CritiqueFinding]] = None,
90
- blocking_questions: Optional[List[Any]] = None,
91
- ) -> GateResult:
92
- """Determines if the overall project plan is ready for execution."""
93
- gaps = []
94
- known_req_ids = {req.id for req in requirements}
95
- known_ac_ids = {
96
- ac.id
97
- for req in requirements
98
- for ac in req.acceptance_criteria
99
- }
100
-
101
- # 1. Check requirement coverage
102
- gaps.extend(self.req_coverage.check(requirements, tasks))
103
-
104
- # 2. Check for acceptance criteria presence
105
- for req in requirements:
106
- if not req.acceptance_criteria:
107
- gaps.append(Gap(
108
- id=f"GAP-PLAN-{req.id}-NO-AC",
109
- severity="high",
110
- gap_type="requirement_not_planned",
111
- requirement_id=req.id,
112
- description=f"Requirement {req.id} has no acceptance criteria.",
113
- recommended_fix="Define at least one measurable AC.",
114
- blocking=True
115
- ))
116
-
117
- for ac in req.acceptance_criteria:
118
- if not ac.verification_method:
119
- gaps.append(Gap(
120
- id=f"GAP-PLAN-{ac.id}-NO-VERIFY",
121
- severity="high",
122
- gap_type="acceptance_criteria_unproven",
123
- requirement_id=req.id,
124
- description=f"Acceptance criterion {ac.id} has no verification method.",
125
- recommended_fix="Define a deterministic verification method for this AC.",
126
- blocking=True,
127
- ))
128
-
129
- for task in tasks:
130
- if not task.requirement_ids:
131
- gaps.append(Gap(
132
- id=f"GAP-PLAN-{task.id}-NO-REQ",
133
- severity="high",
134
- gap_type="requirement_not_planned",
135
- task_id=task.id,
136
- description=f"Task {task.id} is not mapped to any requirement.",
137
- recommended_fix="Map each task to at least one requirement.",
138
- blocking=True,
139
- ))
140
-
141
- unknown_req_ids = [req_id for req_id in task.requirement_ids if req_id not in known_req_ids]
142
- if unknown_req_ids:
143
- gaps.append(Gap(
144
- id=f"GAP-PLAN-{task.id}-UNKNOWN-REQ",
145
- severity="high",
146
- gap_type="requirement_not_planned",
147
- task_id=task.id,
148
- description=f"Task {task.id} references unknown requirement(s): {', '.join(unknown_req_ids)}.",
149
- recommended_fix="Remove invalid requirement links or add the missing requirements.",
150
- blocking=True,
151
- ))
152
-
153
- unknown_ac_ids = [ac_id for ac_id in task.acceptance_criterion_ids if ac_id not in known_ac_ids]
154
- if unknown_ac_ids:
155
- gaps.append(Gap(
156
- id=f"GAP-PLAN-{task.id}-UNKNOWN-AC",
157
- severity="high",
158
- gap_type="acceptance_criteria_unproven",
159
- task_id=task.id,
160
- description=f"Task {task.id} references unknown acceptance criteria: {', '.join(unknown_ac_ids)}.",
161
- recommended_fix="Link tasks only to acceptance criteria declared by requirements.",
162
- blocking=True,
163
- ))
164
-
165
- # Surface read-only-only tasks at PLANNING time (advisory): a task that declares
166
- # planned files but none writable can implement nothing, and was previously only
167
- # caught at execution by the task-readiness gate.
168
- for task in tasks:
169
- if task.planned_files and not any(
170
- pf.allowed_change in ("create", "modify", "delete") for pf in task.planned_files
171
- ):
172
- gaps.append(Gap(
173
- id=f"GAP-PLAN-{task.id}-READ-ONLY",
174
- severity="medium",
175
- gap_type="task_not_implemented",
176
- task_id=task.id,
177
- description=(
178
- f"Task {task.id} declares planned files but none are writable "
179
- "(all read_only); it cannot implement any change. Expected only if "
180
- "this is an analysis-only task."
181
- ),
182
- recommended_fix=(
183
- "Grant 'create', 'modify', or 'delete' to at least one planned file, "
184
- "or confirm the task is intentionally analysis-only."
185
- ),
186
- blocking=False,
187
- ))
188
-
189
- # Surface overlapping ownership (advisory): when 2+ tasks each declare a
190
- # writable (create/modify/delete) change to the SAME file, the plan is
191
- # over-decomposed — the later task tends to duplicate or conflict with the
192
- # earlier one (e.g. both add the same function), which then fails per-task
193
- # verification. Consolidating a file's work into one task avoids this.
194
- writers_by_file: dict[str, list[str]] = {}
195
- for task in tasks:
196
- for pf in task.planned_files:
197
- if pf.allowed_change in ("create", "modify", "delete"):
198
- path = pf.path.replace("\\", "/")
199
- writers_by_file.setdefault(path, [])
200
- if task.id not in writers_by_file[path]:
201
- writers_by_file[path].append(task.id)
202
- for path, owners in writers_by_file.items():
203
- if len(owners) > 1:
204
- gaps.append(Gap(
205
- id=f"GAP-PLAN-OVERLAP-{owners[0]}-{path.replace('/', '_')}",
206
- severity="medium",
207
- gap_type="task_not_implemented",
208
- description=(
209
- f"{len(owners)} tasks ({', '.join(owners)}) each declare writable "
210
- f"changes to {path}; overlapping ownership over-decomposes the plan "
211
- "and tends to cause duplicate/conflicting edits at execution."
212
- ),
213
- recommended_fix=(
214
- f"Consolidate the work on {path} into a single task, or scope the "
215
- "others to read_only."
216
- ),
217
- blocking=False,
218
- ))
219
-
220
- # Validate the task dependency DAG: unknown depends_on ids and cycles would make
221
- # execution ordering impossible / stall the run, so block the plan on them.
222
- gaps.extend(self._validate_task_dependencies(tasks))
223
-
224
- for assumption in assumptions or []:
225
- if (
226
- assumption.impact == "high"
227
- and assumption.status == "open"
228
- and assumption.requires_user_confirmation
229
- ):
230
- gaps.append(Gap(
231
- id=f"GAP-PLAN-{assumption.id}-OPEN",
232
- severity="high",
233
- gap_type="assumption_violated",
234
- requirement_id=assumption.linked_requirement_ids[0] if assumption.linked_requirement_ids else None,
235
- description=f"High-impact assumption {assumption.id} is still open: {assumption.statement}",
236
- recommended_fix="Confirm, reject, or convert this assumption before approving the plan.",
237
- blocking=True,
238
- ))
239
-
240
- for finding in findings or []:
241
- if finding.severity in {"high", "critical"} and finding.status == "open":
242
- gaps.append(Gap(
243
- id=f"GAP-PLAN-{finding.id}-OPEN",
244
- severity=finding.severity,
245
- gap_type="architecture_drift",
246
- requirement_id=finding.linked_requirement_id,
247
- description=f"Open {finding.severity} critique finding remains: {finding.claim}",
248
- recommended_fix="Convert, rebut with evidence, or mark this finding resolved before approval.",
249
- blocking=True,
250
- ))
251
-
252
- for question in blocking_questions or []:
253
- question_id = getattr(question, "id", "QUESTION")
254
- question_text = getattr(question, "question", str(question))
255
- gaps.append(Gap(
256
- id=f"GAP-PLAN-{question_id}-BLOCKING",
257
- severity="high",
258
- gap_type="requirement_not_planned",
259
- description=f"Blocking question remains unanswered: {question_text}",
260
- recommended_fix="Answer or convert the blocking question before approving the plan.",
261
- blocking=True,
262
- ))
263
-
264
- return GateResult(
265
- passed=len([g for g in gaps if g.blocking]) == 0,
266
- gaps=gaps
267
- )
268
-
269
- def check_task_ready(self, task: Task, project_root: Path) -> GateResult:
270
- """Determines if a specific task can begin execution."""
271
- gaps = []
272
-
273
- # 1. Check Git state
274
- gaps.extend(self.clean_git.check(project_root, task.id))
275
-
276
- # 2. Check planned files
277
- gaps.extend(self.planned_files.check(task))
278
-
279
- # 3. Surface a missing execution/verification contract — but do NOT block
280
- # execution on it. The executor still needs to run to implement the code,
281
- # and the evidence requirement is genuinely enforced at verify time
282
- # (acceptance_criteria_unproven / NOAC gaps). Blocking here only prevents
283
- # implementation and stalls multi-task plans when the planner under-specs a
284
- # task; these stay advisory so the work can proceed and be judged on output.
285
- if not task.allowed_commands and not task.expected_tests:
286
- gaps.append(Gap(
287
- id=f"GAP-{task.id}-NO-COMMANDS",
288
- severity="medium",
289
- gap_type="missing_test",
290
- task_id=task.id,
291
- description=f"Task {task.id} has no allowed commands for execution or verification.",
292
- recommended_fix="Add explicit allowed_commands or expected_tests so verification can prove the acceptance criteria.",
293
- blocking=False,
294
- ))
295
-
296
- if not task.expected_tests:
297
- gaps.append(Gap(
298
- id=f"GAP-{task.id}-NO-EXPECTED-EVIDENCE",
299
- severity="medium",
300
- gap_type="missing_test",
301
- task_id=task.id,
302
- description=f"Task {task.id} has no expected verification evidence.",
303
- recommended_fix="Add expected_tests or targeted static/manual review commands that prove the acceptance criteria.",
304
- blocking=False,
305
- ))
306
-
307
- return GateResult(
308
- passed=len([g for g in gaps if g.blocking]) == 0,
309
- gaps=gaps
310
- )
311
-
312
- def _validate_task_dependencies(self, tasks: List[Task]) -> List[Gap]:
313
- """Block on a malformed dependency DAG: unknown depends_on ids and cycles."""
314
- gaps: List[Gap] = []
315
- ids = {t.id for t in tasks}
316
- for task in tasks:
317
- unknown = [dep for dep in task.depends_on if dep not in ids]
318
- if unknown:
319
- gaps.append(Gap(
320
- id=f"GAP-PLAN-{task.id}-UNKNOWN-DEP",
321
- severity="high",
322
- gap_type="task_not_implemented",
323
- task_id=task.id,
324
- description=f"Task {task.id} depends on unknown task(s): {', '.join(unknown)}.",
325
- recommended_fix="Reference only task IDs that exist in this plan, or remove the dependency.",
326
- blocking=True,
327
- ))
328
- cycle = _find_dependency_cycle(tasks)
329
- if cycle:
330
- gaps.append(Gap(
331
- id="GAP-PLAN-DEP-CYCLE",
332
- severity="high",
333
- gap_type="task_not_implemented",
334
- description=f"Task dependency cycle detected: {' -> '.join(cycle)}.",
335
- recommended_fix="Break the cycle so the tasks can be ordered and executed.",
336
- blocking=True,
337
- ))
338
- return gaps
@@ -1,184 +0,0 @@
1
- """Host hardware detection used to size local (Ollama) models.
2
-
3
- DevCouncil runs the council roles against whatever LLM provider is configured.
4
- For the local ``ollama`` provider the model has to fit in host memory. On Apple
5
- Silicon Macs that memory is *unified* (shared by CPU/GPU/OS), so total RAM is the
6
- ceiling. On a host with a discrete GPU (NVIDIA), Ollama offloads to VRAM, so the
7
- *VRAM* is the practical ceiling instead — a 64 GB box with an 8 GB GPU should not be
8
- told to run a model that only fits in system RAM. This module exposes small, pure
9
- helpers so ``dev doctor`` and ``dev setup`` can recommend a model that will actually
10
- run instead of a one-size-fits-all default, on macOS, Linux and Windows.
11
-
12
- Everything here is best-effort and stdlib-only: detection failures return
13
- ``None`` rather than raising, so callers degrade to the static default.
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import os
19
- import platform
20
- import shutil
21
- import subprocess
22
- from dataclasses import dataclass
23
-
24
- # Recommended Ollama context window for DevCouncil's large planning prompts.
25
- # Kept in sync with the value surfaced by `dev doctor`.
26
- RECOMMENDED_NUM_CTX = 16384
27
-
28
- # Fallback model when the host is unknown or has little memory. Matches the
29
- # static defaults in ``llm/model_defaults.yaml``.
30
- DEFAULT_OLLAMA_MODEL = "qwen2.5-coder:7b"
31
-
32
- # RAM (GiB) -> recommended qwen2.5-coder size. Highest matching tier wins.
33
- # Sizes are chosen so the quantized weights plus a 16k context comfortably fit
34
- # alongside the OS in Apple Silicon unified memory.
35
- _OLLAMA_MODEL_TIERS: tuple[tuple[float, str], ...] = (
36
- (48.0, "qwen2.5-coder:32b"),
37
- (24.0, "qwen2.5-coder:14b"),
38
- (0.0, "qwen2.5-coder:7b"),
39
- )
40
-
41
-
42
- def is_macos() -> bool:
43
- return platform.system() == "Darwin"
44
-
45
-
46
- def is_apple_silicon() -> bool:
47
- """True on Apple-Silicon (arm64) Macs."""
48
- return is_macos() and platform.machine() == "arm64"
49
-
50
-
51
- def mac_chip_brand() -> str | None:
52
- """Marketing CPU string on macOS (e.g. ``Apple M3 Pro``), else None."""
53
- if not is_macos():
54
- return None
55
- try:
56
- out = subprocess.check_output(
57
- ["sysctl", "-n", "machdep.cpu.brand_string"],
58
- text=True,
59
- timeout=5,
60
- ).strip()
61
- return out or None
62
- except Exception:
63
- return None
64
-
65
-
66
- def total_ram_gb() -> float | None:
67
- """Total physical RAM in GiB, or None if it cannot be determined."""
68
- try:
69
- if is_macos():
70
- out = subprocess.check_output(
71
- ["sysctl", "-n", "hw.memsize"], text=True, timeout=5
72
- ).strip()
73
- return int(out) / (1024**3)
74
- # POSIX (Linux): pages * page size.
75
- pages = os.sysconf("SC_PHYS_PAGES")
76
- page_size = os.sysconf("SC_PAGE_SIZE")
77
- return (pages * page_size) / (1024**3)
78
- except (OSError, ValueError, AttributeError, subprocess.SubprocessError):
79
- return None
80
-
81
-
82
- def nvidia_vram_gb() -> float | None:
83
- """Total VRAM (GiB) of the largest NVIDIA GPU via ``nvidia-smi``, else ``None``.
84
-
85
- On hosts with a discrete NVIDIA GPU this is the real ceiling for Ollama, since the
86
- model is offloaded to VRAM. Returns ``None`` when ``nvidia-smi`` is absent (no GPU,
87
- Apple Silicon, or an unsupported vendor), so callers fall back to total RAM."""
88
- if not shutil.which("nvidia-smi"):
89
- return None
90
- try:
91
- out = subprocess.check_output(
92
- ["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
93
- text=True,
94
- timeout=5,
95
- stderr=subprocess.DEVNULL,
96
- ).strip()
97
- except (OSError, subprocess.SubprocessError):
98
- return None
99
- sizes_mib: list[float] = []
100
- for line in out.splitlines():
101
- line = line.strip()
102
- if not line:
103
- continue
104
- try:
105
- sizes_mib.append(float(line))
106
- except ValueError:
107
- continue
108
- if not sizes_mib:
109
- return None
110
- return max(sizes_mib) / 1024.0 # MiB -> GiB
111
-
112
-
113
- def recommend_ollama_model(ram_gb: float | None = None, vram_gb: float | None = None) -> str:
114
- """Largest qwen2.5-coder size expected to run on the host.
115
-
116
- When a discrete GPU's ``vram_gb`` is known it is the ceiling (Ollama offloads to
117
- VRAM); otherwise total system RAM is used (correct for unified-memory Macs and
118
- CPU-only hosts)."""
119
- if ram_gb is None:
120
- ram_gb = total_ram_gb()
121
- effective = vram_gb if vram_gb is not None else ram_gb
122
- if effective is None:
123
- return DEFAULT_OLLAMA_MODEL
124
- for floor, model in _OLLAMA_MODEL_TIERS:
125
- if effective >= floor:
126
- return model
127
- return DEFAULT_OLLAMA_MODEL
128
-
129
-
130
- @dataclass(frozen=True)
131
- class HostSummary:
132
- """A snapshot of the host relevant to local-model sizing."""
133
-
134
- is_macos: bool
135
- is_apple_silicon: bool
136
- chip: str | None
137
- ram_gb: float | None
138
- recommended_ollama_model: str
139
- vram_gb: float | None = None
140
-
141
- @property
142
- def ram_label(self) -> str:
143
- return f"{self.ram_gb:.0f} GB" if self.ram_gb is not None else "unknown RAM"
144
-
145
- @property
146
- def vram_label(self) -> str | None:
147
- return f"{self.vram_gb:.0f} GB VRAM" if self.vram_gb is not None else None
148
-
149
- @property
150
- def chip_label(self) -> str:
151
- if self.chip:
152
- return self.chip
153
- if self.is_apple_silicon:
154
- return "Apple Silicon"
155
- if self.vram_gb is not None:
156
- return "discrete GPU host"
157
- return "this host"
158
-
159
- @property
160
- def platform_label(self) -> str:
161
- """Human label for the sizing row across OSes."""
162
- if self.is_macos:
163
- return "Apple Silicon" if self.is_apple_silicon else "Mac (Intel)"
164
- system = platform.system()
165
- return system or "Host"
166
-
167
- @property
168
- def memory_label(self) -> str:
169
- """Memory ceiling used for the recommendation (VRAM if discrete GPU, else RAM)."""
170
- vram = self.vram_label
171
- return f"{vram} (GPU)" if vram else self.ram_label
172
-
173
-
174
- def describe_host() -> HostSummary:
175
- ram = total_ram_gb()
176
- vram = nvidia_vram_gb()
177
- return HostSummary(
178
- is_macos=is_macos(),
179
- is_apple_silicon=is_apple_silicon(),
180
- chip=mac_chip_brand(),
181
- ram_gb=ram,
182
- vram_gb=vram,
183
- recommended_ollama_model=recommend_ollama_model(ram, vram),
184
- )
@@ -1 +0,0 @@
1
-
@@ -1,168 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import ast
4
- import re
5
- from dataclasses import dataclass
6
- from pathlib import Path
7
-
8
-
9
- @dataclass(frozen=True)
10
- class AstMatch:
11
- path: str
12
- language: str
13
- kind: str
14
- name: str
15
- line: int
16
- text: str
17
- engine: str
18
-
19
- def model_dump(self) -> dict[str, object]:
20
- return {
21
- "path": self.path,
22
- "language": self.language,
23
- "kind": self.kind,
24
- "name": self.name,
25
- "line": self.line,
26
- "text": self.text,
27
- "engine": self.engine,
28
- }
29
-
30
-
31
- class AstMatcher:
32
- """Tree-sitter-style structural search with optional tree_sitter and deterministic fallbacks."""
33
-
34
- _EXT_LANGUAGE = {
35
- ".py": "python",
36
- ".ts": "typescript",
37
- ".tsx": "typescript",
38
- ".js": "javascript",
39
- ".jsx": "javascript",
40
- ".go": "go",
41
- ".rs": "rust",
42
- }
43
-
44
- _SYMBOL_PATTERNS: dict[str, re.Pattern[str]] = {
45
- "typescript": re.compile(
46
- r"^\s*(?:export\s+)?(?:(?:async\s+)?(?:function|class|interface|type)\s+|const\s+)([A-Za-z_$][\w$]*)"
47
- ),
48
- "javascript": re.compile(
49
- r"^\s*(?:export\s+)?(?:(?:async\s+)?(?:function|class)\s+|const\s+)([A-Za-z_$][\w$]*)"
50
- ),
51
- "go": re.compile(r"^\s*func\s+(?:\([^)]+\)\s*)?([A-Za-z_]\w*)\s*\("),
52
- "rust": re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?(?:fn|struct|enum|trait)\s+([A-Za-z_]\w*)"),
53
- }
54
- _IGNORED_DIRS = {".git", ".devcouncil", "__pycache__", ".venv", "node_modules", "dist", "build", "target", "vendor"}
55
-
56
- def __init__(self, project_root: Path):
57
- self.project_root = project_root
58
- self.tree_sitter_available = self._has_tree_sitter()
59
-
60
- def _has_tree_sitter(self) -> bool:
61
- try:
62
- import tree_sitter # type: ignore[import-not-found] # noqa: F401
63
- except Exception:
64
- return False
65
- return True
66
-
67
- def match(
68
- self,
69
- *,
70
- query: str = "",
71
- language: str | None = None,
72
- kind: str | None = None,
73
- limit: int = 100,
74
- ) -> list[AstMatch]:
75
- language = language.lower() if language else None
76
- kind = kind.lower() if kind else None
77
- limit = max(1, limit)
78
- matches: list[AstMatch] = []
79
- for path in self._candidate_files(language):
80
- try:
81
- text = path.read_text(encoding="utf-8")
82
- except (OSError, UnicodeDecodeError):
83
- continue
84
- rel = path.relative_to(self.project_root).as_posix()
85
- file_language = self._EXT_LANGUAGE.get(path.suffix.lower(), path.suffix.lower().lstrip("."))
86
- matches.extend(self._match_file(rel, file_language, text, query=query, kind=kind))
87
- if len(matches) >= limit:
88
- return matches[:limit]
89
- return matches[:limit]
90
-
91
- def _candidate_files(self, language: str | None) -> list[Path]:
92
- allowed_exts = {
93
- ext for ext, ext_language in self._EXT_LANGUAGE.items()
94
- if language is None or ext_language == language
95
- }
96
- files: list[Path] = []
97
- try:
98
- for path in self.project_root.rglob("*"):
99
- if not path.is_file() or path.suffix.lower() not in allowed_exts:
100
- continue
101
- if any(part in self._IGNORED_DIRS for part in path.parts):
102
- continue
103
- files.append(path)
104
- except OSError:
105
- return files
106
- return sorted(files)
107
-
108
- def _match_file(self, rel: str, language: str, text: str, *, query: str, kind: str | None) -> list[AstMatch]:
109
- if language == "python":
110
- return self._match_python(rel, text, query=query, kind=kind)
111
- pattern = self._SYMBOL_PATTERNS.get(language)
112
- if not pattern:
113
- return []
114
- results: list[AstMatch] = []
115
- for lineno, line in enumerate(text.splitlines(), start=1):
116
- match = pattern.match(line)
117
- if not match:
118
- continue
119
- symbol_name = match.group(1)
120
- symbol_kind = self._line_kind(line)
121
- if kind and kind != symbol_kind:
122
- continue
123
- if query and query.lower() not in symbol_name.lower() and query.lower() not in line.lower():
124
- continue
125
- results.append(AstMatch(rel, language, symbol_kind, symbol_name, lineno, line.strip(), self._engine()))
126
- return results
127
-
128
- def _match_python(self, rel: str, text: str, *, query: str, kind: str | None) -> list[AstMatch]:
129
- try:
130
- tree = ast.parse(text)
131
- except SyntaxError:
132
- return []
133
- lines = text.splitlines()
134
- results: list[AstMatch] = []
135
- for node in ast.walk(tree):
136
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
137
- symbol_kind = "function"
138
- elif isinstance(node, ast.ClassDef):
139
- symbol_kind = "class"
140
- else:
141
- continue
142
- symbol_name = node.name
143
- source = lines[node.lineno - 1].strip() if 0 < node.lineno <= len(lines) else symbol_name
144
- if kind and kind != symbol_kind:
145
- continue
146
- if query and query.lower() not in symbol_name.lower() and query.lower() not in source.lower():
147
- continue
148
- results.append(AstMatch(rel, "python", symbol_kind, symbol_name, node.lineno, source, self._engine()))
149
- return sorted(results, key=lambda item: (item.path, item.line))
150
-
151
- def _line_kind(self, line: str) -> str:
152
- stripped = line.strip()
153
- if "class " in stripped:
154
- return "class"
155
- if stripped.startswith(("type ", "export type ")):
156
- return "type"
157
- if stripped.startswith(("interface ", "export interface ")):
158
- return "interface"
159
- if stripped.startswith(("struct ", "pub struct ")):
160
- return "struct"
161
- if stripped.startswith(("enum ", "pub enum ")):
162
- return "enum"
163
- if stripped.startswith(("trait ", "pub trait ")):
164
- return "trait"
165
- return "function"
166
-
167
- def _engine(self) -> str:
168
- return "tree-sitter-optional" if self.tree_sitter_available else "fallback-ast"