devcouncil 0.2.0 → 0.3.1

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 (98) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/devcouncil/app/config.py +181 -7
  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 +9 -2
  8. package/src/devcouncil/cli/commands/check.py +12 -1
  9. package/src/devcouncil/cli/commands/design.py +186 -0
  10. package/src/devcouncil/cli/commands/doctor.py +160 -3
  11. package/src/devcouncil/cli/commands/go.py +96 -16
  12. package/src/devcouncil/cli/commands/hook.py +172 -0
  13. package/src/devcouncil/cli/commands/init.py +7 -2
  14. package/src/devcouncil/cli/commands/integrate.py +492 -34
  15. package/src/devcouncil/cli/commands/logs.py +106 -0
  16. package/src/devcouncil/cli/commands/okf.py +245 -0
  17. package/src/devcouncil/cli/commands/plan.py +54 -14
  18. package/src/devcouncil/cli/commands/repair.py +12 -3
  19. package/src/devcouncil/cli/commands/run.py +128 -7
  20. package/src/devcouncil/cli/commands/skills.py +180 -1
  21. package/src/devcouncil/cli/commands/status.py +7 -16
  22. package/src/devcouncil/cli/commands/verify.py +16 -10
  23. package/src/devcouncil/cli/commands/watch.py +24 -4
  24. package/src/devcouncil/cli/main.py +36 -1
  25. package/src/devcouncil/domain/evidence.py +7 -0
  26. package/src/devcouncil/execution/checkpoints.py +12 -2
  27. package/src/devcouncil/execution/fs_watcher.py +27 -2
  28. package/src/devcouncil/execution/handoff.py +1 -1
  29. package/src/devcouncil/execution/patch.py +6 -0
  30. package/src/devcouncil/execution/permissions.py +7 -0
  31. package/src/devcouncil/execution/policy_engine.py +12 -5
  32. package/src/devcouncil/execution/prompt_builder.py +126 -10
  33. package/src/devcouncil/execution/shell_session.py +6 -0
  34. package/src/devcouncil/execution/task_runner.py +18 -7
  35. package/src/devcouncil/executors/agent_registry.py +22 -1
  36. package/src/devcouncil/executors/coding_cli.py +133 -5
  37. package/src/devcouncil/executors/mini_swe.py +6 -0
  38. package/src/devcouncil/executors/native/agent.py +15 -0
  39. package/src/devcouncil/executors/openhands.py +6 -0
  40. package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
  41. package/src/devcouncil/gating/policy.py +38 -7
  42. package/src/devcouncil/indexing/ast_matcher.py +16 -6
  43. package/src/devcouncil/indexing/repo_mapper.py +30 -8
  44. package/src/devcouncil/indexing/semantic_index.py +42 -26
  45. package/src/devcouncil/integrations/actions.py +24 -4
  46. package/src/devcouncil/integrations/check.py +7 -4
  47. package/src/devcouncil/integrations/claude_assets.py +444 -0
  48. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  49. package/src/devcouncil/integrations/github_intent.py +8 -1
  50. package/src/devcouncil/integrations/gitnexus.py +10 -2
  51. package/src/devcouncil/integrations/mcp/server.py +404 -15
  52. package/src/devcouncil/integrations/pr_comments.py +9 -0
  53. package/src/devcouncil/knowledge/__init__.py +23 -0
  54. package/src/devcouncil/knowledge/design.py +374 -0
  55. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  56. package/src/devcouncil/knowledge/fetch.py +223 -0
  57. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  58. package/src/devcouncil/knowledge/okf.py +202 -0
  59. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  60. package/src/devcouncil/knowledge/sources.py +239 -0
  61. package/src/devcouncil/live/cards.py +20 -6
  62. package/src/devcouncil/live/repair_prompt.py +29 -6
  63. package/src/devcouncil/live/reviewer.py +72 -13
  64. package/src/devcouncil/live/summary.py +18 -8
  65. package/src/devcouncil/live/transcripts.py +38 -5
  66. package/src/devcouncil/llm/cache.py +14 -6
  67. package/src/devcouncil/llm/provider.py +179 -92
  68. package/src/devcouncil/llm/router.py +122 -23
  69. package/src/devcouncil/optimization/skillopt.py +673 -0
  70. package/src/devcouncil/planning/arbiter_service.py +10 -2
  71. package/src/devcouncil/planning/correction_manifest.py +47 -4
  72. package/src/devcouncil/planning/critique_service.py +9 -2
  73. package/src/devcouncil/planning/plan_service.py +69 -3
  74. package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
  75. package/src/devcouncil/planning/repair_service.py +8 -2
  76. package/src/devcouncil/planning/spec_service.py +10 -2
  77. package/src/devcouncil/repo/ci_scaffold.py +13 -5
  78. package/src/devcouncil/repo/sca.py +11 -1
  79. package/src/devcouncil/reporting/json_report.py +11 -0
  80. package/src/devcouncil/reporting/markdown_report.py +14 -1
  81. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  82. package/src/devcouncil/reporting/okf_html.py +323 -0
  83. package/src/devcouncil/reporting/report_builder.py +18 -1
  84. package/src/devcouncil/skills/registry.py +111 -33
  85. package/src/devcouncil/storage/db.py +58 -2
  86. package/src/devcouncil/storage/models.py +4 -0
  87. package/src/devcouncil/storage/native.py +20 -18
  88. package/src/devcouncil/storage/repositories.py +35 -18
  89. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  90. package/src/devcouncil/telemetry/stages.py +141 -0
  91. package/src/devcouncil/telemetry/tracker.py +12 -1
  92. package/src/devcouncil/ui/dashboard.py +69 -5
  93. package/src/devcouncil/verification/acceptance_compiler.py +147 -19
  94. package/src/devcouncil/verification/ad_hoc_check.py +6 -0
  95. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  96. package/src/devcouncil/verification/sandbox.py +7 -4
  97. package/src/devcouncil/verification/verifier.py +905 -517
  98. package/uv.lock +1 -1
