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