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.
- package/README.md +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +220 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +195 -54
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- 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)
|