devcouncil 0.1.1 → 0.3.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 (159) hide show
  1. package/README.md +201 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +348 -12
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +32 -5
  8. package/src/devcouncil/assets/__init__.py +1 -0
  9. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  10. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  11. package/src/devcouncil/cli/commands/agents.py +292 -0
  12. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  13. package/src/devcouncil/cli/commands/check.py +220 -0
  14. package/src/devcouncil/cli/commands/config.py +43 -4
  15. package/src/devcouncil/cli/commands/cost.py +57 -0
  16. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  17. package/src/devcouncil/cli/commands/design.py +186 -0
  18. package/src/devcouncil/cli/commands/doctor.py +379 -22
  19. package/src/devcouncil/cli/commands/evidence.py +48 -0
  20. package/src/devcouncil/cli/commands/go.py +532 -33
  21. package/src/devcouncil/cli/commands/handoff.py +69 -0
  22. package/src/devcouncil/cli/commands/hook.py +296 -15
  23. package/src/devcouncil/cli/commands/init.py +161 -20
  24. package/src/devcouncil/cli/commands/integrate.py +1371 -124
  25. package/src/devcouncil/cli/commands/logs.py +106 -0
  26. package/src/devcouncil/cli/commands/map.py +80 -10
  27. package/src/devcouncil/cli/commands/okf.py +245 -0
  28. package/src/devcouncil/cli/commands/plan.py +256 -55
  29. package/src/devcouncil/cli/commands/prompt.py +18 -7
  30. package/src/devcouncil/cli/commands/repair.py +50 -24
  31. package/src/devcouncil/cli/commands/report.py +8 -0
  32. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  33. package/src/devcouncil/cli/commands/rollback.py +27 -28
  34. package/src/devcouncil/cli/commands/run.py +195 -54
  35. package/src/devcouncil/cli/commands/runs.py +223 -0
  36. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  37. package/src/devcouncil/cli/commands/semantic.py +47 -0
  38. package/src/devcouncil/cli/commands/setup.py +145 -6
  39. package/src/devcouncil/cli/commands/shell.py +73 -0
  40. package/src/devcouncil/cli/commands/skills.py +267 -0
  41. package/src/devcouncil/cli/commands/status.py +30 -15
  42. package/src/devcouncil/cli/commands/trace.py +47 -3
  43. package/src/devcouncil/cli/commands/verify.py +144 -3
  44. package/src/devcouncil/cli/commands/watch.py +32 -12
  45. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  46. package/src/devcouncil/cli/main.py +91 -7
  47. package/src/devcouncil/domain/evidence.py +29 -2
  48. package/src/devcouncil/domain/gap.py +27 -1
  49. package/src/devcouncil/domain/task.py +31 -2
  50. package/src/devcouncil/execution/checkpoints.py +256 -0
  51. package/src/devcouncil/execution/context_builder.py +1 -1
  52. package/src/devcouncil/execution/fs_watcher.py +205 -0
  53. package/src/devcouncil/execution/handoff.py +102 -0
  54. package/src/devcouncil/execution/hook_policy.py +162 -74
  55. package/src/devcouncil/execution/patch.py +65 -10
  56. package/src/devcouncil/execution/permissions.py +24 -24
  57. package/src/devcouncil/execution/policy_engine.py +350 -0
  58. package/src/devcouncil/execution/prompt_builder.py +751 -23
  59. package/src/devcouncil/execution/shell_session.py +231 -0
  60. package/src/devcouncil/execution/task_runner.py +24 -9
  61. package/src/devcouncil/executors/agent_registry.py +596 -0
  62. package/src/devcouncil/executors/coding_cli.py +791 -39
  63. package/src/devcouncil/executors/mini_swe.py +6 -0
  64. package/src/devcouncil/executors/native/agent.py +135 -19
  65. package/src/devcouncil/executors/openhands.py +6 -0
  66. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  67. package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
  68. package/src/devcouncil/gating/policy.py +190 -11
  69. package/src/devcouncil/hardware.py +184 -0
  70. package/src/devcouncil/indexing/ast_matcher.py +17 -7
  71. package/src/devcouncil/indexing/lsp.py +45 -4
  72. package/src/devcouncil/indexing/repo_mapper.py +1284 -15
  73. package/src/devcouncil/indexing/semantic_index.py +221 -0
  74. package/src/devcouncil/integrations/actions.py +166 -0
  75. package/src/devcouncil/integrations/check.py +426 -0
  76. package/src/devcouncil/integrations/claude_assets.py +444 -0
  77. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  78. package/src/devcouncil/integrations/github_intent.py +149 -0
  79. package/src/devcouncil/integrations/gitnexus.py +45 -2
  80. package/src/devcouncil/integrations/mcp/server.py +1944 -32
  81. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  82. package/src/devcouncil/integrations/pr_comments.py +9 -0
  83. package/src/devcouncil/knowledge/__init__.py +23 -0
  84. package/src/devcouncil/knowledge/design.py +374 -0
  85. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  86. package/src/devcouncil/knowledge/fetch.py +223 -0
  87. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  88. package/src/devcouncil/knowledge/okf.py +202 -0
  89. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  90. package/src/devcouncil/knowledge/sources.py +239 -0
  91. package/src/devcouncil/live/cards.py +181 -25
  92. package/src/devcouncil/live/repair_prompt.py +29 -6
  93. package/src/devcouncil/live/reviewer.py +72 -13
  94. package/src/devcouncil/live/signals.py +2 -2
  95. package/src/devcouncil/live/summary.py +18 -8
  96. package/src/devcouncil/live/transcripts.py +47 -11
  97. package/src/devcouncil/llm/cache.py +20 -8
  98. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  99. package/src/devcouncil/llm/provider.py +617 -49
  100. package/src/devcouncil/llm/router.py +337 -53
  101. package/src/devcouncil/optimization/__init__.py +1 -0
  102. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  103. package/src/devcouncil/optimization/skillopt.py +673 -0
  104. package/src/devcouncil/planning/arbiter_service.py +10 -2
  105. package/src/devcouncil/planning/correction_manifest.py +346 -0
  106. package/src/devcouncil/planning/critique_service.py +16 -4
  107. package/src/devcouncil/planning/plan_service.py +86 -6
  108. package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
  109. package/src/devcouncil/planning/repair_service.py +8 -2
  110. package/src/devcouncil/planning/spec_service.py +37 -3
  111. package/src/devcouncil/repo/ci_scaffold.py +165 -0
  112. package/src/devcouncil/repo/gitignore.py +123 -0
  113. package/src/devcouncil/repo/sca.py +384 -0
  114. package/src/devcouncil/reporting/json_report.py +22 -1
  115. package/src/devcouncil/reporting/markdown_report.py +29 -1
  116. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  117. package/src/devcouncil/reporting/okf_html.py +323 -0
  118. package/src/devcouncil/reporting/report_builder.py +18 -1
  119. package/src/devcouncil/skills/__init__.py +19 -0
  120. package/src/devcouncil/skills/library/README.md +46 -0
  121. package/src/devcouncil/skills/library/ai-training.md +50 -0
  122. package/src/devcouncil/skills/library/android.md +50 -0
  123. package/src/devcouncil/skills/library/backend.md +52 -0
  124. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  125. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  126. package/src/devcouncil/skills/library/desktop.md +46 -0
  127. package/src/devcouncil/skills/library/devops.md +48 -0
  128. package/src/devcouncil/skills/library/game-dev.md +46 -0
  129. package/src/devcouncil/skills/library/ios.md +48 -0
  130. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  131. package/src/devcouncil/skills/library/security.md +48 -0
  132. package/src/devcouncil/skills/library/systems.md +48 -0
  133. package/src/devcouncil/skills/library/web.md +47 -0
  134. package/src/devcouncil/skills/library/windows.md +47 -0
  135. package/src/devcouncil/skills/registry.py +408 -0
  136. package/src/devcouncil/storage/db.py +140 -3
  137. package/src/devcouncil/storage/models.py +125 -0
  138. package/src/devcouncil/storage/native.py +559 -0
  139. package/src/devcouncil/storage/repositories.py +157 -78
  140. package/src/devcouncil/telemetry/cost.py +123 -17
  141. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  142. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  143. package/src/devcouncil/telemetry/pricing.py +28 -0
  144. package/src/devcouncil/telemetry/stages.py +141 -0
  145. package/src/devcouncil/telemetry/traces.py +62 -7
  146. package/src/devcouncil/telemetry/tracker.py +24 -10
  147. package/src/devcouncil/ui/dashboard.py +393 -28
  148. package/src/devcouncil/utils/redaction.py +9 -3
  149. package/src/devcouncil/utils/subprocess_env.py +69 -0
  150. package/src/devcouncil/verification/acceptance_compiler.py +253 -0
  151. package/src/devcouncil/verification/ad_hoc_check.py +135 -0
  152. package/src/devcouncil/verification/diff_coverage.py +353 -0
  153. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  154. package/src/devcouncil/verification/next_actions.py +189 -0
  155. package/src/devcouncil/verification/sandbox.py +181 -0
  156. package/src/devcouncil/verification/test_resolver.py +91 -0
  157. package/src/devcouncil/verification/verifier.py +1549 -143
  158. package/uv.lock +205 -64
  159. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,9 +1,14 @@
1
+ import ast
2
+ import hashlib
1
3
  import json
2
4
  import logging
3
5
  import os
6
+ import re
4
7
  import subprocess
8
+ import sys
9
+ from collections import Counter, defaultdict
5
10
  from pathlib import Path
6
- from typing import List, Dict
11
+ from typing import Dict, List, Set, Tuple
7
12
 
8
13
  from pydantic import BaseModel, Field
9
14
 
@@ -11,6 +16,32 @@ from devcouncil.indexing.lsp import LspInspector
11
16
 
