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
@@ -3,26 +3,64 @@ import shlex
3
3
  import shutil
4
4
  import subprocess
5
5
  import sys
6
+ from contextlib import contextmanager
6
7
  from pathlib import Path
7
8
 
8
9
  import typer
9
- import yaml
10
+ import yaml # type: ignore[import-untyped]
10
11
  from rich.console import Console
11
12
  from rich.table import Table
12
13
 
14
+ from devcouncil.executors.agent_registry import (
15
+ BUILTIN_CODING_EXECUTOR_NAMES,
16
+ CODING_CLI_INTEGRATION_INFO,
17
+ VALID_INPUT_MODES,
18
+ agent_config_entry,
19
+ detect_available_coding_cli,
20
+ integration_tier_label,
21
+ is_reserved_agent_name,
22
+ load_agent_profiles,
23
+ load_cli_agent_specs,
24
+ normalize_agent_name,
25
+ resolve_automated_executor,
26
+ resolve_coding_cli_executable,
27
+ resolve_coding_cli_probe_order,
28
+ )
29
+ from devcouncil.integrations.actions import apply_integration_target
30
+ from devcouncil.utils.subprocess_env import clean_subprocess_env
31
+ from devcouncil.integrations.check import (
32
+ build_integration_check_report,
33
+ integration_status_summary,
34
+ )
35
+
13
36
  app = typer.Typer(help="Set up DevCouncil integrations with coding CLIs.")
14
37
  setup_app = typer.Typer(help="Set up optional external companion integrations.")
15
38
  app.add_typer(setup_app, name="setup")
16
39
  console = Console()
17
40
 
18
- SUPPORTED_TOOLS = ("codex", "gemini", "claude", "cursor")
19
- SUPPORTED_HOOK_TOOLS = ("codex", "gemini", "claude")
41
+ SUPPORTED_TOOLS = ("codex", "gemini", "claude", "cursor", "opencode", "antigravity", "warp", "aider")
42
+ SUPPORTED_HOOK_TOOLS = ("codex", "gemini", "claude", "cursor")
43
+ OPENCODE_HOOK_PLUGIN_NAME = "opencode_devcouncil_plugin.mjs"
20
44
  PREFERRED_COMMAND = "dev integrate"
21
45
  LEGACY_COMMAND = "dev setup --integrate"
22
46
 
23
47
 
24
- def _project_root(path: Path | None) -> Path:
25
- return (path or Path(".")).expanduser().resolve()
48
+ def _project_root(path: str | Path | None) -> Path:
49
+ return Path(path or ".").expanduser().resolve()
50
+
51
+
52
+ def _warn_if_verify_only(client: str) -> None:
53
+ """Print a prominent containment warning when wiring a verify-only client.
54
+
55
+ Verify-only clients have no native pre-tool-use hook, so DevCouncil cannot block a
56
+ forbidden write or command before it happens — it is only caught post-hoc at verify
57
+ time. Surface this loudly so users don't assume hard containment."""
58
+ info = CODING_CLI_INTEGRATION_INFO.get(normalize_agent_name(client))
59
+ if info is not None and not info.hooks:
60
+ console.print(
61
+ f"[bold yellow]Warning ({info.label}): No pre-action containment — "
62
+ "forbidden writes/commands are caught only at verify time.[/bold yellow]"
63
+ )
26
64
 
27
65
 
28
66
  def _server_args(project_root: Path) -> list[str]:
@@ -57,28 +95,306 @@ def _gemini_command(project_root: Path, scope: str) -> list[str]:
57
95
 
58
96
 
59
97
  def _claude_command(project_root: Path, scope: str) -> list[str]:
98
+ # The server name must come BEFORE --env: the current Claude CLI treats --env
99
+ # as variadic, so `--env KEY=VALUE devcouncil` swallows the name `devcouncil`
100
+ # as a second (invalid) env var. Putting the name first — matching the working
101
+ # codex form — and terminating options with `--` avoids that.
60
102
  return [
61
103
  "claude",
62
104
  "mcp",
63
105
  "add",
64
106
  "--scope",
65
107
  scope,
108
+ "devcouncil",
66
109
  "--env",
67
110
  f"DEVCOUNCIL_PROJECT_ROOT={project_root}",
68
- "devcouncil",
69
111
  "--",
70
112
  *_server_args(project_root),
71
113
  ]
72
114
 
73
115
 
