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
@@ -0,0 +1,221 @@
1
+ """Semantic snapshots and diff classification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import hashlib
7
+ import json
8
+ import re
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ from devcouncil.indexing.ast_matcher import AstMatcher
13
+ from devcouncil.indexing.lsp import LspInspector
14
+ from devcouncil.storage.db import get_db
15
+ from devcouncil.storage.native import SemanticDiffRepository
16
+
17
+ _CONFIG_FILES = {
18
+ "pyproject.toml",
19
+ "package.json",
20
+ "uv.lock",
21
+ "schema.prisma",
22
+ "docker-compose.yml",
23
+ "Dockerfile",
24
+ }
25
+
26
+
27
+ class SemanticIndex:
28
+ def __init__(self, project_root: Path):
29
+ self.project_root = project_root.resolve()
30
+ self.semantic_dir = self.project_root / ".devcouncil" / "semantic"
31
+ self.matcher = AstMatcher(self.project_root)
32
+
33
+ def snapshot_path(self, task_id: str, stage: str) -> Path:
34
+ return self.semantic_dir / task_id / f"{stage}.json"
35
+
36
+ def create_snapshot(self, task_id: str, stage: str) -> Path:
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)
46
+ payload = {
47
+ "task_id": task_id,
48
+ "created_at": datetime.now(timezone.utc).isoformat(),
49
+ "repo_map_path": str(self.project_root / ".devcouncil" / "repo_map.json"),
50
+ "files": self._config_file_entries(),
51
+ "source_files": source_files,
52
+ "symbols": symbols,
53
+ "imports": imports,
54
+ "public_symbols": [s for s in symbols if s.get("public")],
55
+ "lsp": json.loads(LspInspector(self.project_root).summary_json(rel_files)),
56
+ }
57
+ path = self.snapshot_path(task_id, stage)
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
60
+ return path
61
+
62
+ def diff(self, task_id: str) -> dict:
63
+ before_path = self.snapshot_path(task_id, "before")
64
+ after_path = self.snapshot_path(task_id, "after")
65
+ if not after_path.exists():
66
+ self.create_snapshot(task_id, "after")
67
+ before = json.loads(before_path.read_text(encoding="utf-8")) if before_path.exists() else {}
68
+ after = json.loads(after_path.read_text(encoding="utf-8"))
69
+ classifications = self._classify(before, after)
70
+ summary = ", ".join(item["type"] for item in classifications) or "no semantic changes"
71
+ db = get_db(self.project_root)
72
+ if db:
73
+ with db.get_session() as session:
74
+ SemanticDiffRepository(session).save(
75
+ task_id,
76
+ str(before_path),
77
+ str(after_path),
78
+ classifications,
79
+ summary,
80
+ )
81
+ return {"classifications": classifications, "summary": summary}
82
+
83
+ def _collect_symbols(self, files: list[Path] | None = None) -> list[dict]:
84
+ symbols: list[dict] = []
85
+ for match in self.matcher.match(limit=500, files=files):
86
+ symbols.append({
87
+ "path": match.path,
88
+ "language": match.language,
89
+ "kind": match.kind,
90
+ "name": match.name,
91
+ "line": match.line,
92
+ "signature": match.text.strip(),
93
+ "public": match.name[:1].isupper() or "export" in match.text,
94
+ })
95
+ return symbols
96
+
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] = []
112
+ imports: list[dict] = []
113
+ for path in files:
114
+ if not path.is_file() or path.suffix not in {".py", ".ts", ".tsx", ".js", ".go", ".rs"}:
115
+ continue
116
+ rel = path.relative_to(self.project_root).as_posix()
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.
124
+ rel_parts = Path(rel).parts
125
+ if any(part.startswith(".") for part in rel_parts):
126
+ continue
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")
130
+ if path.suffix == ".py":
131
+ try:
132
+ tree = ast.parse(source)
133
+ except (SyntaxError, ValueError):
134
+ continue
135
+ for node in ast.walk(tree):
136
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
137
+ segment = ast.get_source_segment(source, node) or ""
138
+ imports.append({"path": rel, "statement": segment})
139
+ else:
140
+ for line in source.splitlines():
141
+ if re.match(r"^\s*(import|from)\s+", line):
142
+ imports.append({"path": rel, "statement": line.strip()})
143
+ return sorted(source_entries, key=lambda item: item["path"]), imports
144
+
145
+ def _classify(self, before: dict, after: dict) -> list[dict]:
146
+ before_symbols = {(s["path"], s["name"]): s for s in before.get("symbols", [])}
147
+ after_symbols = {(s["path"], s["name"]): s for s in after.get("symbols", [])}
148
+ results: list[dict] = []
149
+
150
+ for key, after_sym in after_symbols.items():
151
+ before_sym = before_symbols.get(key)
152
+ path = after_sym["path"]
153
+ if path.startswith("tests/") or "/test_" in path:
154
+ results.append({"type": "test_only_change", "path": path, "name": after_sym["name"]})
155
+ continue
156
+ if Path(path).name in _CONFIG_FILES:
157
+ results.append({"type": "config_schema_dependency_change", "path": path, "name": after_sym["name"]})
158
+ continue
159
+ if before_sym is None and after_sym.get("public"):
160
+ results.append({"type": "exported_symbol_added", "path": path, "name": after_sym["name"]})
161
+ elif before_sym and before_sym.get("signature") != after_sym.get("signature"):
162
+ if after_sym.get("public"):
163
+ results.append({"type": "public_api_change", "path": path, "name": after_sym["name"]})
164
+ else:
165
+ results.append({"type": "private_implementation_change", "path": path, "name": after_sym["name"]})
166
+
167
+ before_imports = {(i["path"], i["statement"]) for i in before.get("imports", [])}
168
+ after_imports = {(i["path"], i["statement"]) for i in after.get("imports", [])}
169
+ for added in after_imports - before_imports:
170
+ results.append({"type": "import_dependency_change", "path": added[0], "statement": added[1]})
171
+
172
+ for key in before_symbols:
173
+ if key not in after_symbols and before_symbols[key].get("public"):
174
+ results.append({
175
+ "type": "exported_symbol_removed",
176
+ "path": before_symbols[key]["path"],
177
+ "name": before_symbols[key]["name"],
178
+ })
179
+
180
+ before_files = {item["path"]: item.get("content", "") for item in before.get("files", []) if isinstance(item, dict)}
181
+ after_files = {item["path"]: item.get("content", "") for item in after.get("files", []) if isinstance(item, dict)}
182
+ for path, content in after_files.items():
183
+ if Path(path).name in _CONFIG_FILES and before_files.get(path) != content:
184
+ results.append({"type": "config_schema_dependency_change", "path": path})
185
+
186
+ classified_paths = {item.get("path") for item in results}
187
+ before_source = {
188
+ item["path"]: item.get("sha256", "")
189
+ for item in before.get("source_files", [])
190
+ if isinstance(item, dict)
191
+ }
192
+ after_source = {
193
+ item["path"]: item.get("sha256", "")
194
+ for item in after.get("source_files", [])
195
+ if isinstance(item, dict)
196
+ }
197
+ for path, digest in after_source.items():
198
+ if path in classified_paths:
199
+ continue
200
+ if before_source.get(path) == digest:
201
+ continue
202
+ if path.startswith("tests/") or "/test_" in path:
203
+ results.append({"type": "test_only_change", "path": path})
204
+ elif Path(path).name not in _CONFIG_FILES:
205
+ results.append({"type": "private_implementation_change", "path": path})
206
+ return results
207
+
208
+ def _config_file_entries(self) -> list[dict]:
209
+ entries: list[dict] = []
210
+ for name in _CONFIG_FILES:
211
+ path = self.project_root / name
212
+ if path.exists():
213
+ entries.append({
214
+ "path": name,
215
+ "content": path.read_text(encoding="utf-8", errors="replace"),
216
+ })
217
+ return entries
218
+
219
+ def _is_ignored_path(self, rel_path: str) -> bool:
220
+ ignored_parts = {".git", ".devcouncil", "__pycache__", ".venv", "node_modules", "dist", "build", "target", "vendor"}
221
+ return any(part in ignored_parts for part in Path(rel_path).parts)
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from devcouncil.integrations.check import build_integration_check_report
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ VALID_INTEGRATION_TARGETS = {
15
+ "all",
16
+ "hooks",
17
+ "codex",
18
+ "gemini",
19
+ "claude",
20
+ "cursor",
21
+ "opencode",
22
+ "antigravity",
23
+ "agy",
24
+ "warp",
25
+ "aider",
26
+ }
27
+
28
+ TARGET_ALIASES = {
29
+ "agy": "antigravity",
30
+ }
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class IntegrationActionReport:
35
+ target: str
36
+ ok: bool
37
+ results: list[dict[str, Any]]
38
+ warnings: list[str]
39
+ check: dict[str, Any]
40
+
41
+ def as_dict(self) -> dict[str, Any]:
42
+ return {
43
+ "target": self.target,
44
+ "ok": self.ok,
45
+ "results": self.results,
46
+ "warnings": self.warnings,
47
+ "check": self.check,
48
+ }
49
+
50
+ def to_json(self, *, indent: int | None = 2) -> str:
51
+ return json.dumps(self.as_dict(), indent=indent)
52
+
53
+
54
+ def normalize_apply_target(target: str) -> str:
55
+ normalized = (target or "").strip().lower().replace("_", "-")
56
+ normalized = TARGET_ALIASES.get(normalized, normalized)
57
+ if normalized not in VALID_INTEGRATION_TARGETS:
58
+ allowed = ", ".join(sorted(VALID_INTEGRATION_TARGETS))
59
+ raise ValueError(f"Unsupported integration target '{target}'. Expected one of: {allowed}.")
60
+ return normalized
61
+
62
+
63
+ def apply_integration_target(
64
+ project_root: Path,
65
+ target: str,
66
+ *,
67
+ include_hooks: bool = True,
68
+ strict: bool = False,
69
+ gemini_scope: str = "project",
70
+ claude_scope: str = "local",
71
+ claude_write_gate: bool = False,
72
+ ) -> IntegrationActionReport:
73
+ from devcouncil.cli.commands import integrate
74
+
75
+ root = project_root.expanduser().resolve()
76
+ normalized = normalize_apply_target(target)
77
+ logger.info("Applying integration target: %s (root=%s)", normalized, root)
78
+ warnings: list[str] = []
79
+ results: list[dict[str, Any]] = []
80
+
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)
83
+ results.append({
84
+ "target": name,
85
+ "ok": ok,
86
+ "path": str(path.relative_to(root)) if path and path.is_relative_to(root) else (str(path) if path else ""),
87
+ "message": message,
88
+ })
89
+
90
+ def apply_first_party(name: str) -> None:
91
+ command_builders = {
92
+ "codex": integrate._codex_command,
93
+ "gemini": lambda project: integrate._gemini_command(project, gemini_scope),
94
+ "claude": lambda project: integrate._claude_command(project, claude_scope),
95
+ }
96
+ command = command_builders[name](root)
97
+ if not shutil.which(command[0]):
98
+ warning = f"{name} CLI not found on PATH; skipped CLI MCP registration."
99
+ warnings.append(warning)
100
+ integrate.console.print(f"[yellow]{name} CLI not found on PATH. Skipping optional integration.[/yellow]")
101
+ add_result(name, True, None, "CLI not found; skipped optional global/client registration.")
102
+ return
103
+ code = integrate._run(command)
104
+ add_result(name, code == 0, None, "MCP registration command exited " + str(code))
105
+
106
+ def apply_project_file(name: str) -> None:
107
+ writers = {
108
+ "cursor": integrate._write_cursor_config,
109
+ "opencode": integrate._write_opencode_config,
110
+ "antigravity": integrate._write_antigravity_mcp_config,
111
+ "warp": integrate._write_warp_mcp_config,
112
+ }
113
+ recorders = {
114
+ "cursor": integrate._record_cursor_config,
115
+ "opencode": integrate._record_opencode_config,
116
+ "antigravity": integrate._record_antigravity_config,
117
+ "warp": integrate._record_warp_config,
118
+ }
119
+ path = writers[name](root)
120
+ recorders[name](root)
121
+ add_result(name, True, path, "Project integration config written.")
122
+
123
+ def apply_aider() -> None:
124
+ integrate._record_aider_config(root)
125
+ add_result("aider", True, root / ".devcouncil" / "config.yaml", "Aider executor enabled.")
126
+
127
+ def apply_hooks() -> None:
128
+ integrate._configure_native_hooks(root, "all", apply=True, claude_write_gate=claude_write_gate)
129
+ add_result("hooks", True, None, "Native hook files configured.")
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
+
139
+ if normalized == "all":
140
+ for name in ("codex", "gemini", "claude"):
141
+ apply_first_party(name)
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.
145
+ with integrate._batched_raw_config(root):
146
+ for name in ("cursor", "opencode", "antigravity", "warp"):
147
+ apply_project_file(name)
148
+ apply_aider()
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()
155
+ elif normalized in {"codex", "gemini", "claude"}:
156
+ apply_first_party(normalized)
157
+ elif normalized in {"cursor", "opencode", "antigravity", "warp"}:
158
+ apply_project_file(normalized)
159
+ elif normalized == "aider":
160
+ apply_aider()
161
+ elif normalized == "hooks":
162
+ apply_hooks()
163
+
164
+ check = build_integration_check_report(root, strict=strict).as_dict()
165
+ ok = all(item["ok"] for item in results) and (not strict or bool(check["ok"]))
166
+ return IntegrationActionReport(normalized, ok, results, warnings, check)