@@ -71,12 +71,13 @@ class AstMatcher:
71
71
  language: str | None = None,
72
72
  kind: str | None = None,
73
73
  limit: int = 100,
74
+ files: list[Path] | None = None,
74
75
  ) -> list[AstMatch]:
75
76
  language = language.lower() if language else None
76
77
  kind = kind.lower() if kind else None
77
78
  limit = max(1, limit)
78
79
  matches: list[AstMatch] = []
79
- for path in self._candidate_files(language):
80
+ for path in self._candidate_files(language, files):
80
81
  try:
81
82
  text = path.read_text(encoding="utf-8")
82
83
  except (OSError, UnicodeDecodeError):
@@ -88,22 +89,31 @@ class AstMatcher:
88
89
  return matches[:limit]
89
90
  return matches[:limit]
90
91
 
91
- def _candidate_files(self, language: str | None) -> list[Path]:
92
+ def _candidate_files(self, language: str | None, files: list[Path] | None = None) -> list[Path]:
92
93
  allowed_exts = {
93
94
  ext for ext, ext_language in self._EXT_LANGUAGE.items()
94
95
  if language is None or ext_language == language
95
96
  }
96
- files: list[Path] = []
97
+ # When the caller already walked the tree (e.g. SemanticIndex.create_snapshot
98
+ # shares one traversal across all collectors), filter that list in memory
99
+ # instead of re-globbing. The filter is identical to the rglob path below.
100
+ if files is not None:
101
+ return sorted(
102
+ path for path in files
103
+ if path.is_file() and path.suffix.lower() in allowed_exts
104
+ and not any(part in self._IGNORED_DIRS for part in path.parts)
105
+ )
106
+ candidates: list[Path] = []
97
107
  try:
98
108
  for path in self.project_root.rglob("*"):
99
109
  if not path.is_file() or path.suffix.lower() not in allowed_exts:
100
110
  continue
101
111
  if any(part in self._IGNORED_DIRS for part in path.parts):
102
112
  continue