74
- def _cursor_command(project_root: Path) -> list[str]:
75
- server = {
76
- "name": "devcouncil",
77
- "command": "devcouncil",
78
- "args": ["mcp-server"],
79
- "env": {"DEVCOUNCIL_PROJECT_ROOT": str(project_root)},
116
+ def _cursor_config_path(project_root: Path) -> Path:
117
+ return project_root / ".cursor" / "mcp.json"
118
+
119
+
120
+ def _cursor_mcp_config(project_root: Path) -> dict:
121
+ return {
122
+ "mcpServers": {
123
+ "devcouncil": {
124
+ "type": "stdio",
125
+ "command": "devcouncil",
126
+ "args": ["mcp-server"],
127
+ "env": {"DEVCOUNCIL_PROJECT_ROOT": str(project_root)},
128
+ }
129
+ }
130
+ }
131
+
132
+
133
+ def _warp_mcp_config(project_root: Path) -> dict:
134
+ return {
135
+ "devcouncil": {
136
+ "command": "devcouncil",
137
+ "args": ["mcp-server"],
138
+ "env": {"DEVCOUNCIL_PROJECT_ROOT": str(project_root)},
139
+ }
140
+ }
141
+
142
+
143
+ def _warp_mcp_path(project_root: Path) -> Path:
144
+ return project_root / ".devcouncil" / "integrations" / "warp-mcp.json"
145
+
146
+
147
+ def _opencode_config_path(project_root: Path) -> Path:
148
+ return project_root / "opencode.json"
149
+
150
+
151
+ def _opencode_mcp_entry(project_root: Path) -> dict:
152
+ return {
153
+ "type": "local",
154
+ "command": ["devcouncil", "mcp-server"],
155
+ "environment": {"DEVCOUNCIL_PROJECT_ROOT": str(project_root)},
156
+ "enabled": True,
157
+ "timeout": 10000,
158
+ }
159
+
160
+
161
+ def _antigravity_mcp_path(project_root: Path) -> Path:
162
+ return project_root / ".agents" / "mcp_config.json"
163
+
164
+
165
+ def _antigravity_mcp_config(project_root: Path) -> dict:
166
+ return {
167
+ "mcpServers": {
168
+ "devcouncil": {
169
+ "command": "devcouncil",
170
+ "args": ["mcp-server"],
171
+ "env": {"DEVCOUNCIL_PROJECT_ROOT": str(project_root)},
172
+ "cwd": str(project_root),
173
+ }
174
+ }
175
+ }
176
+
177
+
178
+ def _write_warp_mcp_config(project_root: Path) -> Path:
179
+ path = _warp_mcp_path(project_root)
180
+ _save_json(path, _warp_mcp_config(project_root))
181
+ return path
182
+
183
+
184
+ def _write_cursor_config(project_root: Path) -> Path:
185
+ path = _cursor_config_path(project_root)
186
+ data = _load_json_strict(path, "Cursor")
187
+ mcp_servers = data.setdefault("mcpServers", {})
188
+ mcp_servers["devcouncil"] = _cursor_mcp_config(project_root)["mcpServers"]["devcouncil"]
189
+ _save_json(path, data)
190
+ return path
191
+
192
+
193
+ # When set, _mutate_raw_config applies record mutations in memory and
194
+ # _batched_raw_config saves config.yaml once at the end (used by
195
+ # `dev integrate all --apply`, which otherwise re-parses YAML per tool).
196
+ _PENDING_RAW_CONFIG: dict | None = None
197
+
198
+
199
+ @contextmanager
200
+ def _batched_raw_config(project_root: Path):
201
+ global _PENDING_RAW_CONFIG
202
+ # Re-entrant: if a batch is already active, participate in it instead of
203
+ # starting a nested load/save (which would otherwise reset the shared
204
+ # buffer to None on inner exit and drop the outer batch's mutations).
205
+ if _PENDING_RAW_CONFIG is not None:
206
+ yield
207
+ return
208
+ _PENDING_RAW_CONFIG = _load_raw_config(project_root)
209
+ try:
210
+ yield
211
+ finally:
212
+ # Persist whatever mutations accumulated, even if an inner installer raised
213
+ # partway through — matching the old per-installer save, which committed each
214
+ # installer's change immediately rather than dropping the whole batch on a
215
+ # mid-loop failure.
216
+ pending = _PENDING_RAW_CONFIG
217
+ _PENDING_RAW_CONFIG = None
218
+ if pending is not None:
219
+ _save_raw_config(project_root, pending)
220
+
221
+
222
+ def _mutate_raw_config(project_root: Path, mutate) -> None:
223
+ if _PENDING_RAW_CONFIG is not None:
224
+ mutate(_PENDING_RAW_CONFIG)
225
+ return
226
+ config = _load_raw_config(project_root)
227
+ mutate(config)
228
+ _save_raw_config(project_root, config)
229
+
230
+
231
+ def _record_cursor_config(project_root: Path) -> None:
232
+ def mutate(config: dict) -> None:
233
+ cursor = config.setdefault("integrations", {}).setdefault("cursor", {})
234
+ cursor.update({
235
+ "enabled": True,
236
+ "config_path": str(_cursor_config_path(project_root).relative_to(project_root)),
237
+ })
238
+
239
+ _mutate_raw_config(project_root, mutate)
240
+
241
+
242
+ def _record_warp_config(project_root: Path) -> None:
243
+ def mutate(config: dict) -> None:
244
+ warp = config.setdefault("integrations", {}).setdefault("warp", {})
245
+ warp.update({
246
+ "enabled": True,
247
+ "command": warp.get("command", "oz"),
248
+ "run_mode": warp.get("run_mode", "local"),
249
+ "mcp_config_path": str(_warp_mcp_path(project_root).relative_to(project_root)),
250
+ })
251
+
252
+ _mutate_raw_config(project_root, mutate)
253
+
254
+
255
+ def _record_opencode_config(project_root: Path) -> None:
256
+ def mutate(config: dict) -> None:
257
+ opencode = config.setdefault("integrations", {}).setdefault("opencode", {})
258
+ opencode.update({
259
+ "enabled": True,
260
+ "config_path": str(_opencode_config_path(project_root).relative_to(project_root)),
261
+ })
262
+
263
+ _mutate_raw_config(project_root, mutate)
264
+
265
+
266
+ def _record_antigravity_config(project_root: Path) -> None:
267
+ def mutate(config: dict) -> None:
268
+ antigravity = config.setdefault("integrations", {}).setdefault("antigravity", {})
269
+ antigravity.update({
270
+ "enabled": True,
271
+ "mcp_config_path": str(_antigravity_mcp_path(project_root).relative_to(project_root)),
272
+ })
273
+
274
+ _mutate_raw_config(project_root, mutate)
275
+
276
+
277
+ def _load_json_strict(path: Path, label: str = "JSON") -> dict:
278
+ if not path.exists():
279
+ return {}
280
+ try:
281
+ return json.loads(path.read_text(encoding="utf-8")) or {}
282
+ except json.JSONDecodeError as exc:
283
+ raise ValueError(f"{path} is not valid JSON. Fix the {label} config before rerunning integration setup.") from exc
284
+
285
+
286
+ def _write_opencode_config(project_root: Path) -> Path:
287
+ path = _opencode_config_path(project_root)
288
+ data = _load_json_strict(path, "OpenCode")
289
+ data.setdefault("$schema", "https://opencode.ai/config.json")
290
+ mcp = data.setdefault("mcp", {})
291
+ mcp["devcouncil"] = _opencode_mcp_entry(project_root)
292
+ _save_json(path, data)
293
+ return path
294
+
295
+
296
+ def _write_antigravity_mcp_config(project_root: Path) -> Path:
297
+ path = _antigravity_mcp_path(project_root)
298
+ data = _load_json_strict(path, "Antigravity")
299
+ mcp_servers = data.setdefault("mcpServers", {})
300
+ mcp_servers["devcouncil"] = _antigravity_mcp_config(project_root)["mcpServers"]["devcouncil"]
301
+ _save_json(path, data)
302
+ return path
303
+
304
+
305
+ def _configure_cursor(project_root: Path, apply: bool) -> bool:
306
+ path = _cursor_config_path(project_root)
307
+ config = _cursor_mcp_config(project_root)
308
+ if not apply:
309
+ console.print("[bold]Cursor[/bold]")
310
+ console.print(f"Project MCP config file: [dim]{path}[/dim]")
311
+ console.print(json.dumps(config, separators=(",", ":")), soft_wrap=True)
312
+ console.print("Verify in Cursor CLI with: [dim]cursor-agent mcp list[/dim]")
313
+ return True
314
+
315
+ if not shutil.which("cursor") and not shutil.which("cursor-agent"):
316
+ console.print("[yellow]Cursor CLI not found on PATH. Project MCP config will still be available to Cursor.[/yellow]")
317
+ try:
318
+ written = _write_cursor_config(project_root)
319
+ except ValueError as exc:
320
+ console.print(f"[red]{exc}[/red]")
321
+ return False
322
+ _record_cursor_config(project_root)
323
+ console.print(f"[green]Cursor MCP config written:[/green] {written}")
324
+ return True
325
+
326
+
327
+ def _configure_opencode(project_root: Path, apply: bool) -> bool:
328
+ path = _opencode_config_path(project_root)
329
+ config = {
330
+ "$schema": "https://opencode.ai/config.json",
331
+ "mcp": {"devcouncil": _opencode_mcp_entry(project_root)},
80
332
  }
81
- return ["cursor", "--add-mcp", json.dumps(server, separators=(",", ":"))]
333
+ if not apply:
334
+ console.print("[bold]OpenCode[/bold]")
335
+ console.print(f"Project config file: [dim]{path}[/dim]")
336
+ console.print(json.dumps(config, separators=(",", ":")), soft_wrap=True)
337
+ console.print(
338
+ "Direct executor command: "
339
+ "[dim]opencode run --file .devcouncil/TASK-001-opencode-task.md "
340
+ '"Execute the DevCouncil task described in the attached prompt file."[/dim]'
341
+ )
342
+ return True
343
+
344
+ if not shutil.which("opencode"):
345
+ console.print("[yellow]OpenCode CLI not found on PATH. Install it before using `dev run --executor opencode`.[/yellow]")
346
+ try:
347
+ written = _write_opencode_config(project_root)
348
+ except ValueError as exc:
349
+ console.print(f"[red]{exc}[/red]")
350
+ return False
351
+ _record_opencode_config(project_root)
352
+ console.print(f"[green]OpenCode MCP config written:[/green] {written}")
353
+ return True
354
+
355
+
356
+ def _configure_antigravity(project_root: Path, apply: bool) -> bool:
357
+ path = _antigravity_mcp_path(project_root)
358
+ config = _antigravity_mcp_config(project_root)
359
+ if not apply:
360
+ console.print("[bold]Google Antigravity CLI[/bold]")
361
+ console.print(f"Project MCP config file: [dim]{path}[/dim]")
362
+ console.print(json.dumps(config, separators=(",", ":")), soft_wrap=True)
363
+ console.print(
364
+ "Direct executor command: "
365
+ "[dim]agy --print --print-timeout 30m "
366
+ '"Read and execute the DevCouncil task prompt at .devcouncil/TASK-001-antigravity-task.md."[/dim]'
367
+ )
368
+ return True
369
+
370
+ if not shutil.which("agy"):
371
+ console.print("[yellow]Antigravity CLI (`agy`) not found on PATH. Install it before using `dev run --executor antigravity`.[/yellow]")
372
+ try:
373
+ written = _write_antigravity_mcp_config(project_root)
374
+ except ValueError as exc:
375
+ console.print(f"[red]{exc}[/red]")
376
+ return False
377
+ _record_antigravity_config(project_root)
378
+ console.print(f"[green]Antigravity MCP config written:[/green] {written}")
379
+ return True
380
+
381
+
382
+ def _configure_warp(project_root: Path, apply: bool) -> bool:
383
+ path = _warp_mcp_path(project_root)
384
+ config = _warp_mcp_config(project_root)
385
+ if not apply:
386
+ console.print("[bold]Warp / Oz[/bold]")
387
+ console.print(f"MCP config file: [dim]{path}[/dim]")
388
+ console.print(json.dumps(config, separators=(",", ":")), soft_wrap=True)
389
+ console.print(f"Direct executor command: [dim]oz agent run --cwd {project_root} --mcp {path} --prompt <task prompt>[/dim]")
390
+ return True
391
+
392
+ written = _write_warp_mcp_config(project_root)
393
+ _record_warp_config(project_root)
394
+ console.print(f"[green]Warp MCP config written:[/green] {written}")
395
+ if not shutil.which("oz"):
396
+ console.print("[yellow]oz CLI not found on PATH. Install Warp/Oz before using `dev run --executor warp`.[/yellow]")
397
+ return True
82
398
 
83
399
 
84
400
  def _format_command(command: list[str]) -> str:
@@ -96,6 +412,14 @@ def _quote_powershell_arg(arg: str) -> str:
96
412
  return "'" + arg.replace("'", "''") + "'"
97
413
 
98
414
 
415
+ def _opencode_plugin_source() -> Path:
416
+ return Path(__file__).resolve().parents[2] / "integrations" / OPENCODE_HOOK_PLUGIN_NAME
417
+
418
+
419
+ def _opencode_plugin_path(project_root: Path) -> Path:
420
+ return project_root / ".devcouncil" / "integrations" / OPENCODE_HOOK_PLUGIN_NAME
421
+
422
+
99
423
  def _hook_command(project_root: Path, client: str, event: str) -> str:
100
424
  return _format_command([
101
425
  "devcouncil",
@@ -108,6 +432,35 @@ def _hook_command(project_root: Path, client: str, event: str) -> str:
108
432
  ])
109
433
 
110
434
 
435
+ def _probe_mcp_tools(root: Path, *, timeout_seconds: float = 30.0) -> list[str]:
436
+ from mcp import ClientSession, StdioServerParameters
437
+ from mcp.client.stdio import stdio_client
438
+ import asyncio
439
+ import os
440
+
441
+ async def _list_tools() -> list[str]:
442
+ env = os.environ.copy()
443
+ env["DEVCOUNCIL_PROJECT_ROOT"] = str(root)
444
+ params = StdioServerParameters(
445
+ command=sys.executable,
446
+ args=["-m", "devcouncil", "mcp-server"],
447
+ cwd=str(root),
448
+ env=env,
449
+ )
450
+ async with stdio_client(params) as (read, write):
451
+ async with ClientSession(read, write) as session:
452
+ await session.initialize()
453
+ tools = await session.list_tools()
454
+ return [tool.name for tool in tools.tools]
455
+
456
+ async def _list_tools_with_deadline() -> list[str]:
457
+ # A wedged server process would otherwise block `dev integrate check`
458
+ # indefinitely; the caller treats TimeoutError as a failed probe.
459
+ return await asyncio.wait_for(_list_tools(), timeout=timeout_seconds)
460
+
461
+ return asyncio.run(_list_tools_with_deadline())
462
+
463
+
111
464
  def _run(command: list[str]) -> int:
112
465
  executable = shutil.which(command[0])
113
466
  if not executable:
@@ -115,7 +468,10 @@ def _run(command: list[str]) -> int:
115
468
  resolved = [executable, *command[1:]]
116
469
  use_shell = sys.platform == "win32" and Path(executable).suffix.lower() in {".bat", ".cmd", ".ps1"}
117
470
  invocation = subprocess.list2cmdline(resolved) if use_shell else resolved
118
- result = subprocess.run(invocation, text=True, shell=use_shell)
471
+ try:
472
+ result = subprocess.run(invocation, text=True, shell=use_shell)
473
+ except (FileNotFoundError, OSError):
474
+ return 127
119
475
  return result.returncode
120
476
 
121
477
 
@@ -136,9 +492,12 @@ def _run_capture(command: list[str], timeout: int = 10) -> tuple[int, str]:
136
492
  errors="replace",
137
493
  shell=use_shell,
138
494
  timeout=timeout,
495
+ env=clean_subprocess_env(),
139
496
  )
140
497
  except subprocess.TimeoutExpired:
141
498
  return 124, "timed out"
499
+ except (FileNotFoundError, OSError) as exc:
500
+ return 127, f"{command[0]} could not be executed: {exc}"
142
501
  return result.returncode, (result.stdout + result.stderr).strip()
143
502
 
144
503
 
@@ -272,24 +631,104 @@ def _install_gemini_hooks(project_root: Path) -> list[Path]:
272
631
  return [path]
273
632
 
274
633
 
