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.
- package/README.md +12 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/app/config.py +181 -7
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +9 -2
- package/src/devcouncil/cli/commands/check.py +12 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +160 -3
- package/src/devcouncil/cli/commands/go.py +96 -16
- package/src/devcouncil/cli/commands/hook.py +172 -0
- package/src/devcouncil/cli/commands/init.py +7 -2
- package/src/devcouncil/cli/commands/integrate.py +492 -34
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +54 -14
- package/src/devcouncil/cli/commands/repair.py +12 -3
- package/src/devcouncil/cli/commands/run.py +128 -7
- package/src/devcouncil/cli/commands/skills.py +180 -1
- package/src/devcouncil/cli/commands/status.py +7 -16
- package/src/devcouncil/cli/commands/verify.py +16 -10
- package/src/devcouncil/cli/commands/watch.py +24 -4
- package/src/devcouncil/cli/main.py +36 -1
- package/src/devcouncil/domain/evidence.py +7 -0
- package/src/devcouncil/execution/checkpoints.py +12 -2
- package/src/devcouncil/execution/fs_watcher.py +27 -2
- package/src/devcouncil/execution/handoff.py +1 -1
- package/src/devcouncil/execution/patch.py +6 -0
- package/src/devcouncil/execution/permissions.py +7 -0
- package/src/devcouncil/execution/policy_engine.py +12 -5
- package/src/devcouncil/execution/prompt_builder.py +126 -10
- package/src/devcouncil/execution/shell_session.py +6 -0
- package/src/devcouncil/execution/task_runner.py +18 -7
- package/src/devcouncil/executors/agent_registry.py +22 -1
- package/src/devcouncil/executors/coding_cli.py +133 -5
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +15 -0
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
- package/src/devcouncil/gating/policy.py +38 -7
- package/src/devcouncil/indexing/ast_matcher.py +16 -6
- package/src/devcouncil/indexing/repo_mapper.py +30 -8
- package/src/devcouncil/indexing/semantic_index.py +42 -26
- package/src/devcouncil/integrations/actions.py +24 -4
- package/src/devcouncil/integrations/check.py +7 -4
- 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 +8 -1
- package/src/devcouncil/integrations/gitnexus.py +10 -2
- package/src/devcouncil/integrations/mcp/server.py +404 -15
- 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 +20 -6
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +38 -5
- package/src/devcouncil/llm/cache.py +14 -6
- package/src/devcouncil/llm/provider.py +179 -92
- package/src/devcouncil/llm/router.py +122 -23
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +47 -4
- package/src/devcouncil/planning/critique_service.py +9 -2
- package/src/devcouncil/planning/plan_service.py +69 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +10 -2
- package/src/devcouncil/repo/ci_scaffold.py +13 -5
- package/src/devcouncil/repo/sca.py +11 -1
- package/src/devcouncil/reporting/json_report.py +11 -0
- package/src/devcouncil/reporting/markdown_report.py +14 -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/registry.py +111 -33
- package/src/devcouncil/storage/db.py +58 -2
- package/src/devcouncil/storage/models.py +4 -0
- package/src/devcouncil/storage/native.py +20 -18
- package/src/devcouncil/storage/repositories.py +35 -18
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/tracker.py +12 -1
- package/src/devcouncil/ui/dashboard.py +69 -5
- package/src/devcouncil/verification/acceptance_compiler.py +147 -19
- package/src/devcouncil/verification/ad_hoc_check.py +6 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/sandbox.py +7 -4
- package/src/devcouncil/verification/verifier.py +905 -517
- 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
|
-
|
|
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
|
-
|
|
113
|
+
candidates.append(path)
|
|
104
114
|
except OSError:
|
|
105
|
-
return
|
|
106
|
-
return sorted(
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
1298
|
+
parts: List[str] = []
|
|
1278
1299
|
if "requirements.txt" in file_set:
|
|
1279
|
-
|
|
1300
|
+
parts.append(self._read_config_file("requirements.txt"))
|
|
1280
1301
|
if "pyproject.toml" in file_set:
|
|
1281
|
-
|
|
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(
|
|
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
|
-
|
|
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":
|
|
51
|
+
"source_files": source_files,
|
|
44
52
|
"symbols": symbols,
|
|
45
|
-
"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
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
125
|
+
if any(part.startswith(".") for part in rel_parts):
|
|
100
126
|
continue
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
134
|
-
|
|
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(
|
|
111
|
-
detected =
|
|
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
|
|