12
17
  logger = logging.getLogger(__name__)
13
18
 
19
+ # File extensions treated as primary source for subsystem inference.
20
+ _CODE_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".kt", ".rb", ".cs", ".cpp", ".c"}
21
+ # Top-level directories grouped as their own area rather than folded into a source root.
22
+ _AUX_AREA_ROOTS = {"tests", "test", "docs", "doc", "scripts", "examples", "example", "benchmarks"}
23
+ # Filenames that signal an entry point, used to break ties when no import data exists.
24
+ _ENTRY_NAME_HINTS = ("__init__", "__main__", "main", "index", "app", "cli", "server", "mod", "lib")
25
+
26
+
27
+ class RepoFileEntry(BaseModel):
28
+ path: str
29
+ area: str
30
+ kind: str
31
+ language: str | None = None
32
+ summary: str
33
+
34
+
35
+ class RepoSubsystem(BaseModel):
36
+ area: str
37
+ summary: str
38
+ entry_points: List[str]
39
+ critical_files: List[str]
40
+ neighbors: List[str] = Field(default_factory=list)
41
+ handoff_paths: List[str] = Field(default_factory=list)
42
+ role_files: Dict[str, List[str]] = Field(default_factory=dict)
43
+
44
+
14
45
  class RepoMap(BaseModel):
15
46
  languages: List[str]
16
47
  frameworks: List[str]
@@ -18,23 +49,1202 @@ class RepoMap(BaseModel):
18
49
  test_commands: List[str]
19
50
  important_files: List[str]
20
51
  candidate_files: List[Dict[str, str]]
52
+ files: List[RepoFileEntry] = Field(default_factory=list)
53
+ subsystems: List[RepoSubsystem] = Field(default_factory=list)
54
+ # file -> the files that import it (reverse import edges, capped per file). Lets a
55
+ # prompt show the blast radius of changing a file without re-parsing the repo.
56
+ dependents: Dict[str, List[str]] = Field(default_factory=dict)
57
+ # Freshness fingerprints captured at generation: the git HEAD the map was built from
58
+ # and a hash of the tracked file set. Consumers compare against the current repo to
59
+ # detect a stale map before trusting its structure.
60
+ generated_head: str = ""
61
+ indexed_hash: str = ""
21
62
  lsp: Dict[str, object] = Field(default_factory=dict)
63
+ # Optional dependency-vulnerability findings. Populated only when `dev map` is
64
+ # run with SCA explicitly enabled (off by default so the map stays fast and
65
+ # offline-by-default); empty otherwise.
66
+ dependency_risks: List[Dict[str, str]] = Field(default_factory=list)
22
67
 
23
68
  class RepoMapper:
24
69
  def __init__(self, project_root: Path):
25
70
  self.project_root = project_root