103
- files.append(path)
113
+ candidates.append(path)
104
114
  except OSError:
105
- return files
106
- return sorted(files)
115
+ return candidates
116
+ return sorted(candidates)
107
117
 
108
118
  def _match_file(self, rel: str, language: str, text: str, *, query: str, kind: str | None) -> list[AstMatch]:
109
119
  if language == "python":
@@ -78,6 +78,15 @@ class RepoMapper:
78
78
  # Import edges (importer -> imported), computed once per map_repo run and reused
79
79
  # by subsystem inference, important-file ranking, and the dependents index.
80
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]
81
90
 
82
91
  _DEPENDENTS_MAX = 12 # cap dependents listed per file to bound repo_map.json size
83
92
 
@@ -768,12 +777,24 @@ class RepoMapper:
768
777
 
769
778
  def _build_hardcoded_subsystems(self, files: List[str]) -> List[RepoSubsystem]:
770
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()
771
792
  subsystems: List[RepoSubsystem] = []
772
793
  for area, (summary, entry_points) in self._SUBSYSTEM_INDEX.items():
773
794
  available_entry_points = [path for path in entry_points if path in file_set]
774
795
  if not available_entry_points:
775
796
  continue
776
- area_files = sorted(path for path in files if path.startswith(f"{area}/"))
797
+ area_files = by_area.get(area, [])
777
798
  ranked_files = [path for path in available_entry_points if path in file_set]
778
799
  for path in area_files:
779
800
  if path in available_entry_points:
@@ -782,7 +803,7 @@ class RepoMapper:
782
803
  break
783
804
  ranked_files.append(path)
784
805
  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)]
806
+ neighbors = [n for n in self._SUBSYSTEM_NEIGHBORS.get(area, []) if n in by_area]
786
807
  handoff_paths = self._SUBSYSTEM_HANDOFFS.get(area, [])
787
808
  role_files = self._build_role_files(area, area_files)
