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,184 @@
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 +1 @@
1
-
1
+
@@ -0,0 +1,168 @@
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"
@@ -1,48 +1,48 @@
1
- from typing import List, Dict, Any, Set
2
- from pydantic import BaseModel, Field
3
- from pathlib import Path
4
-
5
- class GraphNode(BaseModel):
6
- id: str
7
- type: str # "file", "symbol", "requirement", "task"
8
- metadata: Dict[str, Any] = Field(default_factory=dict)
9
-
10
- class GraphEdge(BaseModel):
11
- source: str
12
- target: str
13
- relation: str # "imports", "implements", "validates", "contains"
14
-
15
- class KnowledgeGraph(BaseModel):
16
- nodes: List[GraphNode] = []
17
- edges: List[GraphEdge] = []
18
-
19
- class GraphIndex:
20
- def __init__(self, project_root: Path):
21
- self.project_root = project_root
22
- self.graph = KnowledgeGraph()
23
-
24
- def build_initial_graph(self, files: List[str]):
25
- """
26
- Bootstrap the graph from file list.
27
- """
28
- for f in files:
29
- self.graph.nodes.append(GraphNode(
30
- id=f,
31
- type="file",
32
- metadata={"extension": Path(f).suffix}
33
- ))
34
-
35
- def add_relation(self, source: str, target: str, relation: str):
36
- self.graph.edges.append(GraphEdge(source=source, target=target, relation=relation))
37
-
38
- def get_context_for_file(self, file_path: str) -> Set[str]:
39
- """
40
- Retrieve related paths for a given file.
41
- """
42
- related = {file_path}
43
- for edge in self.graph.edges:
44
- if edge.source == file_path:
45
- related.add(edge.target)
46
- if edge.target == file_path:
47
- related.add(edge.source)
48
- return related
1
+ from typing import List, Dict, Any, Set
2
+ from pydantic import BaseModel, Field
3
+ from pathlib import Path
4
+
5
+ class GraphNode(BaseModel):
6
+ id: str
7
+ type: str # "file", "symbol", "requirement", "task"
8
+ metadata: Dict[str, Any] = Field(default_factory=dict)
9
+
10
+ class GraphEdge(BaseModel):
11
+ source: str
12
+ target: str
13
+ relation: str # "imports", "implements", "validates", "contains"
14
+
15
+ class KnowledgeGraph(BaseModel):
16
+ nodes: List[GraphNode] = []
17
+ edges: List[GraphEdge] = []
18
+
19
+ class GraphIndex:
20
+ def __init__(self, project_root: Path):
21
+ self.project_root = project_root
22
+ self.graph = KnowledgeGraph()
23
+
24
+ def build_initial_graph(self, files: List[str]):
25
+ """
26
+ Bootstrap the graph from file list.
27
+ """
28
+ for f in files:
29
+ self.graph.nodes.append(GraphNode(
30
+ id=f,
31
+ type="file",
32
+ metadata={"extension": Path(f).suffix}
33
+ ))
34
+
35
+ def add_relation(self, source: str, target: str, relation: str):
36
+ self.graph.edges.append(GraphEdge(source=source, target=target, relation=relation))
37
+
38
+ def get_context_for_file(self, file_path: str) -> Set[str]:
39
+ """
40
+ Retrieve related paths for a given file.
41
+ """
42
+ related = {file_path}
43
+ for edge in self.graph.edges:
44
+ if edge.source == file_path:
45
+ related.add(edge.target)
46
+ if edge.target == file_path:
47
+ related.add(edge.source)
48
+ return related
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class LspServerCandidate:
12
+ language: str
13
+ command: list[str]
14
+ available: bool
15
+ reason: str
16
+
17
+
18
+ class LspInspector:
19
+ """Language-server *detection* only — DevCouncil does not run an LSP client.
20
+
21
+ This inspector exists to answer one honest question: which language servers
22
+ for the repo's languages are installed on PATH? It does NOT spawn a server,
23
+ does NOT speak the LSP wire protocol, and does NOT send the ``initialize``
24
+ handshake. ``starter_initialize_payload`` builds the JSON-RPC ``initialize``
25
+ request a *future* client would send, but it is never transmitted — it is
26
+ surfaced purely as a reference/starter payload, clearly labelled as such, so
27
+ no consumer mistakes detection for a live LSP capability.
28
+ """
29
+
30
+ _LANGUAGE_SERVERS: dict[str, list[list[str]]] = {
31
+ "python": [["pyright-langserver", "--stdio"], ["pylsp"]],
32
+ "typescript": [["typescript-language-server", "--stdio"]],
33
+ "javascript": [["typescript-language-server", "--stdio"]],
34
+ "go": [["gopls"]],
35
+ "rust": [["rust-analyzer"]],
36
+ }
37
+
38
+ _EXTENSIONS: dict[str, str] = {
39
+ ".py": "python",
40
+ ".ts": "typescript",
41
+ ".tsx": "typescript",
42
+ ".js": "javascript",
43
+ ".jsx": "javascript",
44
+ ".go": "go",
45
+ ".rs": "rust",
46
+ }
47
+ _IGNORED_DIRS = {".git", ".devcouncil", "__pycache__", ".venv", "node_modules", "dist", "build", "target", "vendor"}
48
+
49
+ def __init__(self, project_root: Path):
50
+ self.project_root = project_root
51
+
52
+ def _is_ignored_path(self, file: str) -> bool:
53
+ return any(part in self._IGNORED_DIRS for part in Path(file).parts)
54
+
55
+ def detect_languages(self, files: list[str] | None = None) -> list[str]:
56
+ if files is None:
57
+ try:
58
+ discovered: list[str] = []
59
+ for path in self.project_root.rglob("*"):
60
+ if path.is_file() and not any(part in self._IGNORED_DIRS for part in path.parts):
61
+ discovered.append(str(path.relative_to(self.project_root)))
62
+ files = discovered
63
+ except OSError:
64
+ files = []
65
+ languages = {
66
+ self._EXTENSIONS[Path(file).suffix.lower()]
67
+ for file in files
68
+ if Path(file).suffix.lower() in self._EXTENSIONS and not self._is_ignored_path(file)
69
+ }
70
+ return sorted(languages)
71
+
72
+ def server_candidates(self, files: list[str] | None = None) -> list[LspServerCandidate]:
73
+ candidates: list[LspServerCandidate] = []
74
+ for language in self.detect_languages(files):
75
+ for command in self._LANGUAGE_SERVERS.get(language, []):
76
+ executable = command[0]
77
+ available = shutil.which(executable) is not None
78
+ candidates.append(
79
+ LspServerCandidate(
80
+ language=language,
81
+ command=command,
82
+ available=available,
83
+ reason="found on PATH" if available else "not found on PATH",
84
+ )
85
+ )
86
+ return candidates
87
+
88
+ def starter_initialize_payload(self, language: str) -> dict[str, Any]:
89
+ """Build the JSON-RPC ``initialize`` request a future LSP client *would* send.
90
+
91
+ This payload is NEVER sent — DevCouncil has no LSP client. It is provided
92
+ only as a reference/starter for anyone wiring up real LSP support. See the
93
+ ``initialize_requests`` block in :meth:`summary`, which carries the same
94
+ non-sent payloads under an explicit ``"_note"`` disclaimer.
95
+ """
96
+ return {
97
+ "jsonrpc": "2.0",
98
+ "id": 1,
99
+ "method": "initialize",
100
+ "params": {
101
+ "processId": None,
102
+ "rootUri": self.project_root.resolve().as_uri(),
103
+ "capabilities": {
104
+ "textDocument": {
105
+ "publishDiagnostics": {"relatedInformation": True},
106
+ "definition": {"linkSupport": True},
107
+ "references": {},
108
+ "documentSymbol": {"hierarchicalDocumentSymbolSupport": True},
109
+ },
110
+ "workspace": {"symbol": {}},
111
+ },
112
+ "initializationOptions": {"language": language},
113
+ },
114
+ }
115
+
116
+ # Made explicit in every summary so no consumer reads this as a live LSP feature.
117
+ _DETECTION_ONLY_NOTE = (
118
+ "Detection only: DevCouncil checks which language servers are installed on "
119
+ "PATH; it does not run an LSP client or send any requests."
120
+ )
121
+ _STARTER_PAYLOAD_NOTE = (
122
+ "Starter payloads only — these initialize requests are NEVER sent. They are "
123
+ "a reference for wiring up a real LSP client later."
124
+ )
125
+
126
+ def summary(self, files: list[str] | None = None) -> dict[str, Any]:
127
+ candidates = self.server_candidates(files)
128
+ return {
129
+ "mode": "detection-only",
130
+ "note": self._DETECTION_ONLY_NOTE,
131
+ "languages": self.detect_languages(files),
132
+ "detected_servers": [
133
+ {
134
+ "language": candidate.language,
135
+ "command": candidate.command,
136
+ "available": candidate.available,
137
+ "reason": candidate.reason,
138
+ }
139
+ for candidate in candidates
140
+ ],
141
+ # Back-compat alias for "detected_servers" (older consumers/tests).
142
+ "servers": [
143
+ {
144
+ "language": candidate.language,
145
+ "command": candidate.command,
146
+ "available": candidate.available,
147
+ "reason": candidate.reason,
148
+ }
149
+ for candidate in candidates
150
+ ],
151
+ "initialize_requests": {
152
+ "_note": self._STARTER_PAYLOAD_NOTE,
153
+ **{
154
+ language: self.starter_initialize_payload(language)
155
+ for language in sorted({candidate.language for candidate in candidates})
156
+ },
157
+ },
158
+ }
159
+
160
+ def summary_json(self, files: list[str] | None = None) -> str:
161
+ return json.dumps(self.summary(files), indent=2)