devcouncil 0.1.0 → 0.2.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 (190) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +197 -494
  3. package/package.json +9 -2
  4. package/pyproject.toml +62 -27
  5. package/src/devcouncil/__main__.py +4 -4
  6. package/src/devcouncil/app/__init__.py +28 -28
  7. package/src/devcouncil/app/config.py +297 -108
  8. package/src/devcouncil/app/errors.py +23 -23
  9. package/src/devcouncil/app/events.py +44 -44
  10. package/src/devcouncil/app/orchestrator.py +67 -67
  11. package/src/devcouncil/app/project_status.py +29 -0
  12. package/src/devcouncil/app/run_context.py +39 -39
  13. package/src/devcouncil/app/state_machine.py +108 -108
  14. package/src/devcouncil/artifacts/__init__.py +1 -1
  15. package/src/devcouncil/artifacts/coverage.py +96 -96
  16. package/src/devcouncil/artifacts/graph.py +163 -143
  17. package/src/devcouncil/artifacts/migrations.py +20 -20
  18. package/src/devcouncil/artifacts/schemas.py +23 -23
  19. package/src/devcouncil/artifacts/serializer.py +21 -21
  20. package/src/devcouncil/artifacts/validators.py +27 -27
  21. package/src/devcouncil/assets/__init__.py +1 -0
  22. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  23. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  24. package/src/devcouncil/cli/commands/agents.py +292 -0
  25. package/src/devcouncil/cli/commands/artifacts.py +54 -48
  26. package/src/devcouncil/cli/commands/ast.py +22 -0
  27. package/src/devcouncil/cli/commands/baseline.py +35 -32
  28. package/src/devcouncil/cli/commands/check.py +209 -0
  29. package/src/devcouncil/cli/commands/config.py +115 -54
  30. package/src/devcouncil/cli/commands/cost.py +57 -0
  31. package/src/devcouncil/cli/commands/dashboard.py +31 -0
  32. package/src/devcouncil/cli/commands/doctor.py +291 -47
  33. package/src/devcouncil/cli/commands/evidence.py +48 -0
  34. package/src/devcouncil/cli/commands/go.py +656 -0
  35. package/src/devcouncil/cli/commands/handoff.py +69 -0
  36. package/src/devcouncil/cli/commands/hook.py +209 -33
  37. package/src/devcouncil/cli/commands/init.py +204 -57
  38. package/src/devcouncil/cli/commands/integrate.py +1171 -76
  39. package/src/devcouncil/cli/commands/lsp.py +20 -0
  40. package/src/devcouncil/cli/commands/map.py +96 -22
  41. package/src/devcouncil/cli/commands/plan.py +422 -210
  42. package/src/devcouncil/cli/commands/prompt.py +48 -34
  43. package/src/devcouncil/cli/commands/repair.py +89 -69
  44. package/src/devcouncil/cli/commands/report.py +120 -54
  45. package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
  46. package/src/devcouncil/cli/commands/rollback.py +55 -54
  47. package/src/devcouncil/cli/commands/run.py +285 -220
  48. package/src/devcouncil/cli/commands/runs.py +223 -0
  49. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  50. package/src/devcouncil/cli/commands/semantic.py +47 -0
  51. package/src/devcouncil/cli/commands/setup.py +300 -20
  52. package/src/devcouncil/cli/commands/shell.py +73 -0
  53. package/src/devcouncil/cli/commands/show.py +76 -57
  54. package/src/devcouncil/cli/commands/skills.py +88 -0
  55. package/src/devcouncil/cli/commands/status.py +141 -105
  56. package/src/devcouncil/cli/commands/tasks.py +55 -41
  57. package/src/devcouncil/cli/commands/trace.py +49 -4
  58. package/src/devcouncil/cli/commands/verify.py +293 -128
  59. package/src/devcouncil/cli/commands/version.py +20 -20
  60. package/src/devcouncil/cli/commands/watch.py +574 -0
  61. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  62. package/src/devcouncil/cli/main.py +92 -25
  63. package/src/devcouncil/council/prompts/arbiter.md +19 -19
  64. package/src/devcouncil/council/prompts/critic_a.md +10 -10
  65. package/src/devcouncil/council/prompts/critic_b.md +10 -10
  66. package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
  67. package/src/devcouncil/council/prompts/planner_a.md +16 -16
  68. package/src/devcouncil/council/prompts/planner_b.md +16 -16
  69. package/src/devcouncil/council/prompts/rebuttal.md +10 -10
  70. package/src/devcouncil/council/prompts/spec_writer.md +12 -12
  71. package/src/devcouncil/domain/assumption.py +17 -17
  72. package/src/devcouncil/domain/critique.py +32 -32
  73. package/src/devcouncil/domain/evidence.py +47 -27
  74. package/src/devcouncil/domain/gap.py +52 -26
  75. package/src/devcouncil/domain/requirement.py +22 -22
  76. package/src/devcouncil/domain/task.py +55 -26
  77. package/src/devcouncil/execution/__init__.py +1 -1
  78. package/src/devcouncil/execution/checkpoints.py +246 -0
  79. package/src/devcouncil/execution/context_builder.py +54 -54
  80. package/src/devcouncil/execution/executor.py +15 -15
  81. package/src/devcouncil/execution/fs_watcher.py +180 -0
  82. package/src/devcouncil/execution/handoff.py +102 -0
  83. package/src/devcouncil/execution/hook_policy.py +186 -77
  84. package/src/devcouncil/execution/patch.py +77 -28
  85. package/src/devcouncil/execution/permissions.py +52 -59
  86. package/src/devcouncil/execution/policy_engine.py +343 -0
  87. package/src/devcouncil/execution/prompt_builder.py +650 -38
  88. package/src/devcouncil/execution/shell_session.py +225 -0
  89. package/src/devcouncil/execution/task_runner.py +68 -64
  90. package/src/devcouncil/executors/__init__.py +1 -1
  91. package/src/devcouncil/executors/agent_registry.py +575 -0
  92. package/src/devcouncil/executors/coding_cli.py +736 -0
  93. package/src/devcouncil/executors/mini_swe.py +63 -63
  94. package/src/devcouncil/executors/native/agent.py +186 -85
  95. package/src/devcouncil/executors/openhands.py +56 -56
  96. package/src/devcouncil/gating/__init__.py +1 -1
  97. package/src/devcouncil/gating/checks/clean_git.py +52 -45
  98. package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
  99. package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
  100. package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
  101. package/src/devcouncil/gating/policy.py +315 -167
  102. package/src/devcouncil/hardware.py +184 -0
  103. package/src/devcouncil/indexing/__init__.py +1 -1
  104. package/src/devcouncil/indexing/ast_matcher.py +168 -0
  105. package/src/devcouncil/indexing/graph_index.py +48 -48
  106. package/src/devcouncil/indexing/lsp.py +161 -0
  107. package/src/devcouncil/indexing/repo_mapper.py +1455 -204
  108. package/src/devcouncil/indexing/semantic_index.py +205 -0
  109. package/src/devcouncil/integrations/actions.py +146 -0
  110. package/src/devcouncil/integrations/check.py +423 -0
  111. package/src/devcouncil/integrations/github.py +35 -35
  112. package/src/devcouncil/integrations/github_intent.py +142 -0
  113. package/src/devcouncil/integrations/gitnexus.py +62 -27
  114. package/src/devcouncil/integrations/graphify.py +34 -34
  115. package/src/devcouncil/integrations/mcp/server.py +2072 -96
  116. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  117. package/src/devcouncil/integrations/pr_comments.py +62 -0
  118. package/src/devcouncil/live/__init__.py +2 -0
  119. package/src/devcouncil/live/cards.py +349 -0
  120. package/src/devcouncil/live/models.py +63 -0
  121. package/src/devcouncil/live/repair_prompt.py +83 -0
  122. package/src/devcouncil/live/reviewer.py +70 -0
  123. package/src/devcouncil/live/signals.py +135 -0
  124. package/src/devcouncil/live/summary.py +34 -0
  125. package/src/devcouncil/live/tasks.py +18 -0
  126. package/src/devcouncil/live/transcripts.py +141 -0
  127. package/src/devcouncil/llm/__init__.py +1 -1
  128. package/src/devcouncil/llm/cache.py +42 -38
  129. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  130. package/src/devcouncil/llm/provider.py +627 -125
  131. package/src/devcouncil/llm/router.py +303 -118
  132. package/src/devcouncil/optimization/__init__.py +1 -0
  133. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  134. package/src/devcouncil/planning/__init__.py +1 -1
  135. package/src/devcouncil/planning/arbiter_service.py +57 -57
  136. package/src/devcouncil/planning/correction_manifest.py +303 -0
  137. package/src/devcouncil/planning/critique_service.py +71 -66
  138. package/src/devcouncil/planning/plan_service.py +60 -46
  139. package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
  140. package/src/devcouncil/planning/repair_service.py +39 -39
  141. package/src/devcouncil/planning/spec_service.py +70 -44
  142. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  143. package/src/devcouncil/repo/gitignore.py +123 -0
  144. package/src/devcouncil/repo/sca.py +374 -0
  145. package/src/devcouncil/reporting/github_check.py +32 -32
  146. package/src/devcouncil/reporting/json_report.py +30 -17
  147. package/src/devcouncil/reporting/markdown_report.py +83 -46
  148. package/src/devcouncil/reporting/report_builder.py +14 -14
  149. package/src/devcouncil/skills/__init__.py +19 -0
  150. package/src/devcouncil/skills/library/README.md +46 -0
  151. package/src/devcouncil/skills/library/ai-training.md +50 -0
  152. package/src/devcouncil/skills/library/android.md +50 -0
  153. package/src/devcouncil/skills/library/backend.md +52 -0
  154. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  155. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  156. package/src/devcouncil/skills/library/desktop.md +46 -0
  157. package/src/devcouncil/skills/library/devops.md +48 -0
  158. package/src/devcouncil/skills/library/game-dev.md +46 -0
  159. package/src/devcouncil/skills/library/ios.md +48 -0
  160. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  161. package/src/devcouncil/skills/library/security.md +48 -0
  162. package/src/devcouncil/skills/library/systems.md +48 -0
  163. package/src/devcouncil/skills/library/web.md +47 -0
  164. package/src/devcouncil/skills/library/windows.md +47 -0
  165. package/src/devcouncil/skills/registry.py +330 -0
  166. package/src/devcouncil/storage/db.py +147 -66
  167. package/src/devcouncil/storage/models.py +204 -83
  168. package/src/devcouncil/storage/native.py +557 -0
  169. package/src/devcouncil/storage/repositories.py +388 -249
  170. package/src/devcouncil/telemetry/cost.py +140 -34
  171. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  172. package/src/devcouncil/telemetry/pricing.py +28 -0
  173. package/src/devcouncil/telemetry/traces.py +62 -7
  174. package/src/devcouncil/telemetry/tracker.py +52 -49
  175. package/src/devcouncil/ui/__init__.py +1 -0
  176. package/src/devcouncil/ui/dashboard.py +423 -0
  177. package/src/devcouncil/utils/__init__.py +1 -1
  178. package/src/devcouncil/utils/redaction.py +147 -141
  179. package/src/devcouncil/utils/subprocess_env.py +69 -0
  180. package/src/devcouncil/verification/__init__.py +1 -1
  181. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  182. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  183. package/src/devcouncil/verification/diff_coverage.py +353 -0
  184. package/src/devcouncil/verification/implementation_reviewer.py +55 -55
  185. package/src/devcouncil/verification/next_actions.py +189 -0
  186. package/src/devcouncil/verification/sandbox.py +178 -0
  187. package/src/devcouncil/verification/test_resolver.py +91 -0
  188. package/src/devcouncil/verification/verifier.py +1342 -307
  189. package/uv.lock +205 -64
  190. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -0,0 +1,423 @@