788
809
  subsystems.append(
@@ -1262,7 +1283,7 @@ class RepoMapper:
1262
1283
  frameworks = []
1263
1284
  file_set = set(files)
1264
1285
  if "package.json" in file_set:
1265
- content = (self.project_root / "package.json").read_text()
1286
+ content = self._read_config_file("package.json")
1266
1287
  if "next" in content:
1267
1288
  frameworks.append("nextjs")
1268
1289
  if "react" in content:
@@ -1274,12 +1295,13 @@ class RepoMapper:
1274
1295
 
1275
1296
  if "requirements.txt" in file_set or "pyproject.toml" in file_set:
1276
1297
  try:
1277
- content = ""
1298
+ parts: List[str] = []
1278
1299
  if "requirements.txt" in file_set:
1279
- content += (self.project_root / "requirements.txt").read_text()
1300
+ parts.append(self._read_config_file("requirements.txt"))
1280
1301
  if "pyproject.toml" in file_set:
1281
- content += (self.project_root / "pyproject.toml").read_text()
1282
-
1302
+ parts.append(self._read_config_file("pyproject.toml"))
1303
+ content = "".join(parts)
1304
+
1283
1305
  if "fastapi" in content.lower():
1284
1306
  frameworks.append("fastapi")
1285
1307
  if "flask" in content.lower():
@@ -1317,7 +1339,7 @@ class RepoMapper:
1317
1339
  # Node.js projects: read scripts from package.json
1318
1340
  if "package.json" in file_set:
1319
1341
  try:
1320
- pkg = json.loads((self.project_root / "package.json").read_text())
1342
+ pkg = json.loads(self._read_config_file("package.json"))
1321
1343
  scripts = pkg.get("scripts", {})
1322
1344
  pm = "pnpm" if "pnpm-lock.yaml" in file_set else (
1323
1345
  "yarn" if "yarn.lock" in file_set else "npm"
@@ -34,17 +34,25 @@ class SemanticIndex:
34
34
  return self.semantic_dir / task_id / f"{stage}.json"
35
35
 
36
36
  def create_snapshot(self, task_id: str, stage: str) -> Path:
37
- symbols = self._collect_symbols()
37
+ # Walk the tree ONCE and share the file list across every collector. Previously
38
+ # create_snapshot triggered four independent full-tree rglob() traversals (symbol
39
+ # matching, source-file hashing, import extraction, and LSP language detection);
40
+ # each collector now filters this single in-memory list with its own predicate, so
41
+ # the on-disk output is unchanged while the filesystem is walked a single time.
42
+ all_files = [path for path in self.project_root.rglob("*") if path.is_file()]
43
+ rel_files = [str(path.relative_to(self.project_root)) for path in all_files]
44
+ symbols = self._collect_symbols(all_files)
45
+ source_files, imports = self._collect_source_data(all_files)
38
46
  payload = {
39
47
  "task_id": task_id,
40
48
  "created_at": datetime.now(timezone.utc).isoformat(),
41
49
  "repo_map_path": str(self.project_root / ".devcouncil" / "repo_map.json"),
42
50
  "files": self._config_file_entries(),
43
- "source_files": self._source_file_entries(),
51
+ "source_files": source_files,
44
52
  "symbols": symbols,
45
- "imports": self._collect_imports(),
53
+ "imports": imports,
46
54
  "public_symbols": [s for s in symbols if s.get("public")],
47
- "lsp": json.loads(LspInspector(self.project_root).summary_json()),
55
+ "lsp": json.loads(LspInspector(self.project_root).summary_json(rel_files)),
48
56
  }
49
57
  path = self.snapshot_path(task_id, stage)
50
58
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -72,9 +80,9 @@ class SemanticIndex:
72
80
  )
73
81
  return {"classifications": classifications, "summary": summary}
74
82
 
75
- def _collect_symbols(self) -> list[dict]:
83
+ def _collect_symbols(self, files: list[Path] | None = None) -> list[dict]:
76
84
  symbols: list[dict] = []
77
- for match in self.matcher.match(limit=500):
85
+ for match in self.matcher.match(limit=500, files=files):
78
86
  symbols.append({
79
87
  "path": match.path,
80
88
  "language": match.language,
@@ -86,19 +94,39 @@ class SemanticIndex:
86
94
  })
87
95
  return symbols
88
96
 
89
- def _collect_imports(self) -> list[dict]:
97
+ def _collect_source_data(
98
+ self, files: list[Path] | None = None
99
+ ) -> tuple[list[dict], list[dict]]:
100
+ """Hash every source file and extract its imports in a SINGLE read pass.
101
+
102
+ Previously ``_source_file_entries`` read every source file's bytes for hashing
103
+ while ``_collect_imports`` independently re-read (and, for Python, re-parsed) the
104
+ same files. This reads each file once: the raw bytes feed the SHA-256 entry, and
105
+ the decoded text feeds import extraction. The import set is a subset of the hashed
106
+ set (it additionally skips dot-prefixed paths), so both outputs match the originals
107
+ exactly — source entries are still sorted by path, imports stay in traversal order.
108
+ """
109
+ if files is None:
110
+ files = [p for p in self.project_root.rglob("*") if p.is_file()]
111
+ source_entries: list[dict] = []
90
112
  imports: list[dict] = []
91
- for path in self.project_root.rglob("*"):
113
+ for path in files:
92
114
  if not path.is_file() or path.suffix not in {".py", ".ts", ".tsx", ".js", ".go", ".rs"}:
93
115
  continue
94
116
  rel = path.relative_to(self.project_root).as_posix()
95
- # Filter on the path relative to the project root — filtering on the
96
- # absolute path would skip everything when the repo itself lives
97
- # under a dot-directory.
117
+ if self._is_ignored_path(rel):
118
+ continue
119
+ raw = path.read_bytes()
120
+ source_entries.append({"path": rel, "sha256": hashlib.sha256(raw).hexdigest()})
121
+ # Imports additionally skip dot-prefixed relative paths. Filter on the path
122
+ # relative to the project root — filtering on the absolute path would skip
123
+ # everything when the repo itself lives under a dot-directory.
98
124
  rel_parts = Path(rel).parts
99
- if self._is_ignored_path(rel) or any(part.startswith(".") for part in rel_parts):
125
+ if any(part.startswith(".") for part in rel_parts):
100
126
  continue
101
- source = path.read_text(encoding="utf-8", errors="replace")
127
+ # Decode the bytes we already read, normalizing newlines the same way
128
+ # Path.read_text(newline=None) did, so parsed import statements are identical.
129
+ source = raw.decode("utf-8", errors="replace").replace("\r\n", "\n").replace("\r", "\n")
102
130
  if path.suffix == ".py":
103
131
  try:
104
132
  tree = ast.parse(source)
@@ -112,7 +140,7 @@ class SemanticIndex:
112
140
  for line in source.splitlines():
113
141
  if re.match(r"^\s*(import|from)\s+", line):
114
142
  imports.append({"path": rel, "statement": line.strip()})
115
- return imports
143
+ return sorted(source_entries, key=lambda item: item["path"]), imports
116
144
 
117
145
  def _classify(self, before: dict, after: dict) -> list[dict]:
118
146
  before_symbols = {(s["path"], s["name"]): s for s in before.get("symbols", [])}
@@ -188,18 +216,6 @@ class SemanticIndex:
188
216
  })