71
+ # Common source-root prefix of the repo's primary code (e.g. "src/pkg"),
72
+ # computed once per map_repo run. Drives generic, non-DevCouncil subsystem
73
+ # inference. None until computed.
74
+ self._source_root: str | None = None
75
+ # True when this is not the DevCouncil source tree, so generic inference is used
76
+ # for area bucketing. Set in map_repo.
77
+ self._use_generic: bool = False
78
+ # Import edges (importer -> imported), computed once per map_repo run and reused
79
+ # by subsystem inference, important-file ranking, and the dependents index.
80
+ self._edges: List[Tuple[str, str]] | None = None
81
+ # Cache of config-file contents (package.json, pyproject.toml, ...) so framework
82
+ # and test-command detection don't each re-read the same files from disk.
83
+ self._config_file_cache: Dict[str, str] = {}
84
+
85
+ def _read_config_file(self, name: str) -> str:
86
+ """Read a repo-root config file once and cache its contents for reuse."""
87
+ if name not in self._config_file_cache:
88
+ self._config_file_cache[name] = (self.project_root / name).read_text()
89
+ return self._config_file_cache[name]
90
+
91
+ _DEPENDENTS_MAX = 12 # cap dependents listed per file to bound repo_map.json size
92
+
93
+ _LANGUAGE_BY_EXTENSION = {
94
+ ".py": "python",
95
+ ".ts": "typescript",
96
+ ".tsx": "typescript",
97
+ ".js": "javascript",
98
+ ".jsx": "javascript",
99
+ ".go": "go",
100
+ ".rs": "rust",
101
+ ".java": "java",
102
+ ".c": "c",
103
+ ".cpp": "cpp",
104
+ ".md": "markdown",
105
+ ".yaml": "yaml",
106
+ ".yml": "yaml",
107
+ ".toml": "toml",
108
+ ".json": "json",
109
+ ".sh": "shell",
110
+ ".ps1": "powershell",
111
+ }
112
+
113
+ _AREA_SUMMARIES = {
114
+ "src/devcouncil/cli": "CLI entrypoints and command registration",
115
+ "src/devcouncil/app": "Orchestration runtime and lifecycle state",
116
+ "src/devcouncil/artifacts": "Artifact graph, coverage, and serialization",
117
+ "src/devcouncil/council": "Council prompts and debate scaffolding",
118
+ "src/devcouncil/domain": "Domain entities for requirements, tasks, and evidence",
119
+ "src/devcouncil/execution": "Execution plumbing, prompts, permissions, and task runs",
120
+ "src/devcouncil/executors": "Executor adapters and CLI agent registry",
121
+ "src/devcouncil/gating": "Blocking policies and guardrails",
122
+ "src/devcouncil/indexing": "Repo mapping, AST matching, semantic snapshots, and language-server detection",
123
+ "src/devcouncil/integrations": "External integrations and graph adapters",
124
+ "src/devcouncil/live": "Live review cards, signals, summaries, and transcripts",
125
+ "src/devcouncil/llm": "Model provider routing, defaults, and caching",
126
+ "src/devcouncil/planning": "Planning, critique, repair, and spec services",
127
+ "src/devcouncil/repo": "Repository helpers and filesystem utilities",
128
+ "src/devcouncil/reporting": "JSON and markdown report builders",
129
+ "src/devcouncil/storage": "SQLite persistence and repository layer",
130
+ "src/devcouncil/telemetry": "Trace logging, pricing, and telemetry tracking",
131
+ "src/devcouncil/ui": "Dashboard and lightweight UI helpers",
132
+ "src/devcouncil/utils": "Shared utilities and redaction helpers",
133
+ "src/devcouncil/verification": "Verification gates and implementation review",
134
+ "docs": "Repository documentation",
135
+ "tests": "Automated tests",
136
+ "scripts": "Maintenance and smoke-test scripts",
137
+ }
138
+
139
+ _SUBSYSTEM_INDEX: Dict[str, Tuple[str, List[str]]] = {
140
+ "src/devcouncil/council": (
141
+ "Prompt-driven council workflows and debate templates.",
142
+ [
143
+ "src/devcouncil/council/prompts/spec_writer.md",
144
+ "src/devcouncil/council/prompts/planner_a.md",
145
+ "src/devcouncil/council/prompts/implementation_reviewer.md",
146
+ ],
147
+ ),
148
+ "src/devcouncil/domain": (
149
+ "Shared domain entities for tasks, requirements, evidence, and critique.",
150
+ [
151
+ "src/devcouncil/domain/task.py",
152
+ "src/devcouncil/domain/requirement.py",
153
+ "src/devcouncil/domain/gap.py",
154
+ "src/devcouncil/domain/evidence.py",
155
+ ],
156
+ ),
157
+ "src/devcouncil/execution": (
158
+ "Execution planning and task orchestration runtime, including prompt and permission handling.",
159
+ [
160
+ "src/devcouncil/execution/task_runner.py",
161
+ "src/devcouncil/execution/prompt_builder.py",
162
+ "src/devcouncil/execution/permissions.py",
163
+ "src/devcouncil/execution/paths.py",
164
+ ],
165
+ ),
166
+ "src/devcouncil/executors": (
167
+ "Adapter layer that converts tasks into CLI/API side effects.",
168
+ [
169
+ "src/devcouncil/executors/agent_registry.py",
170
+ "src/devcouncil/executors/coding_cli.py",
171
+ "src/devcouncil/executors/mini_swe.py",
172
+ "src/devcouncil/executors/openhands.py",
173
+ ],
174
+ ),
175
+ "src/devcouncil/indexing": (
176
+ "Repo mapping, AST matching, semantic snapshots, and language-server detection (no live LSP client).",
177
+ [
178
+ "src/devcouncil/indexing/repo_mapper.py",
179
+ "src/devcouncil/indexing/ast_matcher.py",
180
+ "src/devcouncil/indexing/semantic_index.py",
181
+ "src/devcouncil/indexing/lsp.py",
182
+ ],
183
+ ),
184
+ "src/devcouncil/integrations": (
185
+ "External system integrations and MCP/Graph adapters.",
186
+ [
187
+ "src/devcouncil/integrations/gitnexus.py",
188
+ "src/devcouncil/integrations/graphify.py",
189
+ "src/devcouncil/integrations/mcp/server.py",
190
+ "src/devcouncil/integrations/github.py",
191
+ ],
192
+ ),
193
+ "src/devcouncil/verification": (
194
+ "Verification gates, evidence checks, and implementation review.",
195
+ [
196
+ "src/devcouncil/verification/verifier.py",
197
+ "src/devcouncil/verification/patch_reviewer.py",
198
+ "src/devcouncil/verification/review_agent.py",
199
+ ],
200
+ ),
201
+ "src/devcouncil/live": (
202
+ "Live review cards, signals, summaries, and repair guidance.",
203
+ [
204
+ "src/devcouncil/live/reviewer.py",
205
+ "src/devcouncil/live/cards.py",
206
+ "src/devcouncil/live/signals.py",
207
+ ],
208
+ ),
209
+ "src/devcouncil/llm": (
210
+ "Model routing, provider registry, and LLM response caching.",
211
+ [
212
+ "src/devcouncil/llm/router.py",
213
+ "src/devcouncil/llm/provider.py",
214
+ "src/devcouncil/llm/cache.py",
215
+ ],
216
+ ),
217
+ "src/devcouncil/planning": (
218
+ "Task planning, spec generation, critique, and arbitration services.",
219
+ [
220
+ "src/devcouncil/planning/plan_service.py",
221
+ "src/devcouncil/planning/spec_service.py",
222
+ "src/devcouncil/planning/critique_service.py",
223
+ "src/devcouncil/planning/repair_service.py",
224
+ ],
225
+ ),
226
+ "src/devcouncil/repo": (
227
+ "Repository helper helpers for workspace interactions.",
228
+ [
229
+ "src/devcouncil/repo/__init__.py",
230
+ ],
231
+ ),
232
+ "src/devcouncil/reporting": (
233
+ "Report generation and check-writing utilities.",
234
+ [
235
+ "src/devcouncil/reporting/report_builder.py",
236
+ "src/devcouncil/reporting/json_report.py",
237
+ "src/devcouncil/reporting/markdown_report.py",
238
+ ],
239
+ ),
240
+ "src/devcouncil/gating": (
241
+ "Policy gates and blocking criteria.",
242
+ [
243
+ "src/devcouncil/gating/gate.py",
244
+ "src/devcouncil/gating/policy.py",
245
+ "src/devcouncil/gating/rules.py",
246
+ ],
247
+ ),
248
+ "src/devcouncil/storage": (
249
+ "Persistence layer for run state, artifacts, and graph-backed history.",
250
+ [
251
+ "src/devcouncil/storage/repositories.py",
252
+ "src/devcouncil/storage/db.py",
253
+ "src/devcouncil/storage/models.py",
254
+ ],
255
+ ),
256
+ "src/devcouncil/cli": (
257
+ "User command surface and command wiring.",
258
+ [
259
+ "src/devcouncil/cli/main.py",
260
+ "src/devcouncil/cli/commands/map.py",
261
+ "src/devcouncil/cli/commands/plan.py",
262
+ "src/devcouncil/cli/commands/run.py",
263
+ ],
264
+ ),
265
+ "src/devcouncil/app": (
266
+ "Orchestrator and state machine controlling project lifecycle.",
267
+ [
268
+ "src/devcouncil/app/orchestrator.py",
269
+ "src/devcouncil/app/state_machine.py",
270
+ "src/devcouncil/app/run_context.py",
271
+ ],
272
+ ),
273
+ "src/devcouncil/artifacts": (
274
+ "Artifact graph primitives and evidence linking.",
275
+ [
276
+ "src/devcouncil/artifacts/graph.py",
277
+ "src/devcouncil/artifacts/exports.py",
278
+ "src/devcouncil/artifacts/types.py",
279
+ ],
280
+ ),
281
+ "src/devcouncil/telemetry": (
282
+ "Telemetry ingestion, tracing, cost, and pricing.",
283
+ [
284
+ "src/devcouncil/telemetry/traces.py",
285
+ "src/devcouncil/telemetry/tracker.py",
286
+ "src/devcouncil/telemetry/cost.py",
287
+ ],
288
+ ),
289
+ "src/devcouncil/ui": (
290
+ "Dashboard rendering and lightweight user interface glue.",
291
+ [
292
+ "src/devcouncil/ui/dashboard.py",
293
+ ],
294
+ ),
295
+ "src/devcouncil/utils": (
296
+ "Shared utility helpers, redaction, and support functions.",
297
+ [
298
+ "src/devcouncil/utils/redaction.py",
299
+ ],
300
+ ),
301
+ }
302
+
303
+ _SUBSYSTEM_CRITICAL_MAX = 6
304
+
305
+ _SUBSYSTEM_NEIGHBORS: Dict[str, List[str]] = {
306
+ "src/devcouncil/council": [
307
+ "src/devcouncil/planning",
308
+ "src/devcouncil/verification",
309
+ ],
310
+ "src/devcouncil/domain": [
311
+ "src/devcouncil/execution",
312
+ "src/devcouncil/executors",
313
+ "src/devcouncil/storage",
314
+ "src/devcouncil/verification",
315
+ "src/devcouncil/planning",
316
+ "src/devcouncil/gating",
317
+ ],
318
+ "src/devcouncil/execution": [
319
+ "src/devcouncil/executors",
320
+ "src/devcouncil/gating",
321
+ "src/devcouncil/verification",
322
+ "src/devcouncil/storage",
323
+ ],
324
+ "src/devcouncil/executors": [
325
+ "src/devcouncil/execution",
326
+ "src/devcouncil/app",
327
+ "src/devcouncil/storage",
328
+ ],
329
+ "src/devcouncil/verification": [
330
+ "src/devcouncil/storage",
331
+ "src/devcouncil/gating",
332
+ "src/devcouncil/app",
333
+ ],
334
+ "src/devcouncil/gating": [
335
+ "src/devcouncil/execution",
336
+ "src/devcouncil/verification",
337
+ "src/devcouncil/storage",
338
+ ],
339
+ "src/devcouncil/storage": [
340
+ "src/devcouncil/app",
341
+ "src/devcouncil/artifacts",
342
+ "src/devcouncil/verification",
343
+ ],
344
+ "src/devcouncil/cli": [
345
+ "src/devcouncil/app",
346
+ "src/devcouncil/storage",
347
+ "src/devcouncil/indexing",
348
+ ],
349
+ "src/devcouncil/app": [
350
+ "src/devcouncil/cli",
351
+ "src/devcouncil/execution",
352
+ "src/devcouncil/storage",
353
+ "src/devcouncil/verification",
354
+ ],
355
+ "src/devcouncil/artifacts": [
356
+ "src/devcouncil/storage",
357
+ "src/devcouncil/verification",
358
+ ],
359
+ "src/devcouncil/indexing": [
360
+ "src/devcouncil/llm",
361
+ "src/devcouncil/execution",
362
+ "src/devcouncil/cli",
363
+ ],
364
+ "src/devcouncil/integrations": [
365
+ "src/devcouncil/cli",
366
+ "src/devcouncil/live",
367
+ "src/devcouncil/reporting",
368
+ "src/devcouncil/telemetry",
369
+ ],
370
+ "src/devcouncil/live": [
371
+ "src/devcouncil/verification",
372
+ "src/devcouncil/telemetry",
373
+ "src/devcouncil/reporting",
374
+ "src/devcouncil/cli",
375
+ ],
376
+ "src/devcouncil/llm": [
377
+ "src/devcouncil/planning",
378
+ "src/devcouncil/execution",
379
+ "src/devcouncil/verification",
380
+ "src/devcouncil/app",
381
+ ],
382
+ "src/devcouncil/planning": [
383
+ "src/devcouncil/domain",
384
+ "src/devcouncil/llm",
385
+ "src/devcouncil/cli",
386
+ "src/devcouncil/execution",
387
+ ],
388
+ "src/devcouncil/repo": [
389
+ "src/devcouncil/cli",
390
+ ],
391
+ "src/devcouncil/reporting": [
392
+ "src/devcouncil/telemetry",
393
+ "src/devcouncil/integrations",
394
+ "src/devcouncil/cli",
395
+ "src/devcouncil/live",
396
+ ],
397
+ "src/devcouncil/telemetry": [
398
+ "src/devcouncil/cli",
399
+ "src/devcouncil/app",
400
+ "src/devcouncil/execution",
401
+ "src/devcouncil/verification",
402
+ "src/devcouncil/llm",
403
+ ],
404
+ "src/devcouncil/ui": [
405
+ "src/devcouncil/telemetry",
406
+ ],
407
+ "src/devcouncil/utils": [
408
+ "src/devcouncil/execution",
409
+ "src/devcouncil/cli",
410
+ "src/devcouncil/verification",
411
+ "src/devcouncil/executors",
412
+ "src/devcouncil/llm",
413
+ ],
414
+ }
415
+
416
+ _SUBSYSTEM_HANDOFFS: Dict[str, List[str]] = {
417
+ "src/devcouncil/council": [
418
+ "planning/arbiter_service.py -> planning/plan_service.py",
419
+ "planning/spec_service.py -> planning/plan_service.py",
420
+ ],
421
+ "src/devcouncil/domain": [
422
+ "domain/task.py -> execution/task_runner.py",
423
+ "domain/evidence.py -> artifacts/graph.py",
424
+ "domain/requirement.py -> verification/verifier.py",
425
+ ],
426
+ "src/devcouncil/execution": [
427
+ "execution/task_runner.py -> executors/*",
428
+ "execution/task_runner.py -> verification/verifier.py",
429
+ "execution/task_runner.py -> storage/repositories.py",
430
+ ],
431
+ "src/devcouncil/executors": [
432
+ "executors/* -> execution/task_runner.py",
433
+ "executors/* -> storage/repositories.py",
434
+ ],
435
+ "src/devcouncil/verification": [
436
+ "verification/verifier.py -> storage/repositories.py",
437
+ "verification/verifier.py -> gating/policy.py",
438
+ "verification/verifier.py -> artifacts/graph.py",
439
+ ],
440
+ "src/devcouncil/gating": [
441
+ "gating/policy.py -> execution/permissions.py",
442
+ "gating/policy.py -> verification/verifier.py",
443
+ ],
444
+ "src/devcouncil/storage": [
445
+ "storage/repositories.py -> app/state_machine.py",
446
+ "storage/repositories.py -> artifacts/graph.py",
447
+ ],
448
+ "src/devcouncil/indexing": [
449
+ "indexing/repo_mapper.py -> cli/commands/map.py",
450
+ "indexing/lsp.py -> execution/task_runner.py",
451
+ ],
452
+ "src/devcouncil/integrations": [
453
+ "integrations/mcp/server.py -> live/reviewer.py",
454
+ "integrations/code_review_graph.py -> live/cards.py",
455
+ "integrations/gitnexus.py -> reporting/report_builder.py",
456
+ ],
457
+ "src/devcouncil/live": [
458
+ "live/summary.py -> live/cards.py",
459
+ "live/reviewer.py -> live/models.py",
460
+ "live/tasks.py -> live/signals.py",
461
+ ],
462
+ "src/devcouncil/llm": [
463
+ "llm/router.py -> telemetry/tracker.py",
464
+ "llm/router.py -> telemetry/traces.py",
465
+ "llm/provider.py -> llm/router.py",
466
+ ],
467
+ "src/devcouncil/planning": [
468
+ "planning/plan_service.py -> execution/task_runner.py",
469
+ "planning/repair_service.py -> verification/implementation_reviewer.py",
470
+ "planning/arbiter_service.py -> verification/verifier.py",
471
+ ],
472
+ "src/devcouncil/repo": [
473
+ "repo/__init__.py -> cli/commands/map.py",
474
+ ],
475
+ "src/devcouncil/reporting": [
476
+ "reporting/report_builder.py -> reporting/markdown_report.py",
477
+ "reporting/report_builder.py -> reporting/json_report.py",
478
+ "reporting/github_check.py -> integrations/pr_comments.py",
479
+ ],
480
+ "src/devcouncil/telemetry": [
481
+ "telemetry/traces.py -> live/summary.py",
482
+ "telemetry/tracker.py -> reporting/markdown_report.py",
483
+ ],
484
+ "src/devcouncil/ui": [
485
+ "ui/dashboard.py -> live/summary.py",
486
+ ],
487
+ }
488
+
489
+ _SUBSYSTEM_ROLE_FILES: Dict[str, List[Tuple[str, List[str]]]] = {
490
+ "src/devcouncil/council": [
491
+ ("prompts", ["council/prompts/spec_writer.md", "council/prompts/rebuttal.md", "council/prompts/implementation_reviewer.md"]),
492
+ ("planners", ["council/prompts/planner_a.md", "council/prompts/planner_b.md"]),
493
+ ("critics", ["council/prompts/critic_a.md", "council/prompts/critic_b.md"]),
494
+ ("arbitration", ["council/prompts/arbiter.md"]),
495
+ ],
496
+ "src/devcouncil/domain": [
497
+ ("tasks", ["domain/task.py"]),
498
+ ("requirements", ["domain/requirement.py"]),
499
+ ("evidence", ["domain/evidence.py"]),
500
+ ("gaps", ["domain/gap.py"]),
501
+ ("critiques", ["domain/critique.py"]),
502
+ ("assumptions", ["domain/assumption.py"]),
503
+ ],
504
+ "src/devcouncil/indexing": [
505
+ ("mapping", ["indexing/repo_mapper.py"]),
506
+ ("ast", ["indexing/ast_matcher.py"]),
507
+ ("semantic", ["indexing/semantic_index.py"]),
508
+ # Detection-only LSP helper (no live client); see lsp.py docstring.
509
+ ("lsp", ["indexing/lsp.py"]),
510
+ # GraphIndex is consumed by integrations/gitnexus.py — kept, not dead.
511
+ ("graph", ["indexing/graph_index.py"]),
512
+ ],
513
+ "src/devcouncil/integrations": [
514
+ ("vcs", ["integrations/github.py"]),
515
+ ("graphify", ["integrations/graphify.py"]),
516
+ ("code_review", ["integrations/code_review_graph.py"]),
517
+ ("comments", ["integrations/pr_comments.py"]),
518
+ ("mcp", ["integrations/mcp/server.py"]),
519
+ ("third_party", ["integrations/gitnexus.py"]),
520
+ ],
521
+ "src/devcouncil/cli": [
522
+ ("entrypoints", ["cli/main.py"]),
523
+ ("commands", ["cli/commands/map.py", "cli/commands/plan.py", "cli/commands/run.py", "cli/commands/verify.py"]),
524
+ ("setup", ["cli/commands/init.py", "cli/commands/setup.py", "cli/commands/integrate.py"]),
525
+ ("lifecycle", ["cli/commands/status.py", "cli/commands/show.py", "cli/commands/watch.py"]),
526
+ ("maintenance", ["cli/commands/doctor.py", "cli/commands/version.py", "cli/commands/config.py"]),
527
+ ],
528
+ "src/devcouncil/app": [
529
+ ("orchestration", ["app/orchestrator.py"]),
530
+ ("state", ["app/state_machine.py", "app/run_context.py"]),
531
+ ("events", ["app/events.py"]),
532
+ ("configuration", ["app/config.py", "app/errors.py", "app/project_status.py"]),
533
+ ],
534
+ "src/devcouncil/artifacts": [
535
+ ("graph", ["artifacts/graph.py"]),
536
+ ("coverage", ["artifacts/coverage.py", "artifacts/serializer.py"]),
537
+ ("schema", ["artifacts/schemas.py", "artifacts/migrations.py"]),
538
+ ("validation", ["artifacts/validators.py"]),
539
+ ],
540
+ "src/devcouncil/executors": [
541
+ ("registry", ["executors/agent_registry.py"]),
542
+ ("adapters", ["executors/coding_cli.py", "executors/openhands.py", "executors/mini_swe.py"]),
543
+ ("native", ["executors/native/agent.py"]),
544
+ ],
545
+ "src/devcouncil/execution": [
546
+ ("runtime", ["execution/task_runner.py", "execution/context_builder.py"]),
547
+ ("prompting", ["execution/prompt_builder.py"]),
548
+ ("permissions", ["execution/permissions.py"]),
549
+ ("patching", ["execution/patch.py", "execution/executor.py"]),
550
+ ("paths", ["execution/paths.py"]),
551
+ ],
552
+ "src/devcouncil/gating": [
553
+ ("policy", ["gating/policy.py"]),
554
+ ("checks", ["gating/checks/clean_git.py", "gating/checks/planned_files_check.py"]),
555
+ ("coverage", ["gating/checks/requirement_coverage.py", "gating/checks/secret_scan_check.py"]),
556
+ ],
557
+ "src/devcouncil/live": [
558
+ ("cards", ["live/cards.py"]),
559
+ ("review", ["live/reviewer.py"]),
560
+ ("signals", ["live/signals.py"]),
561
+ ("sessions", ["live/tasks.py", "live/transcripts.py"]),
562
+ ("summaries", ["live/summary.py"]),
563
+ ("models", ["live/models.py"]),
564
+ ],
565
+ "src/devcouncil/llm": [
566
+ ("routing", ["llm/router.py"]),
567
+ ("providers", ["llm/provider.py"]),
568
+ ("cache", ["llm/cache.py"]),
569
+ ("defaults", ["llm/model_defaults.yaml"]),
570
+ ],
571
+ "src/devcouncil/planning": [
572
+ ("plan", ["planning/plan_service.py", "planning/prompt_enhancer_service.py"]),
573
+ ("spec", ["planning/spec_service.py"]),
574
+ ("critique", ["planning/critique_service.py"]),
575
+ ("repair", ["planning/repair_service.py"]),
576
+ ("arbiter", ["planning/arbiter_service.py"]),
577
+ ],
578
+ "src/devcouncil/repo": [
579
+ ("api", ["repo/__init__.py"]),
580
+ ],
581
+ "src/devcouncil/reporting": [
582
+ ("builder", ["reporting/report_builder.py"]),
583
+ ("markdown", ["reporting/markdown_report.py"]),
584
+ ("json", ["reporting/json_report.py"]),
585
+ ("checks", ["reporting/github_check.py"]),
586
+ ],
587
+ "src/devcouncil/verification": [
588
+ ("gates", ["verification/verifier.py", "verification/implementation_reviewer.py"]),
589
+ ("implementation_reviewer", ["verification/implementation_reviewer.py"]),
590
+ ("policy", ["verification/verifier.py"]),
591
+ ],
592
+ "src/devcouncil/telemetry": [
593
+ ("traces", ["telemetry/traces.py"]),
594
+ ("tracker", ["telemetry/tracker.py"]),
595
+ ("cost", ["telemetry/cost.py"]),
596
+ ("pricing", ["telemetry/pricing.py", "telemetry/model_pricing.yaml"]),
597
+ ],
598
+ "src/devcouncil/storage": [
599
+ ("repositories", ["storage/repositories.py"]),
600
+ ("schema", ["storage/models.py"]),
601
+ ("database", ["storage/db.py"]),
602
+ ],
603
+ "src/devcouncil/ui": [
604
+ ("dashboard", ["ui/dashboard.py"]),
605
+ ],
606
+ "src/devcouncil/utils": [
607
+ ("redaction", ["utils/redaction.py"]),
608
+ ],
609
+ }
610
+
611
+ _COMMAND_SUMMARIES = {
612
+ "agents": "CLI agent registry and integration commands",
613
+ "artifacts": "Artifact graph inspection commands",
614
+ "ast": "AST matching and symbol discovery commands",
615
+ "baseline": "Capture or inspect a baseline snapshot",
616
+ "config": "Inspect or mutate project configuration",
617
+ "dashboard": "Dashboard launch command",
618
+ "doctor": "Preflight and environment diagnostics",
619
+ "go": "End-to-end task execution alias",
620
+ "hook": "Hook configuration commands",
621
+ "init": "Project initialization and integration bootstrap",
622
+ "integrate": "Coding CLI and MCP integration setup",
623
+ "lsp": "LSP inspection commands",
624
+ "map": "Repository mapping command",
625
+ "mcp_server": "MCP server command",
626
+ "plan": "Planning workflow command",
627
+ "prompt": "Prompt generation for agent handoff",
628
+ "repair": "Repair prompt generation",
629
+ "report": "Task and project reporting commands",
630
+ "reset_demo_state": "Reset demo state and sample data",
631
+ "rollback": "Rollback workflow command",
632
+ "run": "Execute an approved task",
633
+ "setup": "Interactive project setup command",
634
+ "show": "Show current project state",
635
+ "status": "Compact workflow status command",
636
+ "tasks": "Task graph and task listing commands",
637
+ "trace": "Trace inspection commands",
638
+ "verify": "Verification workflow command",
639
+ "version": "Version display command",
640
+ "watch": "Live review and transcript monitoring",
641
+ }
642
+
643
+ _DOC_SUMMARIES = {
644
+ "AGENTS.md": "Workspace guide for coding agents",
645
+ "CLAUDE.md": "Workspace guide for Claude-based agents",
646
+ "README.md": "Project overview and usage entrypoint",
647
+ "architecture.md": "Top-level architecture overview",
648
+ "cli-reference.md": "CLI command reference",
649
+ "quickstart.md": "First-run installation and workflow",
650
+ "workflow.md": "Manual sidecar workflow guide",
651
+ "security.md": "Security and privacy model",
652
+ "project-status.md": "Subsystem maturity snapshot",
653
+ "roadmap.md": "Planned work and roadmap",
654
+ }
655
+
656
+ def _language_for_file(self, path: str) -> str | None:
657
+ suffix = Path(path).suffix.lower()
658
+ return self._LANGUAGE_BY_EXTENSION.get(suffix)
659
+
660
+ def _kind_for_file(self, path: str) -> str:
661
+ normalized = path.replace("\\", "/")
662
+ suffix = Path(normalized).suffix.lower()
663
+ name = Path(normalized).name
664
+ if normalized.startswith("tests/") or name.startswith("test_"):
665
+ return "test"
666
+ if normalized.startswith("docs/") or suffix == ".md":
667
+ return "doc"
668
+ if suffix in {".yaml", ".yml", ".toml", ".json", ".ini"}:
669
+ return "config"
670
+ if suffix in {".sh", ".ps1", ".bat"}:
671
+ return "script"
672
+ if suffix in {".sqlite", ".db"}:
673
+ return "database"
674
+ if suffix in {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".c", ".cpp"}:
675
+ return "module" if name != "__init__.py" else "package"
676
+ return "file"
677
+
678
+ def _summary_for_file(self, path: str) -> str:
679
+ normalized = path.replace("\\", "/")
680
+ name = Path(normalized).name
681
+ parts = normalized.split("/")
682
+ if normalized == "README.md":
683
+ return self._DOC_SUMMARIES["README.md"]
684
+ if normalized.startswith("docs/"):
685
+ stem = Path(name).stem.replace("-", " ")
686
+ return self._DOC_SUMMARIES.get(name, f"Documentation: {stem}")
687
+ if normalized.startswith("tests/"):
688
+ remainder = normalized.removeprefix("tests/")
689
+ if remainder.startswith("unit/"):
690
+ return f"Unit tests for {Path(remainder).stem.replace('test_', '').replace('_', ' ').strip() or 'the package'}"
691
+ return f"Tests for {Path(remainder).stem.replace('test_', '').replace('_', ' ').strip() or 'the package'}"
692
+ if normalized.startswith("src/devcouncil/cli/commands/"):
693
+ stem = Path(name).stem
694
+ return self._COMMAND_SUMMARIES.get(stem, f"CLI command module: {stem}")
695
+ if normalized == "src/devcouncil/cli/main.py":
696
+ return "Typer root command composition"
697
+ if normalized == "src/devcouncil/app/orchestrator.py":
698
+ return "Orchestration coordinator and run lifecycle"
699
+ if normalized == "src/devcouncil/app/state_machine.py":
700
+ return "Allowed project phase transitions"
701
+ if normalized == "src/devcouncil/artifacts/graph.py":
702
+ return "Artifact graph and coverage queries"
703
+ if normalized == "src/devcouncil/indexing/repo_mapper.py":
704
+ return "Repository mapping and file classification"
705
+ if normalized == "src/devcouncil/storage/repositories.py":
706
+ return "Persistence repositories for state and artifacts"
707
+ if normalized == "src/devcouncil/storage/models.py":
708
+ return "SQLModel database schema"
709
+ if normalized == "src/devcouncil/verification/verifier.py":
710
+ return "Verification gates and evidence checks"
711
+ if normalized == "src/devcouncil/planning/plan_service.py":
712
+ return "Plan generation service"
713
+ if normalized == "src/devcouncil/planning/spec_service.py":
714
+ return "Spec generation service"
715
+ if normalized == "src/devcouncil/planning/critique_service.py":
716
+ return "Plan critique service"
717
+ if normalized == "src/devcouncil/planning/repair_service.py":
718
+ return "Repair workflow service"
719
+ if normalized == "src/devcouncil/planning/arbiter_service.py":
720
+ return "Plan arbitration service"
721
+ if normalized == "src/devcouncil/execution/task_runner.py":
722
+ return "Task execution runner"
723
+ if normalized == "src/devcouncil/execution/prompt_builder.py":
724
+ return "Prompt assembly for executors"
725
+ if normalized == "src/devcouncil/execution/permissions.py":
726
+ return "Execution permission policy"
727
+ if normalized == "src/devcouncil/executors/agent_registry.py":
728
+ return "Built-in and configured CLI agent registry"
729
+ if normalized == "src/devcouncil/llm/router.py":
730
+ return "LLM provider routing"
731
+ if normalized == "src/devcouncil/telemetry/traces.py":
732
+ return "Trace logging and event persistence"
733
+ if normalized == "src/devcouncil/live/reviewer.py":
734
+ return "Live review service"
735
+ if normalized == "src/devcouncil/integrations/gitnexus.py":
736
+ return "GitNexus integration shim"
737
+ if normalized == "src/devcouncil/integrations/graphify.py":
738
+ return "Graphify integration shim"
739
+ if normalized.startswith("src/devcouncil/"):
740
+ area = "/".join(parts[:3]) if len(parts) >= 3 else "src/devcouncil"
741
+ return self._AREA_SUMMARIES.get(area, f"{area} subsystem")
742
+ if normalized.startswith("scripts/"):
743
+ return f"Utility script: {name}"
744
+ if name in self._DOC_SUMMARIES:
745
+ return self._DOC_SUMMARIES[name]
746
+ return Path(name).stem.replace("_", " ")
747
+
748
+ def _area_for_file(self, path: str) -> str:
749
+ normalized = path.replace("\\", "/")
750
+ if normalized.startswith("src/devcouncil/"):
751
+ parts = normalized.split("/")
752
+ if len(parts) >= 5 and parts[2] == "cli" and parts[3] == "commands":
753
+ return "src/devcouncil/cli/commands"
754
+ if len(parts) >= 4:
755
+ return "/".join(parts[:3])
756
+ return "src/devcouncil"
757
+ if normalized.startswith("tests/"):
758
+ return "tests"
759
+ if normalized.startswith("docs/"):
760
+ return "docs"
761
+ if normalized.startswith("scripts/"):
762
+ return "scripts"
763
+ # Foreign repos: derive the area from the directory tree. Gated on _use_generic
764
+ # so DevCouncil's own map keeps its existing "root" bucketing.
765
+ if self._use_generic:
766
+ return self._generic_area_for_file(normalized, self._source_root or "")
767
+ return "root"
768
+
769
+ def _build_subsystem_index(self, files: List[str]) -> List[RepoSubsystem]:
770
+ # The hardcoded index is authoritative for DevCouncil's own tree (preserves
771
+ # its curated summaries/role buckets). For any other repo it matches nothing,
772
+ # so fall back to generic, import-graph-driven inference.
773
+ hardcoded = self._build_hardcoded_subsystems(files)
774
+ if hardcoded:
775
+ return hardcoded
776
+ return self._build_generic_subsystems(files)
777
+
778
+ def _build_hardcoded_subsystems(self, files: List[str]) -> List[RepoSubsystem]:
779
+ file_set = set(files)
780
+ # Single O(n) pass: bucket files by their "src/devcouncil/<area>" prefix so the
781
+ # per-subsystem loop below uses O(1) dict lookups instead of rescanning every
782
+ # file for each area (and, previously, again for each neighbor). All subsystem
783
+ # and neighbor keys are 3-component "src/devcouncil/<area>" prefixes, so this
784
+ # bucketing reproduces the prior `path.startswith(f"{area}/")` semantics exactly.
785
+ by_area: Dict[str, List[str]] = {}
786
+ for path in files:
787
+ parts = path.split("/")
788
+ if len(parts) >= 4 and parts[0] == "src" and parts[1] == "devcouncil":
789
+ by_area.setdefault("/".join(parts[:3]), []).append(path)
790
+ for bucket in by_area.values():
791
+ bucket.sort()
792
+ subsystems: List[RepoSubsystem] = []
793
+ for area, (summary, entry_points) in self._SUBSYSTEM_INDEX.items():
794
+ available_entry_points = [path for path in entry_points if path in file_set]
795
+ if not available_entry_points:
796
+ continue
797
+ area_files = by_area.get(area, [])
798
+ ranked_files = [path for path in available_entry_points if path in file_set]
799
+ for path in area_files:
800
+ if path in available_entry_points:
801
+ continue
802
+ if len(ranked_files) >= self._SUBSYSTEM_CRITICAL_MAX:
803
+ break
804
+ ranked_files.append(path)
805
+ critical_files = ranked_files[: self._SUBSYSTEM_CRITICAL_MAX]
806
+ neighbors = [n for n in self._SUBSYSTEM_NEIGHBORS.get(area, []) if n in by_area]
807
+ handoff_paths = self._SUBSYSTEM_HANDOFFS.get(area, [])
808
+ role_files = self._build_role_files(area, area_files)
809
+ subsystems.append(
810
+ RepoSubsystem(
811
+ area=area,
812
+ summary=summary,
813
+ entry_points=available_entry_points,
814
+ critical_files=critical_files,
815
+ neighbors=neighbors,
816
+ handoff_paths=handoff_paths,
817
+ role_files=role_files,
818
+ )
819
+ )
820
+ return subsystems
821
+
822
+ def _build_role_files(self, area: str, area_files: List[str]) -> Dict[str, List[str]]:
823
+ role_specs = self._SUBSYSTEM_ROLE_FILES.get(area)
824
+ if not role_specs:
825
+ return {}
826
+
827
+ by_role: Dict[str, List[str]] = {}
828
+ used = set()
829
+ for role, tokens in role_specs:
830
+ matches = [path for path in area_files if any(token in path for token in tokens)]
831
+ if not matches:
832
+ continue
833
+ selected = matches[:4]
834
+ by_role[role] = selected
835
+ used.update(selected)
836
+
837
+ if not by_role:
838
+ return {}
839
+
840
+ leftovers = [path for path in area_files if path not in used][:4]
841
+ if leftovers:
842
+ by_role.setdefault("other", leftovers)
843
+
844
+ return by_role
845
+
846
+ # ------------------------------------------------------------------
847
+ # Generic (non-DevCouncil) subsystem inference
848
+ # ------------------------------------------------------------------
849
+
850
+ def _code_files(self, files: List[str]) -> List[str]:
851
+ return [f for f in files if Path(f).suffix.lower() in _CODE_EXTENSIONS]
852
+
853
+ def _primary_code_files(self, files: List[str]) -> List[str]:
854
+ """Code files excluding tests/docs/scripts — the ones that define the repo's
855
+ real structure and so determine the source root."""
856
+ primary: List[str] = []
857
+ for f in self._code_files(files):
858
+ top = f.replace("\\", "/").split("/")[0]
859
+ name = Path(f).name
860
+ if top in _AUX_AREA_ROOTS or name.startswith("test_") or name.endswith("_test.go"):
861
+ continue
862
+ primary.append(f)
863
+ return primary
864
+
865
+ def detect_source_root(self, files: List[str]) -> str:
866
+ """Longest common directory prefix shared by the primary source files
867
+ (e.g. ``src/mypkg``). Empty when the code spans unrelated top-level dirs."""
868
+ dirs = [Path(f).parent.as_posix() for f in self._primary_code_files(files)]
869
+ dirs = [d for d in dirs if d not in ("", ".")]
870
+ if not dirs:
871
+ return ""
872
+ split = [d.split("/") for d in dirs]
873
+ common = split[0]
874
+ for parts in split[1:]:
875
+ limit = min(len(common), len(parts))
876
+ i = 0
877
+ while i < limit and common[i] == parts[i]:
878
+ i += 1
879
+ common = common[:i]
880
+ if not common:
881
+ break
882
+ return "/".join(common)
883
+
884
+ def _generic_area_for_file(self, path: str, source_root: str) -> str:
885
+ normalized = path.replace("\\", "/")
886
+ parts = normalized.split("/")
887
+ if parts[0] in _AUX_AREA_ROOTS:
888
+ return parts[0]
889
+ if source_root and (normalized == source_root or normalized.startswith(f"{source_root}/")):
890
+ rest = normalized[len(source_root):].lstrip("/").split("/")
891
+ if len(rest) >= 2:
892
+ return f"{source_root}/{rest[0]}"
893
+ return source_root or "root"
894
+ if len(parts) >= 2:
895
+ return parts[0]
896
+ return "root"
897
+
898
+ def _module_suffix_index(self, py_files: List[str]) -> Dict[str, str]:
899
+ """Map every dotted suffix of each module's path to its file, so an import
900
+ statement's module string resolves to a repo file. Ambiguous suffixes (shared
901
+ by two files) are dropped to avoid mislinking. Packages (``__init__.py``) are
902
+ also indexed under their package dotted path."""
903
+ index: Dict[str, str] = {}
904
+ ambiguous: Set[str] = set()
905
+
906
+ def _register(dotted: str, file: str) -> None:
907
+ comps = [c for c in dotted.split(".") if c]
908
+ for i in range(len(comps)):
909
+ suffix = ".".join(comps[i:])
910
+ if not suffix:
911
+ continue
912
+ if suffix in index and index[suffix] != file:
913
+ ambiguous.add(suffix)
914
+ else:
915
+ index[suffix] = file
916
+
917
+ for f in py_files:
918
+ module_path = f[:-3] if f.endswith(".py") else f
919
+ if module_path.endswith("/__init__"):
920
+ # Package import resolves to the __init__ file under the dir's name.
921
+ _register(module_path[: -len("/__init__")].replace("/", "."), f)
922
+ else:
923
+ _register(module_path.replace("/", "."), f)
924
+ for suffix in ambiguous:
925
+ index.pop(suffix, None)
926
+ return index
927
+
928
+ def _resolve_module(self, module: str, index: Dict[str, str]) -> str | None:
929
+ comps = [c for c in module.split(".") if c]
930
+ # An absolute import of a stdlib module is never a repo file — don't let a
931
+ # repo file whose stem happens to equal a stdlib name (e.g. a local json.py)
932
+ # create a false edge for `import json`.
933
+ if comps and comps[0] in sys.stdlib_module_names:
934
+ return None
935
+ while comps:
936
+ candidate = ".".join(comps)
937
+ if candidate in index:
938
+ return index[candidate]
939
+ comps = comps[:-1] # `from pkg.mod import name` -> try pkg.mod, then pkg
940
+ return None
941
+
942
+ def _python_import_edges(self, files: List[str]) -> List[Tuple[str, str]]:
943
+ """Resolve Python import statements into (importer, imported) file edges."""
944
+ py_files = [f for f in self._code_files(files) if f.endswith(".py")]
945
+ if not py_files:
946
+ return []
947
+ index = self._module_suffix_index(py_files)
948
+ edges: List[Tuple[str, str]] = []
949
+ seen: Set[Tuple[str, str]] = set() # dedupe so in-degree isn't inflated by repeats
950
+ for rel in py_files:
951
+ try:
952
+ source = (self.project_root / rel).read_text(encoding="utf-8", errors="replace")
953
+ tree = ast.parse(source)
954
+ except (OSError, SyntaxError, ValueError):
955
+ continue
956
+ pkg_parts = rel[:-3].replace("/", ".").split(".") # importer's dotted path
957
+ for node in ast.walk(tree):
958
+ modules: List[str] = []
959
+ if isinstance(node, ast.Import):
960
+ modules = [alias.name for alias in node.names]
961
+ elif isinstance(node, ast.ImportFrom):
962
+ if node.level:
963
+ base = pkg_parts[: -node.level] if node.level <= len(pkg_parts) else []
964
+ base_mod = ".".join(base + ([node.module] if node.module else []))
965
+ else:
966
+ base_mod = node.module or ""
967
+ if base_mod:
968
+ modules.append(base_mod)
969
+ # `from pkg import sub` / `from . import sub` may import a SUBMODULE
970
+ # file, not just a symbol — resolve each name as a candidate module so
971
+ # those edges aren't silently dropped.
972
+ for alias in node.names:
973
+ if alias.name and alias.name != "*":
974
+ modules.append(f"{base_mod}.{alias.name}" if base_mod else alias.name)
975
+ for module in modules:
976
+ target = self._resolve_module(module, index)
977
+ if target and target != rel and (rel, target) not in seen:
978
+ seen.add((rel, target))
979
+ edges.append((rel, target))
980
+ return edges
981
+
982
+ # Module specifiers in import/require statements: import ... from "x"; require("x");
983
+ # export ... from "x"; dynamic import("x"). Best-effort; only relative specs resolve.
984
+ _JS_IMPORT_RE = re.compile(
985
+ r"""(?:import|export)\s[^'"]*?from\s*['"](?P<spec>[^'"]+)['"]"""
986
+ r"""|(?:require|import)\s*\(\s*['"](?P<spec2>[^'"]+)['"]\s*\)"""
987
+ )
988
+ _JS_BARE_IMPORT_RE = re.compile(r"""^\s*import\s*['"](?P<spec>[^'"]+)['"]""")
989
+ _JS_RESOLVE_EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
990
+ _GO_IMPORT_BLOCK_RE = re.compile(r"import\s*\((?P<body>[^)]*)\)", re.DOTALL)
991
+ _GO_IMPORT_SINGLE_RE = re.compile(r"""^\s*import\s+(?:[A-Za-z_.]\w*\s+)?['"](?P<spec>[^'"]+)['"]""")
992
+ _GO_IMPORT_SPEC_RE = re.compile(r"""['"](?P<spec>[^'"]+)['"]""")
993
+ _GO_MODULE_RE = re.compile(r"^\s*module\s+(?P<mod>\S+)", re.MULTILINE)
994
+
995
+ def _resolve_js_spec(self, importer: str, spec: str, file_set: Set[str]) -> str | None:
996
+ """Resolve a relative TS/JS import specifier (``./x`` / ``../y``) to a repo file.
997
+ Bare specifiers (node_modules packages) are intentionally not resolved."""
998
+ if not spec.startswith("."):
999
+ return None
1000
+ base = Path(importer).parent
1001
+ try:
1002
+ target = (base / spec).as_posix()
1003
+ except Exception:
1004
+ return None
1005
+ # Normalize away any ".." segments without touching the filesystem.
1006
+ parts: List[str] = []
1007
+ for comp in target.split("/"):
1008
+ if comp in ("", "."):
1009
+ continue
1010
+ if comp == "..":
1011
+ if parts:
1012
+ parts.pop()
1013
+ continue
1014
+ parts.append(comp)
1015
+ norm = "/".join(parts)
1016
+ if not norm:
1017
+ return None
1018
+ candidates = [norm]
1019
+ candidates += [f"{norm}{ext}" for ext in self._JS_RESOLVE_EXTS]
1020
+ candidates += [f"{norm}/index{ext}" for ext in self._JS_RESOLVE_EXTS]
1021
+ for cand in candidates:
1022
+ if cand in file_set:
1023
+ return cand
1024
+ return None
1025
+
1026
+ def _js_import_edges(self, files: List[str], file_set: Set[str]) -> List[Tuple[str, str]]:
1027
+ """Resolve TS/JS relative import/require/export-from edges to repo files."""
1028
+ js_files = [f for f in files if Path(f).suffix.lower() in {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"}]
1029
+ edges: List[Tuple[str, str]] = []
1030
+ seen: Set[Tuple[str, str]] = set()
1031
+ for rel in js_files:
1032
+ try:
1033
+ source = (self.project_root / rel).read_text(encoding="utf-8", errors="replace")
1034
+ except OSError:
1035
+ continue
1036
+ specs: List[str] = []
1037
+ for m in self._JS_IMPORT_RE.finditer(source):
1038
+ spec = m.group("spec") or m.group("spec2")
1039
+ if spec:
1040
+ specs.append(spec)
1041
+ for line in source.splitlines():
1042
+ bare = self._JS_BARE_IMPORT_RE.match(line)
1043
+ if bare:
1044
+ specs.append(bare.group("spec"))
1045
+ for spec in specs:
1046
+ target = self._resolve_js_spec(rel, spec, file_set)
1047
+ if target and target != rel and (rel, target) not in seen:
1048
+ seen.add((rel, target))
1049
+ edges.append((rel, target))
1050
+ return edges
1051
+
1052
+ def _go_module_prefix(self, file_set: Set[str]) -> str | None:
1053
+ """The module path declared in go.mod, used to map import paths back to repo dirs."""
1054
+ for candidate in (p for p in file_set if p == "go.mod" or p.endswith("/go.mod")):
1055
+ try:
1056
+ text = (self.project_root / candidate).read_text(encoding="utf-8", errors="replace")
1057
+ except OSError:
1058
+ continue
1059
+ m = self._GO_MODULE_RE.search(text)
1060
+ if m:
1061
+ return m.group("mod").strip()
1062
+ return None
1063
+
1064
+ def _go_import_edges(self, files: List[str], file_set: Set[str]) -> List[Tuple[str, str]]:
1065
+ """Resolve Go import paths (under the module prefix) to package directories, then
1066
+ to the .go files in those directories. Edges target every file in the package."""
1067
+ module = self._go_module_prefix(file_set)
1068
+ if not module:
1069
+ return []
1070
+ go_files = [f for f in files if f.endswith(".go")]
1071
+ if not go_files:
1072
+ return []
1073
+ # package dir -> .go files in it (excluding _test.go, which aren't imported).
1074
+ pkg_files: Dict[str, List[str]] = defaultdict(list)
1075
+ for f in go_files:
1076
+ if f.endswith("_test.go"):
1077
+ continue
1078
+ pkg_files[Path(f).parent.as_posix()].append(f)
1079
+ edges: List[Tuple[str, str]] = []
1080
+ seen: Set[Tuple[str, str]] = set()
1081
+ for rel in go_files:
1082
+ try:
1083
+ source = (self.project_root / rel).read_text(encoding="utf-8", errors="replace")
1084
+ except OSError:
1085
+ continue
1086
+ specs: List[str] = []
1087
+ for block in self._GO_IMPORT_BLOCK_RE.finditer(source):
1088
+ for sm in self._GO_IMPORT_SPEC_RE.finditer(block.group("body")):
1089
+ specs.append(sm.group("spec"))
1090
+ for line in source.splitlines():
1091
+ single = self._GO_IMPORT_SINGLE_RE.match(line)
1092
+ if single:
1093
+ specs.append(single.group("spec"))
1094
+ for spec in specs:
1095
+ if spec != module and not spec.startswith(module + "/"):
1096
+ continue # external/stdlib package
1097
+ rel_pkg = spec[len(module):].lstrip("/")
1098
+ target_dir = rel_pkg if rel_pkg else "."
1099
+ for target in pkg_files.get(target_dir, []):
1100
+ if target != rel and (rel, target) not in seen:
1101
+ seen.add((rel, target))
1102
+ edges.append((rel, target))
1103
+ return edges
1104
+
1105
+ def _all_import_edges(self, files: List[str]) -> List[Tuple[str, str]]:
1106
+ """All cross-file import edges across supported languages, feeding the same
1107
+ dependents reverse index. Best-effort; never raises."""
1108
+ file_set = set(files)
1109
+ edges = list(self._python_import_edges(files))
1110
+ try:
1111
+ edges.extend(self._js_import_edges(files, file_set))
1112
+ except Exception:
1113
+ logger.debug("JS/TS import-edge resolution failed", exc_info=True)
1114
+ try:
1115
+ edges.extend(self._go_import_edges(files, file_set))
1116
+ except Exception:
1117
+ logger.debug("Go import-edge resolution failed", exc_info=True)
1118
+ return edges
1119
+
1120
+ def _rank_area_files(self, area_files: List[str], in_degree: Counter) -> List[str]:
1121
+ def sort_key(path: str) -> Tuple[int, int, str]:
1122
+ name = Path(path).stem.lower()
1123
+ entry_rank = next((i for i, hint in enumerate(_ENTRY_NAME_HINTS) if name == hint), len(_ENTRY_NAME_HINTS))
1124
+ # Most-imported first, then entry-named, then alphabetical for stability.
1125
+ return (-in_degree.get(path, 0), entry_rank, path)
1126
+
1127
+ return sorted(area_files, key=sort_key)
1128
+
1129
+ def _build_generic_subsystems(self, files: List[str]) -> List[RepoSubsystem]:
1130
+ source_root = self._source_root if self._source_root is not None else self.detect_source_root(files)
1131
+ code_files = self._code_files(files)
1132
+ if not code_files:
1133
+ return []
1134
+
1135
+ by_area: Dict[str, List[str]] = defaultdict(list)
1136
+ area_of: Dict[str, str] = {}
1137
+ for f in code_files:
1138
+ area = self._generic_area_for_file(f, source_root)
1139
+ by_area[area].append(f)
1140
+ area_of[f] = area
1141
+
1142
+ edges = self._edges if self._edges is not None else self._python_import_edges(files)
1143
+ in_degree: Counter = Counter(target for _, target in edges)
1144
+ area_neighbors: Dict[str, Set[str]] = defaultdict(set)
1145
+ area_handoffs: Dict[str, List[str]] = defaultdict(list)
1146
+ for importer, imported in edges:
1147
+ a, b = area_of.get(importer), area_of.get(imported)
1148
+ if a and b and a != b:
1149
+ area_neighbors[a].add(b)
1150
+ if len(area_handoffs[a]) < 3:
1151
+ area_handoffs[a].append(f"{importer} -> {imported}")
1152
+
1153
+ subsystems: List[RepoSubsystem] = []
1154
+ for area in sorted(by_area):
1155
+ area_files = by_area[area]
1156
+ # Skip trivial single-file aux areas (e.g. a lone script) to reduce noise,
1157
+ # but keep every real source subsystem.
1158
+ if len(area_files) < 2 and area.split("/")[0] in _AUX_AREA_ROOTS:
1159
+ continue
1160
+ ranked = self._rank_area_files(area_files, in_degree)
1161
+ critical_files = ranked[: self._SUBSYSTEM_CRITICAL_MAX]
1162
+ entry_points = [p for p in critical_files if in_degree.get(p, 0) > 0][:3] or critical_files[:1]
1163
+ stems = ", ".join(Path(p).stem for p in critical_files[:3])
1164
+ summary = f"{Path(area).name or area}: {stems}" if stems else f"{area} ({len(area_files)} files)"
1165
+ subsystems.append(
1166
+ RepoSubsystem(
1167
+ area=area,
1168
+ summary=summary,
1169
+ entry_points=entry_points,
1170
+ critical_files=critical_files,
1171
+ neighbors=sorted(area_neighbors.get(area, set()))[:6],
1172
+ handoff_paths=area_handoffs.get(area, []),
1173
+ role_files={},
1174
+ )
1175
+ )
1176
+ return subsystems
1177
+
1178
+ def generic_important_files(self, files: List[str]) -> List[str]:
1179
+ """The most-depended-on source files across the repo (highest import in-degree),
1180
+ used to seed 'important surfaces' on repos without a curated index."""
1181
+ edges = self._edges if self._edges is not None else self._python_import_edges(files)
1182
+ if not edges:
1183
+ return []
1184
+ in_degree = Counter(target for _, target in edges)
1185
+ ranked = [path for path, _ in in_degree.most_common()]
1186
+ return ranked[:8]
1187
+
1188
+ def build_dependents(self, edges: List[Tuple[str, str]]) -> Dict[str, List[str]]:
1189
+ """Reverse the import edges into a file -> dependents map (who imports each file),
1190
+ capped per file. This is the blast radius an agent needs before changing a file."""
1191
+ reverse: Dict[str, Set[str]] = defaultdict(set)
1192
+ for importer, imported in edges:
1193
+ reverse[imported].add(importer)
1194
+ return {
1195
+ path: sorted(importers)[: self._DEPENDENTS_MAX]
1196
+ for path, importers in sorted(reverse.items())
1197
+ if importers
1198
+ }
1199
+
1200
+ def describe_file(self, path: str) -> RepoFileEntry:
1201
+ return RepoFileEntry(
1202
+ path=path,
1203
+ area=self._area_for_file(path),
1204
+ kind=self._kind_for_file(path),
1205
+ language=self._language_for_file(path),
1206
+ summary=self._summary_for_file(path),
1207
+ )
26
1208
 
27
1209
  def _is_runtime_or_generated_file(self, path: str) -> bool:
28
1210
  normalized = path.replace("\\", "/")
29
1211
  parts = set(normalized.split("/"))
1212
+ name = Path(normalized).name
30
1213
  if "__pycache__" in parts or normalized.endswith(".pyc"):
31
1214
  return True
32
1215
  if parts.intersection({".git", ".devcouncil", ".pytest_cache", ".ruff_cache", ".mypy_cache", ".venv"}):
33
1216
  return True
34
1217
  if normalized.startswith("dist/") or normalized.startswith("build/"):
35
1218
  return True
1219
+ if name.startswith(("tmp", "temp", ".tmp", "debug")) or name.endswith("~"):
1220
+ return True
36
1221
  return False
37
1222
 
1223
+ def _git_head(self) -> str:
1224
+ try:
1225
+ return subprocess.check_output(
1226
+ ["git", "rev-parse", "HEAD"], cwd=self.project_root, stderr=subprocess.DEVNULL
1227
+ ).decode("utf-8", errors="replace").strip()
1228
+ except Exception:
1229
+ return ""
1230
+
1231
+ def _files_fingerprint(self, files: List[str]) -> str:
1232
+ return hashlib.sha1("\n".join(sorted(files)).encode("utf-8")).hexdigest()
1233
+
1234
+ def map_is_stale(self, repo_map: Dict[str, object]) -> bool:
1235
+ """True when the stored map no longer matches the repo's current git HEAD or
1236
+ tracked file set — i.e. commits or file add/removes happened since ``dev map``
1237
+ last ran. Returns False for maps written before fingerprinting (no false alarms)."""
1238
+ stored_head = str(repo_map.get("generated_head") or "")
1239
+ stored_hash = str(repo_map.get("indexed_hash") or "")
1240
+ if not stored_head and not stored_hash:
1241
+ return False
1242
+ try:
1243
+ files = self.get_git_files()
1244
+ except Exception:
1245
+ return False
1246
+ return self._git_head() != stored_head or self._files_fingerprint(files) != stored_hash
1247
+
38
1248
  def get_git_files(self) -> List[str]:
39
1249
  try:
40
1250
  output = subprocess.check_output(
@@ -73,7 +1283,7 @@ class RepoMapper:
73
1283
  frameworks = []
74
1284
  file_set = set(files)
75
1285
  if "package.json" in file_set:
76
- content = (self.project_root / "package.json").read_text()
1286
+ content = self._read_config_file("package.json")
77
1287
  if "next" in content:
78
1288
  frameworks.append("nextjs")
79
1289
  if "react" in content:
@@ -85,12 +1295,13 @@ class RepoMapper:
85
1295
 
86
1296
  if "requirements.txt" in file_set or "pyproject.toml" in file_set:
87
1297
  try:
88
- content = ""
1298
+ parts: List[str] = []
89
1299
  if "requirements.txt" in file_set:
90
- content += (self.project_root / "requirements.txt").read_text()
1300
+ parts.append(self._read_config_file("requirements.txt"))
91
1301
  if "pyproject.toml" in file_set:
92
- content += (self.project_root / "pyproject.toml").read_text()
93
-
1302
+ parts.append(self._read_config_file("pyproject.toml"))
1303
+ content = "".join(parts)
1304
+
94
1305
  if "fastapi" in content.lower():
95
1306
  frameworks.append("fastapi")
96
1307
  if "flask" in content.lower():
@@ -128,7 +1339,7 @@ class RepoMapper:
128
1339
  # Node.js projects: read scripts from package.json
129
1340
  if "package.json" in file_set:
130
1341
  try:
131
- pkg = json.loads((self.project_root / "package.json").read_text())
1342
+ pkg = json.loads(self._read_config_file("package.json"))
132
1343
  scripts = pkg.get("scripts", {})
133
1344
  pm = "pnpm" if "pnpm-lock.yaml" in file_set else (
134
1345
  "yarn" if "yarn.lock" in file_set else "npm"
@@ -171,7 +1382,7 @@ class RepoMapper:
171
1382
  capture_output=True, text=True, cwd=self.project_root, timeout=10,
172
1383
  )
173
1384
  if result.returncode == 0:
174
- for line in result.stdout.strip().splitlines()[:10]:
1385
+ for line in sorted(result.stdout.strip().splitlines())[:10]:
175
1386
  candidates.append({"path": line.strip(), "reason": f"ripgrep match for '{goal}'"})
176
1387
  return candidates
177
1388
  except Exception:
@@ -179,16 +1390,71 @@ class RepoMapper:
179
1390
 
180
1391
  # Naive keyword matching fallback
181
1392
  goal_words = set(goal.lower().split())
1393
+ scored_candidates: list[tuple[int, str]] = []
182
1394
  for f in files:
183
1395
  f_lower = f.lower()
184
1396
  score = sum(1 for word in goal_words if word in f_lower)
185
1397
  if score > 0:
186
- candidates.append({"path": f, "reason": f"Matches goal keywords (score: {score})"})
187
- candidates = sorted(candidates, key=lambda x: x.get("reason", ""), reverse=True)[:10]
1398
+ scored_candidates.append((score, f))
1399
+ candidates = [
1400
+ {"path": path, "reason": f"Matches goal keywords (score: {score})"}
1401
+ for score, path in sorted(scored_candidates, key=lambda item: (item[0], item[1]), reverse=True)
1402
+ ][:10]
188
1403
  return candidates
189
1404
 
190
- def map_repo(self, goal: str = "") -> RepoMap:
1405
+ def _scan_dependency_risks(self) -> List[Dict[str, str]]:
1406
+ """Best-effort SCA scan; isolated so map_repo stays simple and never raises."""
1407
+ try:
1408
+ from devcouncil.repo.sca import scan_dependency_risks
1409
+
1410
+ return scan_dependency_risks(self.project_root)
1411
+ except Exception:
1412
+ logger.debug("Dependency-risk scan failed", exc_info=True)
1413
+ return []
1414
+
1415
+ def map_repo(self, goal: str = "", *, scan_dependencies: bool = False) -> RepoMap:
1416
+ """Build the repo map.
1417
+
1418
+ ``scan_dependencies`` is opt-in (default off) so the common ``dev map`` path
1419
+ stays fast and never shells out to a vulnerability auditor. When enabled, a
1420
+ best-effort SCA scan runs locally (only if an auditor is installed) and its
1421
+ findings are attached as ``dependency_risks``.
1422
+ """
191
1423
  files = self.get_git_files()
1424
+ # Decide DevCouncil-vs-generic and the source root BEFORE describing files, so
1425
+ # area bucketing and subsystem inference agree within a single run.
1426
+ self._use_generic = not any(path.startswith("src/devcouncil/") for path in files)
1427
+ self._source_root = self.detect_source_root(files)
1428
+ # Compute the import graph once; reused by subsystem inference, important-file
1429
+ # ranking, and the dependents (blast-radius) index. Spans Python, TS/JS, and Go
1430
+ # so non-Python repos get dependents/neighbors too.
1431
+ self._edges = self._all_import_edges(files)
1432
+ file_entries = [self.describe_file(path) for path in sorted(files)]
1433
+ file_set = set(files)
1434
+
1435
+ important_candidates = [
1436
+ "README.md",
1437
+ "AGENTS.md",
1438
+ "CLAUDE.md",
1439
+ "package.json",
1440
+ "pyproject.toml",
1441
+ "src/devcouncil/cli/main.py",
1442
+ "src/devcouncil/app/orchestrator.py",
1443
+ "src/devcouncil/app/state_machine.py",
1444
+ "src/devcouncil/artifacts/graph.py",
1445
+ "src/devcouncil/indexing/repo_mapper.py",
1446
+ "src/devcouncil/storage/repositories.py",
1447
+ "src/devcouncil/execution/task_runner.py",
1448
+ "src/devcouncil/verification/verifier.py",
1449
+ ]
1450
+ important_files = [path for path in important_candidates if path in file_set]
1451
+ important_files.extend(sorted(path for path in files if path.startswith(".github/workflows/")))
1452
+ # On non-DevCouncil repos the curated candidates above mostly miss, so seed
1453
+ # important surfaces from the most-depended-on source files.
1454
+ if self._use_generic:
1455
+ for path in self.generic_important_files(files):
1456
+ if path not in important_files:
1457
+ important_files.append(path)
192
1458
 
193
1459
  candidates: List[Dict[str, str]] = []
194
1460
  if goal:
@@ -199,10 +1465,13 @@ class RepoMapper:
199
1465
  frameworks=self.detect_frameworks(files),
200
1466
  package_managers=self.detect_package_managers(files),
201
1467
  test_commands=self.detect_test_commands(files),
202
- important_files=[f for f in files if f in [
203
- "package.json", "pyproject.toml", "README.md", "go.mod",
204
- "Cargo.toml", "Makefile", "Dockerfile", ".github/workflows",
205
- ]],
1468
+ important_files=important_files,
206
1469
  candidate_files=candidates,
1470
+ files=file_entries,
1471
+ subsystems=self._build_subsystem_index(files),
1472
+ dependents=self.build_dependents(self._edges or []),
1473
+ generated_head=self._git_head(),
1474
+ indexed_hash=self._files_fingerprint(files),
207
1475
  lsp=LspInspector(self.project_root).summary(files),
1476
+ dependency_risks=self._scan_dependency_risks() if scan_dependencies else [],
208
1477
  )