275
- def _install_claude_hooks(project_root: Path) -> list[Path]:
276
- path = project_root / ".claude" / "settings.local.json"
634
+ def _upsert_cursor_hook(settings: dict, event: str, matcher: str, command: str) -> None:
635
+ hooks = settings.setdefault("hooks", {})
636
+ entries = hooks.setdefault(event, [])
637
+ for entry in entries:
638
+ if entry.get("command") == command:
639
+ return
640
+ payload: dict = {"command": command}
641
+ if matcher:
642
+ payload["matcher"] = matcher
643
+ entries.append(payload)
644
+
645
+
646
+ def _install_cursor_hooks(project_root: Path) -> list[Path]:
647
+ path = project_root / ".cursor" / "hooks.json"
277
648
  settings = _load_json(path)
278
- matcher = "Bash|Write|Edit|MultiEdit"
279
- _upsert_hook(
649
+ settings.setdefault("version", 1)
650
+ matcher = "Shell|Write|Edit|MultiEdit|Read|Task"
651
+ _upsert_cursor_hook(
280
652
  settings,
281
- "PreToolUse",
653
+ "preToolUse",
282
654
  matcher,
283
- _hook_command(project_root, "claude", "pre-tool-use"),
284
- "devcouncil-pre-tool-use",
655
+ _hook_command(project_root, "cursor", "pre-tool-use"),
285
656
  )
286
- _upsert_hook(
657
+ _upsert_cursor_hook(
287
658
  settings,
288
- "PostToolUse",
659
+ "postToolUse",
289
660
  matcher,
290
- _hook_command(project_root, "claude", "post-tool-use"),
291
- "devcouncil-post-tool-use",
661
+ _hook_command(project_root, "cursor", "post-tool-use"),
292
662
  )
663
+ _save_json(path, settings)
664
+
665
+ def mutate(config: dict) -> None:
666
+ cursor = config.setdefault("integrations", {}).setdefault("cursor", {})
667
+ cursor.update({
668
+ "hooks_path": str(path.relative_to(project_root)),
669
+ })
670
+
671
+ _mutate_raw_config(project_root, mutate)
672
+ return [path]
673
+
674
+
675
+ def _install_opencode_hooks(project_root: Path) -> list[Path]:
676
+ source = _opencode_plugin_source()
677
+ if not source.exists():
678
+ raise FileNotFoundError(f"Missing bundled OpenCode hook plugin: {source}")
679
+ destination = _opencode_plugin_path(project_root)
680
+ destination.parent.mkdir(parents=True, exist_ok=True)
681
+ destination.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
682
+
683
+ path = _opencode_config_path(project_root)
684
+ data = _load_json_strict(path, "OpenCode") if path.exists() else {"$schema": "https://opencode.ai/config.json"}
685
+ data.setdefault("$schema", "https://opencode.ai/config.json")
686
+ plugins_raw = data.setdefault("plugin", [])
687
+ if not isinstance(plugins_raw, list):
688
+ plugins_raw = []
689
+ data["plugin"] = plugins_raw
690
+ plugins: list[str] = [str(item) for item in plugins_raw]
691
+ data["plugin"] = plugins
692
+ plugin_ref = f"./.devcouncil/integrations/{OPENCODE_HOOK_PLUGIN_NAME}"
693
+ if plugin_ref not in plugins:
694
+ plugins.append(plugin_ref)
695
+ _save_json(path, data)
696
+ _record_opencode_config(project_root)
697
+ return [destination, path]
698
+
699
+
700
+ def _install_claude_hooks(project_root: Path, *, write_gate: bool = False) -> list[Path]:
701
+ """Install DevCouncil's Claude Code hooks into .claude/settings.local.json.
702
+
703
+ By default this installs only the *assistive* lifecycle hooks (status injection on
704
+ SessionStart/UserPromptSubmit, the live-review Stop signal, and the SessionEnd/
705
+ PreCompact/SubagentStop/Notification trace hooks). These never block a tool call.
706
+
707
+ The blocking pre-action **write-gate** (PreToolUse/PostToolUse, which denies any
708
+ Bash/Write/Edit not authorized by an active task lease) is installed ONLY when
709
+ ``write_gate`` is True. It is meant for autonomous executor runs, not interactive
710
+ human sessions — in an interactive session there is no task lease, so the gate would
711
+ fail-closed and deny every command. (``dev run --executor claude`` does its own
712
+ post-hoc scope enforcement and does not depend on this hook, so leaving it off by
713
+ default loses no containment.)"""
714
+ path = project_root / ".claude" / "settings.local.json"
715
+ settings = _load_json(path)
716
+ matcher = "Bash|Write|Edit|MultiEdit"
717
+ if write_gate:
718
+ _upsert_hook(
719
+ settings,
720
+ "PreToolUse",
721
+ matcher,
722
+ _hook_command(project_root, "claude", "pre-tool-use"),
723
+ "devcouncil-pre-tool-use",
724
+ )
725
+ _upsert_hook(
726
+ settings,
727
+ "PostToolUse",
728
+ matcher,
729
+ _hook_command(project_root, "claude", "post-tool-use"),
730
+ "devcouncil-post-tool-use",
731
+ )
293
732
  _upsert_hook(
294
733
  settings,
295
734
  "Stop",
@@ -297,41 +736,324 @@ def _install_claude_hooks(project_root: Path) -> list[Path]:
297
736
  _hook_command(project_root, "claude", "agent-response"),
298
737
  "devcouncil-agent-response-ready",
299
738
  )
739
+ # Lifecycle events: status-on-start/prompt, teardown, compaction, subagent finish,
740
+ # and notifications. These complete DevCouncil's coverage of the documented Claude
741
+ # Code hook surface beyond the pre/post/stop gate.
742
+ _upsert_hook(
743
+ settings,
744
+ "SessionStart",
745
+ "startup|resume",
746
+ _hook_command(project_root, "claude", "session-start"),
747
+ "devcouncil-session-start",
748
+ )
749
+ _upsert_hook(
750
+ settings,
751
+ "UserPromptSubmit",
752
+ "",
753
+ _hook_command(project_root, "claude", "user-prompt-submit"),
754
+ "devcouncil-user-prompt-submit",
755
+ )
756
+ _upsert_hook(
757
+ settings,
758
+ "SessionEnd",
759
+ "",
760
+ _hook_command(project_root, "claude", "session-end"),
761
+ "devcouncil-session-end",
762
+ )
763
+ _upsert_hook(
764
+ settings,
765
+ "PreCompact",
766
+ "",
767
+ _hook_command(project_root, "claude", "pre-compact"),
768
+ "devcouncil-pre-compact",
769
+ )
770
+ _upsert_hook(
771
+ settings,
772
+ "SubagentStop",
773
+ "",
774
+ _hook_command(project_root, "claude", "subagent-stop"),
775
+ "devcouncil-subagent-stop",
776
+ )
777
+ _upsert_hook(
778
+ settings,
779
+ "Notification",
780
+ "",
781
+ _hook_command(project_root, "claude", "notification"),
782
+ "devcouncil-notification",
783
+ )
300
784
  _save_json(path, settings)
301
785
  return [path]
302
786
 
303
787
 
788
+ def _devcouncil_version() -> str:
789
+ """Package version for plugin manifests, or a stable placeholder when uninstalled."""
790
+ import importlib.metadata
791
+
792
+ try:
793
+ return importlib.metadata.version("devcouncil")
794
+ except importlib.metadata.PackageNotFoundError:
795
+ return "0.0.0"
796
+
797
+
798
+ # Read-only DevCouncil commands the generated slash commands / hooks shell out to. Adding
799
+ # them to the Claude permissions allow-list keeps the integration from prompting on every
800
+ # `dev status`/`dev report` the slash commands run.
801
+ _CLAUDE_PERMISSION_ALLOW = [
802
+ "Bash(dev status:*)",
803
+ "Bash(dev report:*)",
804
+ "Bash(dev tasks:*)",
805
+ "Bash(dev verify:*)",
806
+ "Bash(dev repair:*)",
807
+ "Bash(dev plan:*)",
808
+ "Bash(dev watch:*)",
809
+ "Bash(devcouncil mcp-server)",
810
+ ]
811
+
812
+
813
+ def _install_claude_settings(project_root: Path) -> tuple[Path, bool]:
814
+ """Write the statusLine, MCP enablement, and permission allow-list into Claude settings.
815
+
816
+ Merges into .claude/settings.local.json without clobbering existing user entries.
817
+ Returns (path, changed); only rewrites the file when the merge changes something so
818
+ re-running integration is a true no-op."""
819
+ path = project_root / ".claude" / "settings.local.json"
820
+ settings = _load_json(path)
821
+ before = json.dumps(settings, sort_keys=True)
822
+
823
+ settings["statusLine"] = {
824
+ "type": "command",
825
+ "command": "devcouncil hook claude-statusline",
826
+ }
827
+ # Auto-enable the project-scoped DevCouncil MCP server so a teammate cloning the repo
828
+ # doesn't have to approve it interactively.
829
+ enabled = settings.setdefault("enabledMcpjsonServers", [])
830
+ if isinstance(enabled, list) and "devcouncil" not in enabled:
831
+ enabled.append("devcouncil")
832
+
833
+ permissions = settings.setdefault("permissions", {})
834
+ if isinstance(permissions, dict):
835
+ allow = permissions.setdefault("allow", [])
836
+ if isinstance(allow, list):
837
+ for rule in _CLAUDE_PERMISSION_ALLOW:
838
+ if rule not in allow:
839
+ allow.append(rule)
840
+
841
+ changed = json.dumps(settings, sort_keys=True) != before
842
+ if changed:
843
+ _save_json(path, settings)
844
+ return path, changed
845
+
846
+
847
+ def _selected_skill_assets(project_root: Path):
848
+ """Scaffold the applicable skills and return them as GeneratedAsset-like records.
849
+
850
+ Returns (written_paths, skill_assets) where skill_assets carry (path, content) for the
851
+ plugin bundler so the plugin ships the same skill bodies that land in .claude/skills/."""
852
+ from devcouncil.integrations.claude_assets import GeneratedAsset
853
+ from devcouncil.skills.registry import scaffold_skills, select_skills
854
+
855
+ skills = select_skills("", project_root)
856
+ written = scaffold_skills(project_root, skills)
857
+ assets: list[GeneratedAsset] = []
858
+ skills_root = project_root / ".claude" / "skills"
859
+ for skill in skills:
860
+ target = skills_root / skill.name / "SKILL.md"
861
+ if target.exists():
862
+ assets.append(GeneratedAsset(target, target.read_text(encoding="utf-8")))
863
+ return written, assets
864
+
865
+
866
+ def _install_claude_assets(project_root: Path) -> list[Path]:
867
+ """Generate the static Claude Code asset surface (commands, agents, output style,
868
+ statusline, permissions) and scaffold the applicable skills. Idempotent."""
869
+ from devcouncil.integrations import claude_assets
870
+
871
+ written: list[Path] = []
872
+ assets: list[claude_assets.GeneratedAsset] = []
873
+ assets += claude_assets.build_slash_commands(project_root)
874
+ assets += claude_assets.build_subagents(project_root)
875
+ assets += claude_assets.build_output_style(project_root)
876
+ for asset in assets:
877
+ if asset.write_if_changed():
878
+ written.append(asset.path)
879
+
880
+ skills_written, _ = _selected_skill_assets(project_root)
881
+ written.extend(skills_written)
882
+ settings_path, settings_changed = _install_claude_settings(project_root)
883
+ if settings_changed:
884
+ written.append(settings_path)
885
+ return written
886
+
887
+
888
+ def _install_claude_plugin(project_root: Path, *, write_gate: bool = False) -> list[Path]:
889
+ """Build the self-contained Claude Code plugin + single-repo marketplace bundle.
890
+
891
+ Bundles the commands, agents, applicable skills, hooks, and MCP config so the entire
892
+ DevCouncil integration installs with one `/plugin install`. Assist-mode hooks by
893
+ default; pass write_gate=True to bundle the blocking containment gate."""
894
+ from devcouncil.integrations import claude_assets
895
+
896
+ _, skill_assets = _selected_skill_assets(project_root)
897
+ bundle = claude_assets.build_plugin_bundle(
898
+ project_root, version=_devcouncil_version(), skill_assets=skill_assets, write_gate=write_gate
899
+ )
900
+ return [asset.path for asset in bundle if asset.write_if_changed()]
901
+
902
+
903
+ def _uninstall_claude(project_root: Path) -> list[str]:
904
+ """Remove everything DevCouncil installed into a Claude Code project. Idempotent.
905
+
906
+ Strips DevCouncil's hooks (every event), the DevCouncil statusLine, the MCP enablement
907
+ and permission rules from .claude/settings.local.json (leaving any user-authored
908
+ entries untouched), deletes the generated commands/subagents/output-style files, and
909
+ best-effort de-registers the MCP server via `claude mcp remove`. Returns a list of the
910
+ changes made. The recoverable, in-band counterpart to a fail-closed write-gate."""
911
+ removed: list[str] = []
912
+ path = project_root / ".claude" / "settings.local.json"
913
+ settings = _load_json(path)
914
+ before = json.dumps(settings, sort_keys=True)
915
+
916
+ # Hooks: drop any entry whose command invokes `devcouncil hook`, then prune empties.
917
+ hooks = settings.get("hooks")
918
+ if isinstance(hooks, dict):
919
+ for event in list(hooks):
920
+ groups = hooks.get(event)
921
+ if not isinstance(groups, list):
922
+ continue
923
+ kept_groups = []
924
+ for group in groups:
925
+ inner = group.get("hooks", []) if isinstance(group, dict) else []
926
+ inner_kept = [
927
+ h for h in inner
928
+ if "devcouncil hook" not in str(h.get("command", ""))
929
+ ]
930
+ if inner_kept:
931
+ group["hooks"] = inner_kept
932
+ kept_groups.append(group)
933
+ if kept_groups:
934
+ hooks[event] = kept_groups
935
+ else:
936
+ hooks.pop(event)
937
+ if not hooks:
938
+ settings.pop("hooks")
939
+ removed.append(f"hooks in {path.name}")
940
+
941
+ # statusLine: only remove ours.
942
+ status = settings.get("statusLine")
943
+ if isinstance(status, dict) and "devcouncil" in str(status.get("command", "")):
944
+ settings.pop("statusLine")
945
+ removed.append("statusLine")
946
+
947
+ enabled = settings.get("enabledMcpjsonServers")
948
+ if isinstance(enabled, list) and "devcouncil" in enabled:
949
+ enabled.remove("devcouncil")
950
+ if not enabled:
951
+ settings.pop("enabledMcpjsonServers")
952
+ removed.append("enabledMcpjsonServers entry")
953
+
954
+ permissions = settings.get("permissions")
955
+ if isinstance(permissions, dict) and isinstance(permissions.get("allow"), list):
956
+ kept = [r for r in permissions["allow"] if r not in _CLAUDE_PERMISSION_ALLOW]
957
+ if len(kept) != len(permissions["allow"]):
958
+ permissions["allow"] = kept
959
+ removed.append("permission allow-rules")
960
+ if not permissions.get("allow"):
961
+ permissions.pop("allow", None)
962
+ if not permissions:
963
+ settings.pop("permissions")
964
+
965
+ if json.dumps(settings, sort_keys=True) != before:
966
+ if settings:
967
+ _save_json(path, settings)
968
+ elif path.exists():
969
+ path.unlink()
970
+ removed.append(f"deleted empty {path.name}")
971
+
972
+ # Generated asset files.
973
+ targets = [
974
+ project_root / ".claude" / "commands" / "devcouncil",
975
+ project_root / ".claude" / "output-styles" / "devcouncil.md",
976
+ ]
977
+ targets += [
978
+ project_root / ".claude" / "agents" / f"{name}.md"
979
+ for name in ("devcouncil-implementer", "devcouncil-verifier", "devcouncil-reviewer")
980
+ ]
981
+ for target in targets:
982
+ if target.is_dir():
983
+ shutil.rmtree(target)
984
+ removed.append(str(target.relative_to(project_root)))
985
+ elif target.exists():
986
+ target.unlink()
987
+ removed.append(str(target.relative_to(project_root)))
988
+
989
+ # De-register the MCP server (best-effort; only if the claude CLI is present).
990
+ if shutil.which("claude"):
991
+ code = _run(["claude", "mcp", "remove", "devcouncil"])
992
+ if code == 0:
993
+ removed.append("claude mcp server registration")
994
+
995
+ return removed
996
+
997
+
304
998
  def _preview_hook_paths(project_root: Path, tool: str) -> list[tuple[str, Path]]:
305
999
  paths = {
306
1000
  "codex": [project_root / ".codex" / "hooks.json", project_root / ".codex" / "config.toml"],
307
1001
  "gemini": [project_root / ".gemini" / "settings.json"],
308
1002
  "claude": [project_root / ".claude" / "settings.local.json"],
1003
+ "cursor": [project_root / ".cursor" / "hooks.json"],
1004
+ "opencode": [_opencode_plugin_path(project_root), _opencode_config_path(project_root)],
309
1005
  }
310
- selected = SUPPORTED_HOOK_TOOLS if tool == "all" else (tool,)
311
- return [(client, path) for client in selected for path in paths[client]]
1006
+ selected: tuple[str, ...]
1007
+ if tool == "all":
1008
+ selected = (*SUPPORTED_HOOK_TOOLS, "opencode")
1009
+ elif tool == "opencode":
1010
+ selected = ("opencode",)
1011
+ else:
1012
+ selected = (tool,)
1013
+ return [(client, path) for client in selected for path in paths.get(client, [])]
312
1014
 
313
1015
 
314
- def _configure_native_hooks(project_root: Path, tool: str = "all", apply: bool = False) -> None:
315
- if tool not in {"all", *SUPPORTED_HOOK_TOOLS}:
316
- console.print("[red]--tool must be one of: all, codex, gemini, claude.[/red]")
1016
+ def _configure_native_hooks(
1017
+ project_root: Path, tool: str = "all", apply: bool = False, *, claude_write_gate: bool = False
1018
+ ) -> None:
1019
+ allowed = {"all", *SUPPORTED_HOOK_TOOLS, "opencode"}
1020
+ if tool not in allowed:
1021
+ console.print("[red]--tool must be one of: all, codex, gemini, claude, cursor, opencode.[/red]")
317
1022
  raise typer.Exit(code=2)
318
1023
 
319
1024
  if not apply:
320
1025
  console.print("[bold]Native hook config preview[/bold]")
321
1026
  for client, path in _preview_hook_paths(project_root, tool):
322
- console.print(f"{client}: {path}")
1027
+ console.print(f"{client}: {path}", soft_wrap=True)
323
1028
  console.print("[yellow]Preview only. Rerun with --apply to write hook config files.[/yellow]")
324
1029
  return
325
1030
 
326
- selected = SUPPORTED_HOOK_TOOLS if tool == "all" else (tool,)
1031
+ selected: tuple[str, ...]
1032
+ if tool == "all":
1033
+ selected = (*SUPPORTED_HOOK_TOOLS, "opencode")
1034
+ elif tool == "opencode":
1035
+ selected = ("opencode",)
1036
+ else:
1037
+ selected = (tool,)
327
1038
  installers = {
328
1039
  "codex": _install_codex_hooks,
329
1040
  "gemini": _install_gemini_hooks,
330
- "claude": _install_claude_hooks,
1041
+ # Claude's blocking write-gate is opt-in (assist-mode default); the other clients
1042
+ # install their native pre/post hooks unconditionally as before.
1043
+ "claude": lambda root: _install_claude_hooks(root, write_gate=claude_write_gate),
1044
+ "cursor": _install_cursor_hooks,
1045
+ "opencode": _install_opencode_hooks,
331
1046
  }
332
- for client in selected:
333
- written = installers[client](project_root)
334
- console.print(f"[green]{client} native hooks configured:[/green] {', '.join(str(path) for path in written)}")
1047
+ # Batch the per-installer config.yaml record updates (cursor/opencode)
1048
+ # into one load/save instead of re-parsing YAML per tool.
1049
+ with _batched_raw_config(project_root):
1050
+ for client in selected:
1051
+ try:
1052
+ written = installers[client](project_root)
1053
+ except (ValueError, FileNotFoundError) as exc:
1054
+ console.print(f"[red]{client} hook setup failed: {exc}[/red]")
1055
+ raise typer.Exit(code=1) from exc
1056
+ console.print(f"[green]{client} native hooks configured:[/green] {', '.join(str(path) for path in written)}")
335
1057
 
336
1058
 
337
1059
  def _print_command(tool: str, command: list[str], apply: bool):
@@ -378,22 +1100,37 @@ def overview(ctx: typer.Context):
378
1100
  table.add_column("Notes")
379
1101
  table.add_row("Codex CLI", f"{PREFERRED_COMMAND} codex --apply", "Adds DevCouncil as a stdio MCP server.")
380
1102
  table.add_row("Gemini CLI", f"{PREFERRED_COMMAND} gemini --apply", "Adds DevCouncil as a project-scoped stdio MCP server.")
381
- table.add_row("Claude Code", f"{PREFERRED_COMMAND} claude --apply", "Adds DevCouncil as a Claude Code MCP server.")
382
- table.add_row("Cursor", f"{PREFERRED_COMMAND} cursor --apply", "Adds DevCouncil as a Cursor MCP server.")
1103
+ table.add_row("Claude Code", f"{PREFERRED_COMMAND} claude --apply", "MCP + assistive hooks + slash commands, subagents, output style, skills, statusline. Add --write-gate for blocking containment.")
1104
+ table.add_row("Claude assets", f"{PREFERRED_COMMAND} claude-assets --apply", "Slash commands, subagents, output style, statusline, permissions, skills (no MCP/hooks).")
1105
+ table.add_row("Claude plugin", f"{PREFERRED_COMMAND} claude-plugin --apply", "Self-contained Claude Code plugin + marketplace bundling everything for /plugin install.")
1106
+ table.add_row("Claude uninstall", f"{PREFERRED_COMMAND} claude --uninstall", "Remove DevCouncil hooks, statusline, MCP enablement, and generated assets from .claude/.")
1107
+ table.add_row("Cursor", f"{PREFERRED_COMMAND} cursor --apply", "Writes project .cursor/mcp.json for Cursor editor and cursor-agent.")
1108
+ table.add_row("OpenCode", f"{PREFERRED_COMMAND} opencode --apply", "Adds DevCouncil as a project-scoped OpenCode MCP server and executor.")
1109
+ table.add_row("Google Antigravity CLI", f"{PREFERRED_COMMAND} antigravity --apply", "Writes project .agents/mcp_config.json and enables the agy executor.")
1110
+ table.add_row("Warp / Oz", f"{PREFERRED_COMMAND} warp --apply", "Writes a Warp-compatible MCP JSON file for local agents and Oz CLI.")
1111
+ table.add_row("Aider", f"{PREFERRED_COMMAND} aider --apply", "Enables the built-in Aider headless executor (no MCP).")
1112
+ table.add_row("Bring your own CLI", f"{PREFERRED_COMMAND} cli-agent NAME --command TOOL --apply", "Registers any prompt-taking CLI as a DevCouncil executor.")
383
1113
  table.add_row("All", f"{PREFERRED_COMMAND} all --apply", "Runs MCP setup and installs native hooks.")
384
- table.add_row("Native hooks", f"{PREFERRED_COMMAND} hooks --apply", "Installs Codex, Gemini, and Claude hook files.")
1114
+ table.add_row("Native hooks", f"{PREFERRED_COMMAND} hooks --apply", "Installs Codex, Gemini, Claude, Cursor, and OpenCode hook files.")
1115
+ table.add_row("Recommend", f"{PREFERRED_COMMAND} recommend", "Show the best executor for this machine and project.")
1116
+ table.add_row("Status", f"{PREFERRED_COMMAND} status", "Compact PATH + config summary (no MCP probe).")
1117
+ table.add_row("Matrix", f"{PREFERRED_COMMAND} matrix", "Print built-in coding CLI integration tiers.")
1118
+ table.add_row("Check", f"{PREFERRED_COMMAND} check", "Verify MCP, hooks, and optional CLIs (--strict, --json for CI).")
385
1119
  console.print(table)
386
1120
  console.print(f"\nIf your install exposes only the setup flow, use: {LEGACY_COMMAND} --apply")
387
1121
  console.print("\nRun without [bold]--apply[/bold] to preview the exact commands first.")
388
1122
 
389
1123
 
390
1124
  @app.command("doctor")
391
- def integrations_doctor():
1125
+ def integrations_doctor(
1126
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1127
+ ):
392
1128
  """Check optional integration tools and local client wiring prerequisites."""
1129
+ root = _project_root(project_root)
393
1130
  table = Table(title="DevCouncil Integration Doctor")
394
- table.add_column("Integration", style="cyan")
1131
+ table.add_column("Integration", style="cyan", no_wrap=True)
395
1132
  table.add_column("Status")
396
- table.add_column("Notes")
1133
+ table.add_column("Notes", overflow="fold")
397
1134
 
398
1135
  checks = [
399
1136
  ("Agent Flow", "agent-flow-app", "Optional live/replay visualizer for trace JSONL."),
@@ -401,14 +1138,34 @@ def integrations_doctor():
401
1138
  ("Claude Code", "claude", "Optional MCP client and native hook runtime for pre-tool-use enforcement."),
402
1139
  ("Codex CLI", "codex", "Optional MCP client, headless executor companion, and native hook runtime."),
403
1140
  ("Gemini CLI", "gemini", "Optional MCP client companion and native hook runtime."),
404
- ("Cursor", "cursor", "Optional MCP client and agent companion."),
405
- ("Aider", "aider", "Optional prompt/stdin sidecar; no first-party MCP setup command."),
1141
+ ("Cursor", "cursor-agent", "Optional MCP client, cursor-agent executor, and native hooks."),
1142
+ ("OpenCode", "opencode", "Optional MCP client and headless coding-agent executor."),
1143
+ ("Google Antigravity CLI", "agy", "Optional Antigravity CLI companion and headless coding-agent executor."),
1144
+ ("Warp / Oz", "oz", "Optional Warp/Oz CLI companion and agent executor."),
1145
+ ("Aider", "aider", "Optional headless executor via `dev run --executor aider` (no MCP)."),
406
1146
  ]
407
1147
  for label, executable, notes in checks:
408
1148
  found = shutil.which(executable)
409
1149
  table.add_row(label, "[green]OK[/green]" if found else "[yellow]Missing[/yellow]", found or notes)
410
1150
 
411
- config = _config_path(Path("."))
1151
+ profiles = load_agent_profiles(root)
1152
+ for name, spec in load_cli_agent_specs(root).items():
1153
+ if spec.built_in:
1154
+ continue
1155
+ found = shutil.which(spec.executable)
1156
+ mode_ok = spec.input_mode in VALID_INPUT_MODES
1157
+ profile_ok = spec.default_profile in profiles
1158
+ status = "[green]OK[/green]" if found and mode_ok and profile_ok else "[red]Invalid[/red]"
1159
+ if not found:
1160
+ status = "[yellow]Missing[/yellow]"
1161
+ details = found or f"{spec.executable} not found on PATH"
1162
+ if not mode_ok:
1163
+ details = f"invalid input_mode={spec.input_mode}"
1164
+ if not profile_ok:
1165
+ details = f"{details}; missing profile={spec.default_profile}"
1166
+ table.add_row(f"CLI agent: {name}", status, details)
1167
+
1168
+ config = _config_path(root)
412
1169
  table.add_row(
413
1170
  "DevCouncil config",
414
1171
  "[green]OK[/green]" if config.exists() else "[red]Missing[/red]",
@@ -457,36 +1214,346 @@ def claude(
457
1214
  apply: bool = typer.Option(False, "--apply", help="Run the setup command instead of printing it."),
458
1215
  project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
459
1216
  scope: str = typer.Option("local", "--scope", help="Claude MCP config scope: local, project, or user."),
1217
+ write_gate: bool = typer.Option(
1218
+ False,
1219
+ "--write-gate/--no-write-gate",
1220
+ "--contain/--no-contain",
1221
+ help="Also install the blocking PreToolUse/PostToolUse write-gate (containment). "
1222
+ "Off by default — it denies any tool call not authorized by an active task lease, "
1223
+ "which fail-closes an interactive session. Use it for autonomous executor runs.",
1224
+ ),
1225
+ uninstall: bool = typer.Option(
1226
+ False,
1227
+ "--uninstall",
1228
+ help="Remove DevCouncil's Claude hooks, statusline, MCP enablement, and generated assets.",
1229
+ ),
460
1230
  ):
461
1231
  """
462
- Set up DevCouncil MCP tools for Claude Code.
1232
+ Set up DevCouncil for Claude Code: MCP server + assistive hooks + slash commands,
1233
+ subagents, output style, skills, and statusline. The blocking write-gate is opt-in
1234
+ via --write-gate. Use --uninstall to remove everything DevCouncil installed.
463
1235
  """
1236
+ root = _project_root(project_root)
1237
+ if uninstall:
1238
+ removed = _uninstall_claude(root)
1239
+ if removed:
1240
+ console.print(f"[green]Removed DevCouncil Claude integration[/green] ({len(removed)} change(s)):")
1241
+ for item in removed:
1242
+ console.print(f" {item}")
1243
+ else:
1244
+ console.print("[dim]Nothing to remove — DevCouncil Claude integration not found.[/dim]")
1245
+ return
1246
+
464
1247
  if scope not in {"local", "project", "user"}:
465
1248
  console.print("[red]--scope must be 'local', 'project', or 'user'.[/red]")
466
1249
  raise typer.Exit(code=2)
467
1250
 
468
- root = _project_root(project_root)
469
1251
  command = _claude_command(root, scope)
470
1252
  ok = _configure("Claude Code", command, apply)
1253
+ if apply:
1254
+ # One-shot: MCP server + assistive hooks (write-gate only with --write-gate) + the
1255
+ # static asset surface (slash commands, subagents, output style, skills, statusline).
1256
+ try:
1257
+ written = _install_claude_hooks(root, write_gate=write_gate)
1258
+ written += _install_claude_assets(root)
1259
+ except (ValueError, FileNotFoundError, OSError) as exc:
1260
+ console.print(f"[red]Claude asset setup failed: {exc}[/red]")
1261
+ raise typer.Exit(code=1) from exc
1262
+ mode = "with write-gate (containment)" if write_gate else "assist mode (no write-gate)"
1263
+ console.print(
1264
+ f"[green]Claude Code integration installed[/green] ({len(written)} file(s), {mode}): "
1265
+ "MCP, hooks, slash commands, subagents, output style, skills, statusline, permissions."
1266
+ )
1267
+ if not write_gate:
1268
+ console.print(
1269
+ "[dim]Add pre-action containment for autonomous runs with[/dim] "
1270
+ f"[dim]{PREFERRED_COMMAND} claude --apply --write-gate[/dim]"
1271
+ )
1272
+ console.print(
1273
+ "Bundle everything as an installable plugin with: "
1274
+ f"[dim]{PREFERRED_COMMAND} claude-plugin --apply[/dim]"
1275
+ )
471
1276
  if not ok and apply:
472
1277
  raise typer.Exit(code=1)
473
1278
 
474
1279
 
1280
+ @app.command("claude-assets")
1281
+ def claude_assets_cmd(
1282
+ apply: bool = typer.Option(False, "--apply", help="Write the Claude asset files instead of previewing them."),
1283
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1284
+ ):
1285
+ """
1286
+ Generate the Claude Code asset surface: slash commands, subagents, output style,
1287
+ statusline, permissions, and scaffolded skills (no MCP/hook registration).
1288
+ """
1289
+ from devcouncil.integrations import claude_assets as _assets
1290
+
1291
+ root = _project_root(project_root)
1292
+ if not apply:
1293
+ console.print("[bold]Claude Code assets (preview)[/bold]")
1294
+ preview = (
1295
+ _assets.build_slash_commands(root)
1296
+ + _assets.build_subagents(root)
1297
+ + _assets.build_output_style(root)
1298
+ )
1299
+ for asset in preview:
1300
+ console.print(f" {asset.path}", soft_wrap=True)
1301
+ console.print(" .claude/settings.local.json (statusLine + permissions + enabledMcpjsonServers)")
1302
+ console.print(" .claude/skills/<applicable>/SKILL.md")
1303
+ console.print("[yellow]Preview only. Rerun with --apply to write the files.[/yellow]")
1304
+ return
1305
+
1306
+ try:
1307
+ written = _install_claude_assets(root)
1308
+ except (ValueError, FileNotFoundError, OSError) as exc:
1309
+ console.print(f"[red]Claude asset setup failed: {exc}[/red]")
1310
+ raise typer.Exit(code=1) from exc
1311
+ console.print(f"[green]Wrote {len(written)} Claude asset file(s).[/green]")
1312
+ for path in written:
1313
+ try:
1314
+ console.print(f" {path.relative_to(root).as_posix()}")
1315
+ except ValueError:
1316
+ console.print(f" {path}")
1317
+
1318
+
1319
+ @app.command("claude-plugin")
1320
+ def claude_plugin_cmd(
1321
+ apply: bool = typer.Option(False, "--apply", help="Write the plugin bundle instead of previewing it."),
1322
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1323
+ write_gate: bool = typer.Option(
1324
+ False,
1325
+ "--write-gate/--no-write-gate",
1326
+ "--contain/--no-contain",
1327
+ help="Bundle Claude's blocking write-gate in the plugin hooks (off by default).",
1328
+ ),
1329
+ ):
1330
+ """
1331
+ Build a self-contained Claude Code plugin + single-repo marketplace bundling the
1332
+ DevCouncil commands, subagents, skills, hooks, and MCP server under
1333
+ .devcouncil/claude-plugin/ for one-command `/plugin install`.
1334
+ """
1335
+ from devcouncil.integrations.claude_assets import PLUGIN_ROOT_REL
1336
+
1337
+ root = _project_root(project_root)
1338
+ market_dir = root / PLUGIN_ROOT_REL
1339
+ if not apply:
1340
+ console.print("[bold]Claude Code plugin bundle (preview)[/bold]")
1341
+ console.print(f"Marketplace + plugin root: [dim]{market_dir}[/dim]")
1342
+ console.print("Install after --apply with:")
1343
+ console.print(f" [dim]/plugin marketplace add {market_dir}[/dim]")
1344
+ console.print(" [dim]/plugin install devcouncil@devcouncil-local[/dim]")
1345
+ console.print("[yellow]Preview only. Rerun with --apply to write the bundle.[/yellow]")
1346
+ return
1347
+
1348
+ try:
1349
+ written = _install_claude_plugin(root, write_gate=write_gate)
1350
+ except (ValueError, FileNotFoundError, OSError) as exc:
1351
+ console.print(f"[red]Claude plugin build failed: {exc}[/red]")
1352
+ raise typer.Exit(code=1) from exc
1353
+ console.print(f"[green]Built Claude plugin bundle[/green] ({len(written)} file(s)) at {market_dir}")
1354
+ console.print("Install it in Claude Code with:")
1355
+ console.print(f" [dim]/plugin marketplace add {market_dir}[/dim]")
1356
+ console.print(" [dim]/plugin install devcouncil@devcouncil-local[/dim]")
1357
+
1358
+
475
1359
  @app.command("cursor")
476
1360
  def cursor(
477
- apply: bool = typer.Option(False, "--apply", help="Run the setup command instead of printing it."),
1361
+ apply: bool = typer.Option(False, "--apply", help="Write project Cursor MCP config instead of printing it."),
478
1362
  project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
479
1363
  ):
480
1364
  """
481
1365
  Set up DevCouncil MCP tools for Cursor.
482
1366
  """
483
1367
  root = _project_root(project_root)
484
- command = _cursor_command(root)
485
- ok = _configure("Cursor", command, apply)
1368
+ if apply:
1369
+ report = apply_integration_target(root, "cursor")
1370
+ if not report.ok:
1371
+ console.print(report.to_json())
1372
+ raise typer.Exit(code=1)
1373
+ console.print("[green]Cursor integration configured.[/green]")
1374
+ return
1375
+ ok = _configure_cursor(root, apply)
486
1376
  if not ok and apply:
487
1377
  raise typer.Exit(code=1)
488
1378
 
489
1379
 
1380
+ @app.command("opencode")
1381
+ def opencode(
1382
+ apply: bool = typer.Option(False, "--apply", help="Write project OpenCode config instead of printing it."),
1383
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1384
+ ):
1385
+ """
1386
+ Set up DevCouncil MCP tools for OpenCode.
1387
+ """
1388
+ root = _project_root(project_root)
1389
+ if apply:
1390
+ report = apply_integration_target(root, "opencode")
1391
+ if not report.ok:
1392
+ console.print(report.to_json())
1393
+ raise typer.Exit(code=1)
1394
+ console.print("[green]OpenCode integration configured.[/green]")
1395
+ return
1396
+ ok = _configure_opencode(root, apply)
1397
+ if not ok and apply:
1398
+ raise typer.Exit(code=1)
1399
+
1400
+
1401
+ @app.command("agy")
1402
+ @app.command("antigravity")
1403
+ def antigravity(
1404
+ apply: bool = typer.Option(False, "--apply", help="Write project Antigravity MCP config instead of printing it."),
1405
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1406
+ ):
1407
+ """
1408
+ Set up DevCouncil MCP tools for Google Antigravity CLI.
1409
+ """
1410
+ root = _project_root(project_root)
1411
+ if apply:
1412
+ report = apply_integration_target(root, "antigravity")
1413
+ if not report.ok:
1414
+ console.print(report.to_json())
1415
+ raise typer.Exit(code=1)
1416
+ console.print("[green]Antigravity integration configured.[/green]")
1417
+ _warn_if_verify_only("antigravity")
1418
+ return
1419
+ ok = _configure_antigravity(root, apply)
1420
+ if not ok and apply:
1421
+ raise typer.Exit(code=1)
1422
+
1423
+
1424
+ @app.command("warp")
1425
+ def warp(
1426
+ apply: bool = typer.Option(False, "--apply", help="Write Warp MCP config instead of printing it."),
1427
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1428
+ ):
1429
+ """
1430
+ Set up DevCouncil MCP tools for Warp local agents and the Oz CLI.
1431
+ """
1432
+ root = _project_root(project_root)
1433
+ if apply:
1434
+ report = apply_integration_target(root, "warp")
1435
+ if not report.ok:
1436
+ console.print(report.to_json())
1437
+ raise typer.Exit(code=1)
1438
+ console.print("[green]Warp integration configured.[/green]")
1439
+ _warn_if_verify_only("warp")
1440
+ return
1441
+ _configure_warp(root, apply)
1442
+
1443
+
1444
+ def _record_aider_config(project_root: Path) -> None:
1445
+ def mutate(config: dict) -> None:
1446
+ config.setdefault("integrations", {}).setdefault("aider", {}).update({"enabled": True})
1447
+
1448
+ _mutate_raw_config(project_root, mutate)
1449
+
1450
+
1451
+ def _configure_aider(project_root: Path, apply: bool) -> bool:
1452
+ command = ["aider", "--yes", "--no-show-model-warnings", "--message", "<task prompt>"]
1453
+ if not apply:
1454
+ console.print("[bold]Aider[/bold]")
1455
+ console.print("Built-in executor: [dim]dev run TASK-001 --executor aider[/dim]")
1456
+ console.print("Launch command: [dim]" + _format_command(command) + "[/dim]")
1457
+ console.print("Aider does not expose a first-party DevCouncil MCP server.")
1458
+ return True
1459
+
1460
+ if not shutil.which("aider"):
1461
+ console.print("[yellow]Aider CLI not found on PATH. Install it before using `dev run --executor aider`.[/yellow]")
1462
+ _record_aider_config(project_root)
1463
+ console.print("[green]Aider executor enabled in .devcouncil/config.yaml.[/green]")
1464
+ return True
1465
+
1466
+
1467
+ @app.command("aider")
1468
+ def aider(
1469
+ apply: bool = typer.Option(False, "--apply", help="Record the built-in Aider executor in DevCouncil config."),
1470
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1471
+ ):
1472
+ """
1473
+ Enable the built-in Aider headless executor (no MCP integration).
1474
+ """
1475
+ root = _project_root(project_root)
1476
+ if apply:
1477
+ report = apply_integration_target(root, "aider")
1478
+ if not report.ok:
1479
+ console.print(report.to_json())
1480
+ raise typer.Exit(code=1)
1481
+ console.print("[green]Aider integration configured.[/green]")
1482
+ _warn_if_verify_only("aider")
1483
+ return
1484
+ ok = _configure_aider(root, apply)
1485
+ if not ok and apply:
1486
+ raise typer.Exit(code=1)
1487
+
1488
+
1489
+ @app.command("cli-agent")
1490
+ def cli_agent(
1491
+ name: str = typer.Argument(..., help="Executor name to register, for example opencode or aider."),
1492
+ command: str = typer.Option(..., "--command", help="Executable to launch."),
1493
+ arg: list[str] | None = typer.Option(None, "--arg", help="Argument to pass to the CLI. Repeat for multiple args."),
1494
+ input_mode: str = typer.Option("stdin", "--input-mode", help="Prompt input mode: stdin, argument, or prompt-file."),
1495
+ prompt_arg: str | None = typer.Option(None, "--prompt-arg", help="Flag used before the prompt or prompt file, for example --prompt."),
1496
+ timeout_seconds: int | None = typer.Option(None, "--timeout-seconds", help="Agent-specific timeout override."),
1497
+ display_name: str | None = typer.Option(None, "--display-name", help="Human-readable agent name."),
1498
+ kind: str = typer.Option("custom", "--kind", help="Agent kind, for example coding-cli or review-cli."),
1499
+ supports_mcp: bool = typer.Option(False, "--supports-mcp", help="Mark this agent as MCP-capable."),
1500
+ supports_diff_review: bool = typer.Option(False, "--supports-diff-review", help="Mark this agent as able to review diffs."),
1501
+ default_profile: str = typer.Option("default", "--default-profile", help="Default execution profile for this agent."),
1502
+ help_arg: list[str] | None = typer.Option(None, "--help-arg", help="Argument for the agent help command. Repeat for multiple args."),
1503
+ apply: bool = typer.Option(False, "--apply", help="Write .devcouncil/config.yaml instead of previewing."),
1504
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1505
+ ):
1506
+ """
1507
+ Register an arbitrary prompt-taking CLI as a DevCouncil executor.
1508
+ """
1509
+ if input_mode not in VALID_INPUT_MODES:
1510
+ console.print("[red]--input-mode must be one of: stdin, argument, prompt-file.[/red]")
1511
+ raise typer.Exit(code=2)
1512
+ if not name.strip():
1513
+ console.print("[red]Agent name cannot be empty.[/red]")
1514
+ raise typer.Exit(code=2)
1515
+ if not command.strip():
1516
+ console.print("[red]--command cannot be empty.[/red]")
1517
+ raise typer.Exit(code=2)
1518
+
1519
+ root = _project_root(project_root)
1520
+ if is_reserved_agent_name(name):
1521
+ console.print(f"[red]'{name}' is reserved for a built-in DevCouncil agent.[/red]")
1522
+ raise typer.Exit(code=2)
1523
+ if default_profile not in load_agent_profiles(root):
1524
+ console.print(f"[red]Unknown --default-profile '{default_profile}'.[/red]")
1525
+ raise typer.Exit(code=2)
1526
+
1527
+ normalized = normalize_agent_name(name)
1528
+ entry = agent_config_entry(
1529
+ command=command,
1530
+ args=arg or [],
1531
+ input_mode=input_mode,
1532
+ prompt_arg=prompt_arg,
1533
+ timeout_seconds=timeout_seconds,
1534
+ display_name=display_name,
1535
+ kind=kind,
1536
+ supports_mcp=supports_mcp,
1537
+ supports_diff_review=supports_diff_review,
1538
+ default_profile=default_profile,
1539
+ help_command=[command, *(help_arg or [])] if help_arg else [],
1540
+ )
1541
+
1542
+ if not apply:
1543
+ console.print("[bold]Bring your own CLI executor preview[/bold]")
1544
+ console.print(f"Executor: [cyan]{normalized}[/cyan]")
1545
+ console.print(json.dumps(entry, indent=2), soft_wrap=True)
1546
+ console.print(f"Run with: [dim]dev run TASK-001 --executor {normalized}[/dim]")
1547
+ console.print("[yellow]Preview only. Rerun with --apply to update .devcouncil/config.yaml.[/yellow]")
1548
+ return
1549
+
1550
+ config = _load_raw_config(root)
1551
+ agents = config.setdefault("integrations", {}).setdefault("cli_agents", {}).setdefault("agents", {})
1552
+ agents[normalized] = entry
1553
+ _save_raw_config(root, config)
1554
+ console.print(f"[green]Registered CLI executor '{normalized}' in .devcouncil/config.yaml.[/green]")
1555
+
1556
+
490
1557
  @app.command("all")
491
1558
  def all_tools(
492
1559
  apply: bool = typer.Option(False, "--apply", help="Run setup commands instead of printing them."),
@@ -494,6 +1561,17 @@ def all_tools(
494
1561
  gemini_scope: str = typer.Option("project", "--gemini-scope", help="Gemini MCP config scope: project or user."),
495
1562
  claude_scope: str = typer.Option("local", "--claude-scope", help="Claude MCP config scope: local, project, or user."),
496
1563
  hooks: bool = typer.Option(True, "--hooks/--no-hooks", help="Include native Codex, Gemini, and Claude hook setup."),
1564
+ write_gate: bool = typer.Option(
1565
+ False,
1566
+ "--write-gate/--no-write-gate",
1567
+ "--contain/--no-contain",
1568
+ help="Install Claude's blocking write-gate too (off by default; for autonomous executor runs).",
1569
+ ),
1570
+ strict: bool = typer.Option(
1571
+ False,
1572
+ "--strict",
1573
+ help="After --apply, run dev integrate check --strict and fail on missing optional CLIs.",
1574
+ ),
497
1575
  ):
498
1576
  """
499
1577
  Set up DevCouncil MCP tools and native hooks for every supported coding CLI found on PATH.
@@ -506,116 +1584,285 @@ def all_tools(
506
1584
  raise typer.Exit(code=2)
507
1585
 
508
1586
  root = _project_root(project_root)
1587
+ if apply:
1588
+ report = apply_integration_target(
1589
+ root,
1590
+ "all",
1591
+ include_hooks=hooks,
1592
+ strict=strict,
1593
+ gemini_scope=gemini_scope,
1594
+ claude_scope=claude_scope,
1595
+ claude_write_gate=write_gate,
1596
+ )
1597
+ if not report.ok:
1598
+ console.print(report.to_json())
1599
+ raise typer.Exit(code=1)
1600
+ console.print("[green]Coding CLI integrations configured.[/green]")
1601
+ return
1602
+
509
1603
  commands = [
510
1604
  ("Codex CLI", _codex_command(root)),
511
1605
  ("Gemini CLI", _gemini_command(root, gemini_scope)),
512
1606
  ("Claude Code", _claude_command(root, claude_scope)),
513
- ("Cursor", _cursor_command(root)),
514
1607
  ]
515
- results = []
516
1608
  for tool, command in commands:
517
- if apply and not shutil.which(command[0]):
518
- console.print(f"[yellow]{tool} CLI not found on PATH. Skipping optional integration.[/yellow]")
519
- continue
520
- results.append(_configure(tool, command, apply))
1609
+ _configure(tool, command, apply)
1610
+ _configure_cursor(root, apply)
1611
+ _configure_opencode(root, apply)
1612
+ _configure_antigravity(root, apply)
1613
+ _configure_warp(root, apply)
1614
+ _configure_aider(root, apply)
521
1615
  if hooks:
522
1616
  _configure_native_hooks(root, "all", apply)
523
- if apply and not all(results):
524
- raise typer.Exit(code=1)
1617
+
1618
+
1619
+ @app.command("recommend")
1620
+ def recommend(
1621
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1622
+ ):
1623
+ """Recommend a coding CLI executor for this machine and project."""
1624
+ root = _project_root(project_root)
1625
+ probe_order = resolve_coding_cli_probe_order(root)
1626
+ detected = detect_available_coding_cli(root, probe_order=probe_order)
1627
+ resolved = resolve_automated_executor(root, None)
1628
+
1629
+ table = Table(title="DevCouncil Integration Recommendations")
1630
+ table.add_column("Client", style="cyan")
1631
+ table.add_column("PATH")
1632
+ table.add_column("Tier")
1633
+ table.add_column("MCP")
1634
+ table.add_column("Hooks")
1635
+
1636
+ for client in probe_order:
1637
+ info = CODING_CLI_INTEGRATION_INFO.get(client)
1638
+ on_path = resolve_coding_cli_executable(root, client)
1639
+ table.add_row(
1640
+ client,
1641
+ "[green]yes[/green]" if on_path else "[dim]no[/dim]",
1642
+ integration_tier_label(client),
1643
+ "yes" if info and info.mcp else "no",
1644
+ "yes" if info and info.hooks else "no",
1645
+ )
1646
+
1647
+ console.print(table)
1648
+ if summary := integration_status_summary(root):
1649
+ if summary.get("custom_probe_order"):
1650
+ console.print(
1651
+ f"\n[dim]Probe order:[/dim] {', '.join(summary['probe_order'])} "
1652
+ f"(from execution.coding_cli_probe_order)"
1653
+ )
1654
+ else:
1655
+ console.print(f"\n[dim]Probe order:[/dim] {', '.join(summary['probe_order'])} (default)")
1656
+ if detected:
1657
+ console.print(f"\n[bold]Recommended executor:[/bold] [cyan]{resolved}[/cyan]")
1658
+ console.print(f"Run: [dim]dev run TASK-001 --executor {resolved}[/dim]")
1659
+ console.print(f"Or: [dim]dev go \"Your goal\" --executor {resolved}[/dim]")
1660
+ console.print(f"Setup: [dim]{PREFERRED_COMMAND} {resolved} --apply[/dim]")
1661
+ else:
1662
+ console.print("\n[yellow]No built-in coding CLI was found on PATH.[/yellow]")
1663
+ console.print("Install Codex, Gemini, Claude Code, Cursor Agent, OpenCode, or register a custom CLI:")
1664
+ console.print(f"[dim]{PREFERRED_COMMAND} cli-agent NAME --command TOOL --apply[/dim]")
1665
+
1666
+
1667
+ @app.command("status")
1668
+ def status(
1669
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1670
+ as_json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
1671
+ ):
1672
+ """Show a compact integration summary without running the MCP server probe."""
1673
+ root = _project_root(project_root)
1674
+ summary = integration_status_summary(root)
1675
+ raw_config = _load_raw_config(root) if (root / ".devcouncil").exists() else {}
1676
+ integrations = raw_config.get("integrations", {})
1677
+
1678
+ if as_json:
1679
+ payload = {
1680
+ **summary,
1681
+ "integrations_enabled": {
1682
+ name: bool(integrations.get(name, {}).get("enabled"))
1683
+ for name in ("cursor", "opencode", "antigravity", "warp", "aider")
1684
+ },
1685
+ }
1686
+ typer.echo(json.dumps(payload, indent=2))
1687
+ return
1688
+
1689
+ table = Table(title="DevCouncil Integration Status")
1690
+ table.add_column("Setting", style="cyan")
1691
+ table.add_column("Value")
1692
+
1693
+ table.add_row("Project", "[green]initialized[/green]" if summary["project_initialized"] else "[yellow]not initialized[/yellow]")
1694
+ table.add_row("Default executor", summary["default_executor"])
1695
+ table.add_row("Resolved executor", summary["resolved_executor"])
1696
+ table.add_row("CLIs on PATH", ", ".join(summary["coding_clis_on_path"]) or "[dim]none[/dim]")
1697
+ table.add_row("Probe order", ", ".join(summary["probe_order"]))
1698
+ table.add_row("Stream CLI output", "yes" if summary["stream_cli_output"] else "no")
1699
+ table.add_row("Cursor resume mode", summary["cursor_resume_mode"])
1700
+
1701
+ for name in ("cursor", "opencode", "antigravity", "warp", "aider"):
1702
+ enabled = bool(integrations.get(name, {}).get("enabled"))
1703
+ table.add_row(f"{name} integration", "[green]enabled[/green]" if enabled else "[dim]off[/dim]")
1704
+
1705
+ console.print(table)
1706
+ if summary["resolved_executor"] not in {"", "manual"}:
1707
+ console.print(
1708
+ f"\n[dim]Next:[/dim] dev run TASK-001 --executor {summary['resolved_executor']} "
1709
+ f"| {PREFERRED_COMMAND} check for full readiness"
1710
+ )
1711
+ else:
1712
+ console.print(f"\n[dim]Next:[/dim] {PREFERRED_COMMAND} recommend | {PREFERRED_COMMAND} check")
1713
+
1714
+
1715
+ @app.command("matrix")
1716
+ def matrix(
1717
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1718
+ ):
1719
+ """Print built-in coding CLI integration tiers and capabilities."""
1720
+ root = _project_root(project_root)
1721
+ _ = root
1722
+ table = Table(title="DevCouncil Coding CLI Integration Matrix")
1723
+ table.add_column("Client", style="cyan")
1724
+ table.add_column("Tier")
1725
+ table.add_column("Headless")
1726
+ table.add_column("MCP setup")
1727
+ table.add_column("Native hooks")
1728
+ table.add_column("Enforcement")
1729
+ table.add_column("Notes")
1730
+
1731
+ for client in sorted(BUILTIN_CODING_EXECUTOR_NAMES):
1732
+ info = CODING_CLI_INTEGRATION_INFO.get(client)
1733
+ posture = info.enforcement if info else "verify-only"
1734
+ posture_render = "[green]pre-action[/green]" if posture == "pre-action" else "[yellow]verify-only[/yellow]"
1735
+ table.add_row(
1736
+ client,
1737
+ integration_tier_label(client),
1738
+ "yes" if info and info.tier == 1 else "no",
1739
+ "yes" if info and info.mcp else "no",
1740
+ "yes" if info and info.hooks else "verify only",
1741
+ posture_render,
1742
+ info.notes if info else "",
1743
+ )
1744
+ console.print(table)
1745
+ console.print(
1746
+ "\n[dim]Enforcement:[/dim] [green]pre-action[/green] blocks forbidden writes/commands "
1747
+ "before they happen; [yellow]verify-only[/yellow] catches them only at verify time."
1748
+ )
1749
+ console.print("\nSee [dim]docs/integration-tiers.md[/dim] for workflow guidance.")
525
1750
 
526
1751
 
527
1752
  @app.command("hooks")
528
1753
  def hooks(
529
1754
  apply: bool = typer.Option(False, "--apply", help="Write native hook config files instead of previewing paths."),
530
1755
  project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
531
- tool: str = typer.Option("all", "--tool", help="Hook target: all, codex, gemini, or claude."),
1756
+ tool: str = typer.Option("all", "--tool", help="Hook target: all, codex, gemini, claude, cursor, or opencode."),
1757
+ write_gate: bool = typer.Option(
1758
+ False,
1759
+ "--write-gate/--no-write-gate",
1760
+ "--contain/--no-contain",
1761
+ help="Install Claude's blocking PreToolUse/PostToolUse write-gate too (off by default; "
1762
+ "fail-closes an interactive session without a task lease).",
1763
+ ),
532
1764
  ):
533
1765
  """
534
- Install DevCouncil native hook configuration for hook-capable coding CLIs.
1766
+ Install DevCouncil hook configuration for Codex, Gemini, Claude, Cursor, and OpenCode.
1767
+
1768
+ Claude installs only assistive hooks by default; add --write-gate for pre-action
1769
+ containment (intended for autonomous executor runs).
535
1770
  """
536
1771
  root = _project_root(project_root)
537
- _configure_native_hooks(root, tool, apply)
1772
+ if apply and tool == "all":
1773
+ report = apply_integration_target(root, "hooks", claude_write_gate=write_gate)
1774
+ if not report.ok:
1775
+ console.print(report.to_json())
1776
+ raise typer.Exit(code=1)
1777
+ console.print("[green]Native hooks configured.[/green]")
1778
+ return
1779
+ _configure_native_hooks(root, tool, apply, claude_write_gate=write_gate)
1780
+
1781
+
1782
+ @app.command("uninstall")
1783
+ def uninstall(
1784
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1785
+ target: str = typer.Option("claude", "--target", help="What to uninstall. Currently: claude."),
1786
+ ):
1787
+ """
1788
+ Remove a DevCouncil integration. Reverses `dev integrate claude` — hooks, statusline,
1789
+ MCP enablement, permission rules, and the generated commands/subagents/output style.
1790
+ """
1791
+ root = _project_root(project_root)
1792
+ if target != "claude":
1793
+ console.print("[red]--target must be 'claude'.[/red]")
1794
+ raise typer.Exit(code=2)
1795
+ removed = _uninstall_claude(root)
1796
+ if removed:
1797
+ console.print(f"[green]Removed DevCouncil Claude integration[/green] ({len(removed)} change(s)):")
1798
+ for item in removed:
1799
+ console.print(f" {item}")
1800
+ else:
1801
+ console.print("[dim]Nothing to remove — DevCouncil Claude integration not found.[/dim]")
538
1802
 
539
1803
 
540
1804
  @app.command("check")
541
1805
  def check(
542
1806
  project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1807
+ strict: bool = typer.Option(
1808
+ False,
1809
+ "--strict",
1810
+ help="Treat missing optional coding CLIs as failures instead of warnings.",
1811
+ ),
1812
+ as_json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON for CI."),
1813
+ report_file: Path | None = typer.Option(
1814
+ None,
1815
+ "--report-file",
1816
+ "--output",
1817
+ "-o",
1818
+ help="Write the JSON integration report to this file (implies structured output).",
1819
+ ),
543
1820
  ):
544
1821
  """
545
1822
  Check whether DevCouncil is ready to integrate with coding CLIs.
546
1823
  """
547
1824
  root = _project_root(project_root)
1825
+ report = build_integration_check_report(root, strict=strict)
548
1826
  table = Table(title="DevCouncil Integration Check")
549
1827
  table.add_column("Check", style="cyan")
550
1828
  table.add_column("Status", style="magenta")
551
1829
  table.add_column("Details")
552
1830
 
553
- failures = 0
554
-
555
- def add(ok: bool, name: str, details: str):
556
- nonlocal failures
557
- table.add_row(name, "[green]OK[/green]" if ok else "[red]FAIL[/red]", details)
558
- if not ok:
559
- failures += 1
560
-
561
- add((root / ".devcouncil").exists(), "Project state", str(root / ".devcouncil"))
562
-
563
- devcouncil_path = shutil.which("devcouncil")
564
- add(devcouncil_path is not None, "devcouncil CLI", devcouncil_path or "Install DevCouncil first.")
565
-
566
- code, output = _run_capture(["devcouncil", "--help"])
567
- add(code == 0, "devcouncil command", output.splitlines()[0] if output else "No output")
568
-
569
- code, output = _run_capture(["codex", "--version"])
570
- add(code == 0, "Codex CLI", output.splitlines()[0] if output else "Optional; install Codex to use this integration.")
571
-
572
- code, output = _run_capture(["gemini", "--version"])
573
- add(code == 0, "Gemini CLI", output.splitlines()[0] if output else "Optional; install Gemini CLI to use this integration.")
574
-
575
- code, output = _run_capture(["claude", "--version"])
576
- add(code == 0, "Claude Code", output.splitlines()[0] if output else "Optional; install Claude Code to use this integration.")
577
-
578
- code, output = _run_capture(["cursor", "--version"])
579
- add(code == 0, "Cursor", output.splitlines()[0] if output else "Optional; install Cursor to use this integration.")
580
-
581
- try:
582
- from mcp import ClientSession, StdioServerParameters
583
- from mcp.client.stdio import stdio_client
584
-
585
- async def _list_tools() -> list[str]:
586
- import os
587
-
588
- env = os.environ.copy()
589
- env["DEVCOUNCIL_PROJECT_ROOT"] = str(root)
590
- params = StdioServerParameters(
591
- command=sys.executable,
592
- args=["-m", "devcouncil", "mcp-server"],
593
- cwd=str(root),
594
- env=env,
1831
+ for row in report.checks:
1832
+ if row.status == "ok":
1833
+ rendered = "[green]OK[/green]"
1834
+ elif row.status == "skip":
1835
+ rendered = "[dim]SKIP[/dim]"
1836
+ elif row.status == "missing":
1837
+ rendered = "[yellow]Missing[/yellow]"
1838
+ else:
1839
+ rendered = "[red]FAIL[/red]"
1840
+ table.add_row(row.name, rendered, row.details)
1841
+
1842
+ write_json = as_json or report_file is not None
1843
+ if write_json:
1844
+ json_text = report.to_json()
1845
+ if report_file is not None:
1846
+ report_path = Path(report_file).expanduser().resolve()
1847
+ report_path.parent.mkdir(parents=True, exist_ok=True)
1848
+ report_path.write_text(json_text + "\n", encoding="utf-8")
1849
+ if not as_json:
1850
+ console.print(f"[dim]Wrote integration report to[/dim] {report_path}")
1851
+ if as_json:
1852
+ typer.echo(json_text)
1853
+ if not write_json or not as_json:
1854
+ console.print(table)
1855
+
1856
+ if report.failures:
1857
+ if not as_json:
1858
+ console.print(
1859
+ f"\n[yellow]Fix failed checks, then run:[/yellow] {PREFERRED_COMMAND} all --apply "
1860
+ f"(or {LEGACY_COMMAND} --apply)."
595
1861
  )
596
- async with stdio_client(params) as (read, write):
597
- async with ClientSession(read, write) as session:
598
- await session.initialize()
599
- tools = await session.list_tools()
600
- return [tool.name for tool in tools.tools]
601
-
602
- import asyncio
603
-
604
- tools = asyncio.run(_list_tools())
605
- expected = {"devcouncil_status", "devcouncil_report", "devcouncil_get_task"}
606
- add(expected.issubset(set(tools)), "MCP server", ", ".join(tools))
607
- except Exception as exc:
608
- add(False, "MCP server", str(exc))
609
-
610
- console.print(table)
611
- if failures:
612
- console.print(
613
- f"\n[yellow]Fix failed checks, then run:[/yellow] {PREFERRED_COMMAND} all --apply "
614
- f"(or {LEGACY_COMMAND} --apply)."
615
- )
616
1862
  raise typer.Exit(code=1)
617
1863
 
618
- console.print(f"\n[green]Ready.[/green] Run: {PREFERRED_COMMAND} all --apply (or {LEGACY_COMMAND} --apply).")
1864
+ if not as_json:
1865
+ console.print(f"\n[green]Ready.[/green] Run: {PREFERRED_COMMAND} all --apply (or {LEGACY_COMMAND} --apply).")
619
1866
 
620
1867
 
621
1868
  @setup_app.command("agent-flow")