189
217
  return entries
190
218
 
191
- def _source_file_entries(self) -> list[dict]:
192
- entries: list[dict] = []
193
- for path in self.project_root.rglob("*"):
194
- if not path.is_file() or path.suffix not in {".py", ".ts", ".tsx", ".js", ".go", ".rs"}:
195
- continue
196
- rel = path.relative_to(self.project_root).as_posix()
197
- if self._is_ignored_path(rel):
198
- continue
199
- raw = path.read_bytes()
200
- entries.append({"path": rel, "sha256": hashlib.sha256(raw).hexdigest()})
201
- return sorted(entries, key=lambda item: item["path"])
202
-
203
219
  def _is_ignored_path(self, rel_path: str) -> bool:
204
220
  ignored_parts = {".git", ".devcouncil", "__pycache__", ".venv", "node_modules", "dist", "build", "target", "vendor"}
205
221
  return any(part in ignored_parts for part in Path(rel_path).parts)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import logging
4
5
  import shutil
5
6
  from dataclasses import dataclass
6
7
  from pathlib import Path
@@ -8,6 +9,8 @@ from typing import Any
8
9
 
9
10
  from devcouncil.integrations.check import build_integration_check_report
10
11
 
12
+ logger = logging.getLogger(__name__)
13
+
11
14
  VALID_INTEGRATION_TARGETS = {
12
15
  "all",
13
16
  "hooks",
@@ -65,15 +68,18 @@ def apply_integration_target(
65
68
  strict: bool = False,
66
69
  gemini_scope: str = "project",
67
70
  claude_scope: str = "local",
71
+ claude_write_gate: bool = False,
68
72
  ) -> IntegrationActionReport:
69
73
  from devcouncil.cli.commands import integrate
70
74
 
71
75
  root = project_root.expanduser().resolve()
72
76
  normalized = normalize_apply_target(target)
77
+ logger.info("Applying integration target: %s (root=%s)", normalized, root)
73
78
  warnings: list[str] = []
74
79
  results: list[dict[str, Any]] = []
75
80
 
76
81
  def add_result(name: str, ok: bool, path: Path | None = None, message: str = "") -> None:
82
+ (logger.info if ok else logger.warning)("Integration %s: %s — %s", name, "ok" if ok else "FAILED", message)
77
83
  results.append({
78
84
  "target": name,
79
85
  "ok": ok,
@@ -119,19 +125,33 @@ def apply_integration_target(
119
125
  add_result("aider", True, root / ".devcouncil" / "config.yaml", "Aider executor enabled.")
120
126
 
121
127
  def apply_hooks() -> None:
122
- integrate._configure_native_hooks(root, "all", apply=True)
128
+ integrate._configure_native_hooks(root, "all", apply=True, claude_write_gate=claude_write_gate)
123
129
  add_result("hooks", True, None, "Native hook files configured.")
124
130
 
131
+ def apply_claude_assets() -> None:
132
+ try:
133
+ written = integrate._install_claude_assets(root)
134
+ except (ValueError, FileNotFoundError, OSError) as exc:
135
+ add_result("claude-assets", False, None, f"Claude asset setup failed: {exc}")
136
+ return
137
+ add_result("claude-assets", True, None, f"Claude assets installed ({len(written)} file(s)).")
138
+
125
139
  if normalized == "all":
126
140
  for name in ("codex", "gemini", "claude"):
127
141
  apply_first_party(name)
128
- # Batch the per-tool config.yaml record updates into one load/save.
142
+ # Batch the per-tool config.yaml record updates (project files, aider,
143
+ # and native hooks) into one load/save cycle. _batched_raw_config is
144
+ # re-entrant, so apply_hooks()'s own batching participates in this one.
129
145
  with integrate._batched_raw_config(root):
130
146
  for name in ("cursor", "opencode", "antigravity", "warp"):
131
147
  apply_project_file(name)
132
148
  apply_aider()
133
- if include_hooks:
134
- apply_hooks()
149
+ if include_hooks:
150
+ apply_hooks()
151
+ # The static Claude Code asset surface (slash commands, subagents, output
152
+ # style, statusline, permissions, skills) — installed regardless of whether
153
+ # the claude CLI is on PATH, since these are plain files.
154
+ apply_claude_assets()
135
155
  elif normalized in {"codex", "gemini", "claude"}:
136
156
  apply_first_party(normalized)
137
157
  elif normalized in {"cursor", "opencode", "antigravity", "warp"}:
@@ -107,8 +107,11 @@ def probe_coding_cli_version(client: str) -> tuple[bool, str]:
107
107
  return False, f"Optional; install {label} to use this integration."
108
108
 
109
109
 
110
- def recommended_executor_status(project_root: Path) -> tuple[bool, str]:
111
- detected = detect_available_coding_cli(project_root)
110
+ def recommended_executor_status(
111
+ project_root: Path, detected: str | None = None
112
+ ) -> tuple[bool, str]:
113
+ if detected is None:
114
+ detected = detect_available_coding_cli(project_root)
112
115
  if not detected:
113
116
  return False, "No built-in coding CLI on PATH. Run dev integrate recommend after installing one."
114
117
  resolved = resolve_automated_executor(project_root, None)
@@ -279,6 +282,7 @@ def build_integration_check_report(project_root: Path, *, strict: bool = False)
279
282
  rows.append(IntegrationCheckRow(name=name, status="skip", details=details))
280
283
 
281
284
  root = project_root.expanduser().resolve()
285
+ detected = detect_available_coding_cli(root)
282
286
  add((root / ".devcouncil").exists(), "Project state", str(root / ".devcouncil"))
283
287
  devcouncil_path = shutil.which("devcouncil")
284
288
  add(
@@ -295,7 +299,7 @@ def build_integration_check_report(project_root: Path, *, strict: bool = False)
295
299
  cli_ok, cli_details = probe_coding_cli_version(client)
296
300
  add_optional(cli_ok, CODING_CLI_CHECK_LABELS.get(client, client), cli_details)
297
301
 
298
- rec_ok, rec_details = recommended_executor_status(root)
302
+ rec_ok, rec_details = recommended_executor_status(root, detected)
299
303
  add_optional(rec_ok, "Recommended coding CLI", rec_details)
300
304
 
301
305
  for row in integration_capability_rows(root):
@@ -383,7 +387,6 @@ def build_integration_check_report(project_root: Path, *, strict: bool = False)
383
387
  str(hook_path) if references else f"{hook_path} no longer references devcouncil (tampered/disarmed).",
384
388
  )
385
389
 
386
- detected = detect_available_coding_cli(root)
387
390
  recommended = resolve_automated_executor(root, None) if detected else None
388
391
  return IntegrationCheckReport(tuple(rows), recommended, failures)
389
392