1
+ """Shared integration readiness checks for `dev integrate check`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from devcouncil.executors.agent_registry import (
14
+ CODING_CLI_INTEGRATION_INFO,
15
+ CODING_CLI_VERSION_COMMANDS,
16
+ detect_available_coding_cli,
17
+ resolve_automated_executor,
18
+ resolve_coding_cli_executable,
19
+ resolve_coding_cli_probe_order,
20
+ )
21
+ from devcouncil.utils.subprocess_env import clean_subprocess_env
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class IntegrationCheckRow:
26
+ name: str
27
+ status: str
28
+ details: str
29
+
30
+ def as_dict(self) -> dict[str, str]:
31
+ return {"name": self.name, "status": self.status, "details": self.details}
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class IntegrationCheckReport:
36
+ checks: tuple[IntegrationCheckRow, ...]
37
+ recommended_executor: str | None
38
+ failures: int
39
+
40
+ @property
41
+ def ok(self) -> bool:
42
+ return self.failures == 0
43
+
44
+ def as_dict(self) -> dict[str, Any]:
45
+ return {
46
+ "ok": self.ok,
47
+ "failures": self.failures,
48
+ "recommended_executor": self.recommended_executor,
49
+ "checks": [row.as_dict() for row in self.checks],
50
+ }
51
+
52
+ def to_json(self, *, indent: int | None = 2) -> str:
53
+ return json.dumps(self.as_dict(), indent=indent)
54
+
55
+
56
+ CODING_CLI_CHECK_LABELS: dict[str, str] = {
57
+ "codex": "Codex CLI",
58
+ "gemini": "Gemini CLI",
59
+ "claude": "Claude Code",
60
+ "cursor": "Cursor",
61
+ "opencode": "OpenCode",
62
+ "antigravity": "Google Antigravity CLI",
63
+ "warp": "Warp / Oz",
64
+ "aider": "Aider",
65
+ "copilot": "GitHub Copilot CLI",
66
+ "goose": "Goose",
67
+ "amp": "Amp (Sourcegraph)",
68
+ "qwen": "Qwen Code",
69
+ "crush": "Crush (Charm)",
70
+ }
71
+
72
+
73
+ def probe_cli_version(command: list[str], *, timeout: int = 10) -> tuple[int, str]:
74
+ executable = shutil.which(command[0])
75
+ if not executable:
76
+ return 127, f"{command[0]} not found on PATH"
77
+
78
+ resolved = [executable, *command[1:]]
79
+ use_shell = sys.platform == "win32" and Path(executable).suffix.lower() in {".bat", ".cmd", ".ps1"}
80
+ invocation = subprocess.list2cmdline(resolved) if use_shell else resolved
81
+ try:
82
+ result = subprocess.run(
83
+ invocation,
84
+ capture_output=True,
85
+ text=True,
86
+ encoding="utf-8",
87
+ errors="replace",
88
+ shell=use_shell,
89
+ timeout=timeout,
90
+ env=clean_subprocess_env(),
91
+ )
92
+ except subprocess.TimeoutExpired:
93
+ return 124, "timed out"
94
+ except (FileNotFoundError, OSError) as exc:
95
+ return 127, f"{command[0]} could not be executed: {exc}"
96
+ return result.returncode, (result.stdout + result.stderr).strip()
97
+
98
+
99
+ def probe_coding_cli_version(client: str) -> tuple[bool, str]:
100
+ label = CODING_CLI_CHECK_LABELS.get(client, client)
101
+ commands = CODING_CLI_VERSION_COMMANDS.get(client, ())
102
+ for command in commands:
103
+ code, output = probe_cli_version(list(command))
104
+ if code == 0:
105
+ first_line = output.splitlines()[0] if output else "installed"
106
+ return True, first_line
107
+ return False, f"Optional; install {label} to use this integration."
108
+
109
+
110
+ def recommended_executor_status(project_root: Path) -> tuple[bool, str]:
111
+ detected = detect_available_coding_cli(project_root)
112
+ if not detected:
113
+ return False, "No built-in coding CLI on PATH. Run dev integrate recommend after installing one."
114
+ resolved = resolve_automated_executor(project_root, None)
115
+ return True, f"Use --executor {resolved} for dev go / dev run (detected: {detected})."
116
+
117
+
118
+ def coding_clis_on_path(project_root: Path) -> list[str]:
119
+ order = resolve_coding_cli_probe_order(project_root)
120
+ return [client for client in order if resolve_coding_cli_executable(project_root, client)]
121
+
122
+
123
+ def _load_json_file(path: Path) -> dict[str, Any]:
124
+ if not path.exists():
125
+ return {}
126
+ try:
127
+ loaded = json.loads(path.read_text(encoding="utf-8")) or {}
128
+ except json.JSONDecodeError:
129
+ return {}
130
+ return loaded if isinstance(loaded, dict) else {}
131
+
132
+
133
+ def _cursor_config_status(project_root: Path) -> tuple[str, bool, list[str]]:
134
+ path = project_root / ".cursor" / "mcp.json"
135
+ data = _load_json_file(path)
136
+ server = ((data.get("mcpServers") or {}).get("devcouncil") or {}) if data else {}
137
+ ok = (
138
+ server.get("type") == "stdio"
139
+ and server.get("command") == "devcouncil"
140
+ and server.get("args") == ["mcp-server"]
141
+ and (server.get("env") or {}).get("DEVCOUNCIL_PROJECT_ROOT") == str(project_root)
142
+ )
143
+ if ok:
144
+ return "ok", False, [str(path)]
145
+ return ("missing" if not path.exists() else "drifted"), True, [str(path)]
146
+
147
+
148
+ def _opencode_config_status(project_root: Path) -> tuple[str, bool, list[str]]:
149
+ path = project_root / "opencode.json"
150
+ data = _load_json_file(path)
151
+ server = ((data.get("mcp") or {}).get("devcouncil") or {}) if data else {}
152
+ plugin = data.get("plugin") or []
153
+ plugin_ok = "./.devcouncil/integrations/opencode_devcouncil_plugin.mjs" in plugin
154
+ ok = (
155
+ server.get("type") == "local"
156
+ and server.get("command") == ["devcouncil", "mcp-server"]
157
+ and (server.get("environment") or {}).get("DEVCOUNCIL_PROJECT_ROOT") == str(project_root)
158
+ )
159
+ status = "ok" if ok else ("missing" if not path.exists() else "drifted")
160
+ return status, not ok or not plugin_ok, [str(path), str(project_root / ".devcouncil" / "integrations" / "opencode_devcouncil_plugin.mjs")]
161
+
162
+
163
+ def _antigravity_config_status(project_root: Path) -> tuple[str, bool, list[str]]:
164
+ path = project_root / ".agents" / "mcp_config.json"
165
+ data = _load_json_file(path)
166
+ server = ((data.get("mcpServers") or {}).get("devcouncil") or {}) if data else {}
167
+ ok = (
168
+ server.get("command") == "devcouncil"
169
+ and server.get("args") == ["mcp-server"]
170
+ and server.get("cwd") == str(project_root)
171
+ and (server.get("env") or {}).get("DEVCOUNCIL_PROJECT_ROOT") == str(project_root)
172
+ )
173
+ if ok:
174
+ return "ok", False, [str(path)]
175
+ return ("missing" if not path.exists() else "drifted"), True, [str(path)]
176
+
177
+
178
+ def _warp_config_status(project_root: Path) -> tuple[str, bool, list[str]]:
179
+ path = project_root / ".devcouncil" / "integrations" / "warp-mcp.json"
180
+ data = _load_json_file(path)
181
+ server = data.get("devcouncil") or {}
182
+ ok = (
183
+ "mcpServers" not in data
184
+ and server.get("command") == "devcouncil"
185
+ and server.get("args") == ["mcp-server"]
186
+ and (server.get("env") or {}).get("DEVCOUNCIL_PROJECT_ROOT") == str(project_root)
187
+ )
188
+ if ok:
189
+ return "ok", False, [str(path)]
190
+ return ("missing" if not path.exists() else "drifted"), True, [str(path)]
191
+
192
+
193
+ def integration_capability_rows(project_root: Path) -> list[dict[str, object]]:
194
+ order = resolve_coding_cli_probe_order(project_root)
195
+ rows: list[dict[str, object]] = []
196
+ for client in order:
197
+ info = CODING_CLI_INTEGRATION_INFO.get(client)
198
+ if info is None:
199
+ continue
200
+ config_status = "not_applicable"
201
+ fixable = bool(info.mcp or info.hooks or info.launcher_shim)
202
+ paths: list[str] = []
203
+ if client == "cursor":
204
+ config_status, fixable, paths = _cursor_config_status(project_root)
205
+ elif client == "opencode":
206
+ config_status, fixable, paths = _opencode_config_status(project_root)
207
+ elif client == "antigravity":
208
+ config_status, fixable, paths = _antigravity_config_status(project_root)
209
+ elif client == "warp":
210
+ config_status, fixable, paths = _warp_config_status(project_root)
211
+
212
+ rows.append({
213
+ "name": info.name,
214
+ "label": info.label,
215
+ "on_path": resolve_coding_cli_executable(project_root, client) is not None,
216
+ "tier": info.tier,
217
+ "headless": info.headless,
218
+ "mcp": info.mcp,
219
+ "hooks": info.hooks,
220
+ "enforcement": info.enforcement,
221
+ "launcher_shim": info.launcher_shim,
222
+ "notes": info.notes,
223
+ "configured": config_status == "ok",
224
+ "config_status": config_status,
225
+ "fixable": fixable,
226
+ "paths": paths,
227
+ "apply_target": client,
228
+ })
229
+ return rows
230
+
231
+
232
+ def _hook_config_references_devcouncil(path: Path) -> bool | None:
233
+ """Return whether a client hook config still wires DevCouncil's gate.
234
+
235
+ ``True`` -> the file exists and references ``devcouncil`` somewhere in its hooks.
236
+ ``False`` -> the file exists but no longer references it (tampered/disarmed).
237
+ ``None`` -> the file does not exist (client was never integrated here).
238
+
239
+ Reads the raw text rather than parsing each client's bespoke schema so it works
240
+ uniformly across JSON hook files and is resilient to format drift; the goal is a
241
+ tamper tripwire, not full schema validation."""
242
+ if not path.exists():
243
+ return None
244
+ try:
245
+ text = path.read_text(encoding="utf-8")
246
+ except OSError:
247
+ return False
248
+ return "devcouncil" in text.lower()
249
+
250
+
251
+ def _hook_config_tamper_targets(project_root: Path) -> list[tuple[str, Path]]:
252
+ return [
253
+ ("Claude", project_root / ".claude" / "settings.local.json"),
254
+ ("Codex", project_root / ".codex" / "hooks.json"),
255
+ ("Gemini", project_root / ".gemini" / "settings.json"),
256
+ ("Cursor", project_root / ".cursor" / "hooks.json"),
257
+ ]
258
+
259
+
260
+ def build_integration_check_report(project_root: Path, *, strict: bool = False) -> IntegrationCheckReport:
261
+ from devcouncil.cli.commands import integrate
262
+
263
+ rows: list[IntegrationCheckRow] = []
264
+ failures = 0
265
+
266
+ def add(ok: bool, name: str, details: str) -> None:
267
+ nonlocal failures
268
+ rows.append(IntegrationCheckRow(name=name, status="ok" if ok else "fail", details=details))
269
+ if not ok:
270
+ failures += 1
271
+
272
+ def add_optional(ok: bool, name: str, details: str) -> None:
273
+ if strict and not ok:
274
+ add(False, name, details)
275
+ return
276
+ rows.append(IntegrationCheckRow(name=name, status="ok" if ok else "missing", details=details))
277
+
278
+ def add_skip(name: str, details: str) -> None:
279
+ rows.append(IntegrationCheckRow(name=name, status="skip", details=details))
280
+
281
+ root = project_root.expanduser().resolve()
282
+ add((root / ".devcouncil").exists(), "Project state", str(root / ".devcouncil"))
283
+ devcouncil_path = shutil.which("devcouncil")
284
+ add(
285
+ devcouncil_path is not None or Path(sys.executable).exists(),
286
+ "devcouncil CLI",
287
+ devcouncil_path or f"{sys.executable} -m devcouncil",
288
+ )
289
+
290
+ devcouncil_launch = [devcouncil_path] if devcouncil_path else [sys.executable, "-m", "devcouncil"]
291
+ code, output = integrate._run_capture([*devcouncil_launch, "--help"])
292
+ add(code == 0, "devcouncil command", output.splitlines()[0] if output else "No output")
293
+
294
+ for client in resolve_coding_cli_probe_order(root):
295
+ cli_ok, cli_details = probe_coding_cli_version(client)
296
+ add_optional(cli_ok, CODING_CLI_CHECK_LABELS.get(client, client), cli_details)
297
+
298
+ rec_ok, rec_details = recommended_executor_status(root)
299
+ add_optional(rec_ok, "Recommended coding CLI", rec_details)
300
+
301
+ for row in integration_capability_rows(root):
302
+ name = str(row["name"])
303
+ status = str(row["config_status"])
304
+ if status == "ok":
305
+ paths = row.get("paths")
306
+ path_list = paths if isinstance(paths, list) else []
307
+ add(True, f"{row['label']} config", ", ".join(str(path) for path in path_list))
308
+ elif status in {"missing", "drifted"}:
309
+ add_skip(f"{row['label']} config", f"Run dev integrate {name} --apply to repair.")
310
+
311
+ raw_config = integrate._load_raw_config(root) if (root / ".devcouncil").exists() else {}
312
+
313
+ cursor_hooks = root / ".cursor" / "hooks.json"
314
+ cursor_enabled = bool(raw_config.get("integrations", {}).get("cursor", {}).get("enabled"))
315
+ if cursor_enabled or cursor_hooks.exists():
316
+ hooks_ok = False
317
+ if cursor_hooks.exists():
318
+ try:
319
+ hooks_data = json.loads(cursor_hooks.read_text(encoding="utf-8")) or {}
320
+ hooks_ok = "preToolUse" in hooks_data.get("hooks", {})
321
+ except json.JSONDecodeError:
322
+ hooks_ok = False
323
+ add(
324
+ hooks_ok,
325
+ "Cursor hooks",
326
+ str(cursor_hooks) if hooks_ok else "Run dev integrate hooks --apply --tool cursor.",
327
+ )
328
+
329
+ opencode_config = integrate._opencode_config_path(root)
330
+ opencode_enabled = bool(raw_config.get("integrations", {}).get("opencode", {}).get("enabled"))
331
+ opencode_plugin = integrate._opencode_plugin_path(root)
332
+ if opencode_enabled or opencode_plugin.exists():
333
+ plugin_ok = opencode_plugin.exists()
334
+ plugin_registered = False
335
+ if plugin_ok and opencode_config.exists():
336
+ try:
337
+ opencode_data = json.loads(opencode_config.read_text(encoding="utf-8")) or {}
338
+ plugin_registered = (
339
+ f"./.devcouncil/integrations/{integrate.OPENCODE_HOOK_PLUGIN_NAME}"
340
+ in (opencode_data.get("plugin") or [])
341
+ )
342
+ except json.JSONDecodeError:
343
+ plugin_registered = False
344
+ add(
345
+ plugin_ok and plugin_registered,
346
+ "OpenCode hook plugin",
347
+ str(opencode_plugin) if plugin_ok and plugin_registered else "Run dev integrate hooks --apply --tool opencode.",
348
+ )
349
+
350
+ bundled_plugin = integrate._opencode_plugin_source()
351
+ add(
352
+ bundled_plugin.exists(),
353
+ "Bundled OpenCode hook plugin",
354
+ str(bundled_plugin) if bundled_plugin.exists() else "Reinstall DevCouncil; package asset is missing.",
355
+ )
356
+
357
+ custom_agents = raw_config.get("integrations", {}).get("cli_agents", {}).get("agents", {})
358
+ if custom_agents:
359
+ for name, agent in sorted(custom_agents.items()):
360
+ command = str(agent.get("command", "")).strip()
361
+ found = shutil.which(command) if command else None
362
+ add(found is not None, f"CLI agent: {name}", found or f"{command or 'command'} not found on PATH")
363
+ else:
364
+ add_skip("Custom CLI agents", "No agents registered.")
365
+
366
+ try:
367
+ tools = integrate._probe_mcp_tools(root)
368
+ expected = {"devcouncil_status", "devcouncil_report", "devcouncil_get_task"}
369
+ add(expected.issubset(set(tools)), "MCP server", ", ".join(tools))
370
+ except Exception as exc:
371
+ add(False, "MCP server", str(exc))
372
+
373
+ # Tamper tripwire: any installed client hook config must still reference the
374
+ # DevCouncil gate. A present-but-unreferenced file means the pre-action gate was
375
+ # disarmed (by an agent or by hand) and is reported as a failure.
376
+ for label, hook_path in _hook_config_tamper_targets(root):
377
+ references = _hook_config_references_devcouncil(hook_path)
378
+ if references is None:
379
+ continue
380
+ add(
381
+ references,
382
+ f"{label} hook integrity",
383
+ str(hook_path) if references else f"{hook_path} no longer references devcouncil (tampered/disarmed).",
384
+ )
385
+
386
+ detected = detect_available_coding_cli(root)
387
+ recommended = resolve_automated_executor(root, None) if detected else None
388
+ return IntegrationCheckReport(tuple(rows), recommended, failures)
389
+
390
+
391
+ def integration_status_summary(project_root: Path) -> dict[str, Any]:
392
+ from devcouncil.app.config import load_config
393
+
394
+ probe_order = resolve_coding_cli_probe_order(project_root)
395
+ on_path = coding_clis_on_path(project_root)
396
+ detected = on_path[0] if on_path else None
397
+ try:
398
+ execution = load_config(project_root).execution
399
+ default_executor = execution.default_executor
400
+ stream_cli_output = execution.stream_cli_output
401
+ cursor_resume_mode = execution.cursor_resume_mode
402
+ custom_probe_order = list(execution.coding_cli_probe_order)
403
+ except Exception:
404
+ default_executor = "manual"
405
+ stream_cli_output = False
406
+ cursor_resume_mode = "off"
407
+ custom_probe_order = []
408
+
409
+ resolved = resolve_automated_executor(project_root, None) if detected or default_executor != "manual" else "manual"
410
+ config_path = project_root / ".devcouncil" / "config.yaml"
411
+ return {
412
+ "project_initialized": (project_root / ".devcouncil").is_dir(),
413
+ "config_path": str(config_path) if config_path.exists() else None,
414
+ "default_executor": default_executor,
415
+ "resolved_executor": resolved,
416
+ "detected_executor": detected,
417
+ "coding_clis_on_path": on_path,
418
+ "probe_order": list(probe_order),
419
+ "custom_probe_order": custom_probe_order,
420
+ "stream_cli_output": stream_cli_output,
421
+ "cursor_resume_mode": cursor_resume_mode,
422
+ "capabilities": integration_capability_rows(project_root),
423
+ }
@@ -2,38 +2,38 @@ import httpx
2
2
  import logging
3
3
  from devcouncil.artifacts.graph import ArtifactGraph
4
4
  from devcouncil.reporting.github_check import GitHubCheckGenerator
5
-
6
- logger = logging.getLogger(__name__)
7
-
8
- class GitHubIntegration:
9
- """Manages interactions with GitHub API, specifically PR Checks."""
10
-
11
- def __init__(self, github_token: str, repository: str, commit_sha: str):
12
- self.github_token = github_token
13
- self.repository = repository
14
- self.commit_sha = commit_sha
15
- self.base_url = f"https://api.github.com/repos/{repository}"
16
-
17
- async def report_verification(self, graph: ArtifactGraph):
18
- """Creates or updates a GitHub Check Run with the current verification status."""
19
- payload = GitHubCheckGenerator.generate(graph)
20
- payload["head_sha"] = self.commit_sha
21
-
22
- headers = {
23
- "Authorization": f"Bearer {self.github_token}",
24
- "Accept": "application/vnd.github.v3+json",
25
- "Content-Type": "application/json"
26
- }
27
-
28
- async with httpx.AsyncClient() as client:
29
- try:
30
- response = await client.post(
31
- f"{self.base_url}/check-runs",
32
- headers=headers,
33
- json=payload
34
- )
35
- response.raise_for_status()
36
- logger.info(f"GitHub PR Check updated for {self.repository} at {self.commit_sha}")
37
- except Exception as e:
38
- logger.error(f"Failed to report to GitHub: {e}")
39
- raise
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ class GitHubIntegration:
9
+ """Manages interactions with GitHub API, specifically PR Checks."""
10
+
11
+ def __init__(self, github_token: str, repository: str, commit_sha: str):
12
+ self.github_token = github_token
13
+ self.repository = repository
14
+ self.commit_sha = commit_sha
15
+ self.base_url = f"https://api.github.com/repos/{repository}"
16
+
17
+ async def report_verification(self, graph: ArtifactGraph):
18
+ """Creates or updates a GitHub Check Run with the current verification status."""
19
+ payload = GitHubCheckGenerator.generate(graph)
20
+ payload["head_sha"] = self.commit_sha
21
+
22
+ headers = {
23
+ "Authorization": f"Bearer {self.github_token}",
24
+ "Accept": "application/vnd.github.v3+json",
25
+ "Content-Type": "application/json"
26
+ }
27
+
28
+ async with httpx.AsyncClient() as client:
29
+ try:
30
+ response = await client.post(
31
+ f"{self.base_url}/check-runs",
32
+ headers=headers,
33
+ json=payload
34
+ )
35
+ response.raise_for_status()
36
+ logger.info(f"GitHub PR Check updated for {self.repository} at {self.commit_sha}")
37
+ except Exception as e:
38
+ logger.error(f"Failed to report to GitHub: {e}")
39
+ raise
@@ -0,0 +1,142 @@
1
+ """Resolve a DevCouncil goal from a GitHub issue or pull-request reference.
2
+
3
+ A terse goal like ``"#142"`` or a full issue URL carries far more intent than a
4
+ one-line argument — the issue body usually *is* the spec. This module detects
5
+ such references and expands them into a rich goal string (title + body + a few
6
+ comments) by shelling out to the authenticated ``gh`` CLI, so private repos work
7
+ without any token plumbing. When ``gh`` is unavailable or the lookup fails, the
8
+ caller keeps the original goal text unchanged — expansion is strictly additive.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ from devcouncil.utils.subprocess_env import clean_subprocess_env
21
+
22
+ # Cap each pulled discussion comment so a long thread can't dominate the prompt.
23
+ _MAX_COMMENT_CHARS = 600
24
+
25
+ # "#142", "GH-142", "owner/repo#142", or a full issues/pull URL.
26
+ _SHORT_REF = re.compile(r"^\s*(?:GH-|#)(\d+)\s*$", re.IGNORECASE)
27
+ _OWNER_REPO_REF = re.compile(r"^\s*([\w.-]+/[\w.-]+)#(\d+)\s*$")
28
+ _URL_REF = re.compile(
29
+ r"^\s*https?://github\.com/([\w.-]+/[\w.-]+)/(issues|pull)/(\d+)",
30
+ re.IGNORECASE,
31
+ )
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class IntentRef:
36
+ number: int
37
+ kind: str # "issue" | "pull" | "auto"
38
+ repo: str | None # "owner/repo" when explicit, else None (current repo)
39
+
40
+
41
+ def parse_intent_ref(goal: str) -> IntentRef | None:
42
+ """Return the GitHub reference a goal points at, or None if it's plain text."""
43
+ text = goal.strip()
44
+ m = _URL_REF.match(text)
45
+ if m:
46
+ kind = "pull" if m.group(2).lower() == "pull" else "issue"
47
+ return IntentRef(number=int(m.group(3)), kind=kind, repo=m.group(1))
48
+ m = _OWNER_REPO_REF.match(text)
49
+ if m:
50
+ return IntentRef(number=int(m.group(2)), kind="auto", repo=m.group(1))
51
+ m = _SHORT_REF.match(text)
52
+ if m:
53
+ return IntentRef(number=int(m.group(1)), kind="auto", repo=None)
54
+ return None
55
+
56
+
57
+ def _gh_view(ref: IntentRef, sub: str, root: Path) -> dict | None:
58
+ """Run ``gh <issue|pr> view`` and return the parsed JSON, or None on failure."""
59
+ gh = shutil.which("gh")
60
+ if not gh:
61
+ return None
62
+ cmd = [gh, sub, "view", str(ref.number), "--json", "title,body,comments,url,state"]
63
+ if ref.repo:
64
+ cmd += ["--repo", ref.repo]
65
+ try:
66
+ result = subprocess.run(
67
+ cmd, cwd=root, capture_output=True, text=True,
68
+ encoding="utf-8", errors="replace", timeout=20, env=clean_subprocess_env(),
69
+ )
70
+ except Exception:
71
+ return None
72
+ if result.returncode != 0 or not result.stdout.strip():
73
+ return None
74
+ try:
75
+ data = json.loads(result.stdout)
76
+ except json.JSONDecodeError:
77
+ return None
78
+ return data if isinstance(data, dict) else None
79
+
80
+
81
+ def _compose_goal(ref: IntentRef, data: dict, source: str) -> str:
82
+ title = str(data.get("title") or "").strip()
83
+ body = str(data.get("body") or "").strip()
84
+ url = str(data.get("url") or "").strip()
85
+ lines = [f"Implement {source} #{ref.number}: {title}".rstrip(": ").rstrip()]
86
+ if url:
87
+ lines.append(f"Source: {url}")
88
+ if body:
89
+ lines += ["", body]
90
+ # Pull in up to three discussion comments — clarifications often live there.
91
+ # Cap each so a long thread can't bloat the planning prompt; the issue body
92
+ # above is the primary spec, comments are secondary context.
93
+ comments = data.get("comments")
94
+ if isinstance(comments, list) and comments:
95
+ snippets = []
96
+ for comment in comments[:3]:
97
+ text = str((comment or {}).get("body") or "").strip()
98
+ if text:
99
+ if len(text) > _MAX_COMMENT_CHARS:
100
+ text = text[:_MAX_COMMENT_CHARS].rstrip() + " […]"
101
+ snippets.append(text)
102
+ if snippets:
103
+ lines += ["", "Discussion notes:"]
104
+ lines += [f"- {s}" for s in snippets]
105
+ return "\n".join(lines).strip()
106
+
107
+
108
+ def resolve_goal_intent(goal: str, root: Path) -> tuple[str, str | None]:
109
+ """Expand a GitHub issue/PR reference into a full goal.
110
+
111
+ Returns ``(goal, note)``. When ``goal`` is a reference and the lookup
112
+ succeeds, the first element is the composed goal and ``note`` describes the
113
+ expansion (for display). Otherwise the original goal is returned with a
114
+ ``note`` explaining why it could not be expanded (or ``None`` when the goal
115
+ was plain text and no expansion was attempted).
116
+ """
117
+ ref = parse_intent_ref(goal)
118
+ if ref is None:
119
+ return goal, None
120
+
121
+ if not shutil.which("gh"):
122
+ return goal, (
123
+ f"Goal looks like GitHub reference #{ref.number}, but the `gh` CLI is not on "
124
+ "PATH — using the literal text. Install/auth gh to pull the issue/PR body."
125
+ )
126
+
127
+ order = (
128
+ ["pull", "issue"] if ref.kind == "pull"
129
+ else ["issue", "pull"] if ref.kind == "issue"
130
+ else ["issue", "pull"]
131
+ )
132
+ for sub in order:
133
+ data = _gh_view(ref, "pr" if sub == "pull" else "issue", root)
134
+ if data is not None:
135
+ source = "pull request" if sub == "pull" else "issue"
136
+ composed = _compose_goal(ref, data, source)
137
+ return composed, f"Pulled intent from {source} #{ref.number} via gh."
138
+
139
+ return goal, (
140
+ f"Could not fetch GitHub #{ref.number} via gh (not found, no access, or not a "
141
+ "git/GitHub repo) — using the literal text."
142
+ )