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
@@ -1,82 +1,400 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import secrets
5
+ import time
6
+ from importlib import resources
4
7
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5
8
  from pathlib import Path
9
+ from typing import TYPE_CHECKING
6
10
  from urllib.parse import urlparse
7
11
 
8
12
  from devcouncil.app.project_status import compute_phase
13
+ from devcouncil.integrations.actions import apply_integration_target
14
+ from devcouncil.integrations.check import build_integration_check_report, integration_status_summary
9
15
  from devcouncil.storage.db import get_db
10
- from devcouncil.storage.repositories import ArtifactGraphRepository, StateRepository, TaskRepository
11
- from devcouncil.telemetry.traces import read_trace_events
16
+ from devcouncil.storage.repositories import ArtifactGraphRepository, StateRepository
17
+ from devcouncil.telemetry.traces import TraceEvent, read_trace_events_since
18
+
19
+ if TYPE_CHECKING:
20
+ from devcouncil.artifacts.graph import ArtifactGraph
21
+
22
+ LOGO_ASSET = "devcouncil_logo_premium.png"
23
+ LEGACY_LOGO_ASSET = "devcouncil-logo.svg"
24
+
25
+
26
+ # mtime-keyed cache so the dashboard's poll loop doesn't re-read and re-parse
27
+ # every run manifest on each refresh.
28
+ _RUN_MANIFEST_CACHE: dict[str, tuple[float, dict]] = {}
29
+
30
+
31
+ def _load_run_manifest(manifest_path: Path) -> dict | None:
32
+ key = str(manifest_path)
33
+ try:
34
+ mtime = manifest_path.stat().st_mtime
35
+ except OSError:
36
+ return None
37
+ cached = _RUN_MANIFEST_CACHE.get(key)
38
+ if cached is not None and cached[0] == mtime:
39
+ return dict(cached[1])
40
+ try:
41
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8")) or {}
42
+ except (json.JSONDecodeError, OSError):
43
+ return None
44
+ _RUN_MANIFEST_CACHE[key] = (mtime, manifest)
45
+ return dict(manifest)
46
+
47
+
48
+ # The glob+stat over every run manifest is wasteful on each 2-second poll; a
49
+ # short TTL cache of the assembled result keeps the loop cheap.
50
+ _RECENT_RUNS_TTL_SECONDS = 2.0
51
+ _RECENT_RUNS_CACHE: dict[str, tuple[float, int, list[dict]]] = {}
52
+
53
+
54
+ def _invalidate_recent_runs(project_root: Path) -> None:
55
+ _RECENT_RUNS_CACHE.pop(str(project_root), None)
56
+
57
+
58
+ def recent_run_artifacts(project_root: Path, *, limit: int = 10) -> list[dict]:
59
+ key = str(project_root)
60
+ now = time.monotonic()
61
+ cached = _RECENT_RUNS_CACHE.get(key)
62
+ if cached is not None and cached[1] == limit and now - cached[0] < _RECENT_RUNS_TTL_SECONDS:
63
+ return cached[2]
64
+ runs_dir = project_root / ".devcouncil" / "runs"
65
+ if not runs_dir.exists():
66
+ _RECENT_RUNS_CACHE[key] = (now, limit, [])
67
+ return []
68
+ manifests: list[dict] = []
69
+ for manifest_path in sorted(
70
+ runs_dir.glob("*/agent-run.json"),
71
+ key=lambda path: path.stat().st_mtime,
72
+ reverse=True,
73
+ ):
74
+ manifest = _load_run_manifest(manifest_path)
75
+ if manifest is None:
76
+ continue
77
+ manifest["manifest_path"] = str(manifest_path)
78
+ manifests.append(manifest)
79
+ if len(manifests) >= limit:
80
+ break
81
+ _RECENT_RUNS_CACHE[key] = (now, limit, manifests)
82
+ return manifests
83
+
84
+
85
+ # Integration status probes the filesystem (and optionally CLIs) — far too
86
+ # expensive to recompute on every 2-second dashboard poll.
87
+ _INTEGRATION_SUMMARY_TTL_SECONDS = 5.0
88
+ _INTEGRATION_SUMMARY_CACHE: dict[str, tuple[float, dict]] = {}
89
+
90
+
91
+ def _integration_summary_cached(project_root: Path) -> dict:
92
+ key = str(project_root)
93
+ now = time.monotonic()
94
+ cached = _INTEGRATION_SUMMARY_CACHE.get(key)
95
+ if cached is not None and now - cached[0] < _INTEGRATION_SUMMARY_TTL_SECONDS:
96
+ return cached[1]
97
+ summary = integration_status_summary(project_root)
98
+ _INTEGRATION_SUMMARY_CACHE[key] = (now, summary)
99
+ return summary
100
+
101
+
102
+ def _invalidate_integration_summary(project_root: Path) -> None:
103
+ _INTEGRATION_SUMMARY_CACHE.pop(str(project_root), None)
104
+
105
+
106
+ # load_graph() runs six full-table scans, but the dashboard only needs the
107
+ # coverage summary and task list. Cache the materialized graph for a short TTL so
108
+ # the 2-second poll loop reuses it instead of rescanning every table each time.
109
+ _ARTIFACT_GRAPH_TTL_SECONDS = 2.0
110
+ _ARTIFACT_GRAPH_CACHE: dict[str, tuple[float, "ArtifactGraph"]] = {}
111
+
112
+
113
+ def _artifact_graph_cached(project_root: Path, session) -> "ArtifactGraph":
114
+ key = str(project_root)
115
+ now = time.monotonic()
116
+ cached = _ARTIFACT_GRAPH_CACHE.get(key)
117
+ if cached is not None and now - cached[0] < _ARTIFACT_GRAPH_TTL_SECONDS:
118
+ return cached[1]
119
+ graph = ArtifactGraphRepository(session).load_graph()
120
+ _ARTIFACT_GRAPH_CACHE[key] = (now, graph)
121
+ return graph
122
+
123
+
124
+ def _invalidate_artifact_graph(project_root: Path) -> None:
125
+ _ARTIFACT_GRAPH_CACHE.pop(str(project_root), None)
126
+
127
+
128
+ # Re-reading and re-parsing the whole trace file on each poll is O(all events).
129
+ # Keep a per-root byte cursor plus the last 50 parsed events; each refresh reads
130
+ # only the bytes appended since the cursor. Semantics match the previous
131
+ # ``list(read_trace_events(...))[-50:]`` (last 50 events, in order).
132
+ _TRACE_EVENTS_LIMIT = 50
133
+ _TRACE_EVENTS_CACHE: dict[str, tuple[int, list[TraceEvent]]] = {}
134
+
135
+
136
+ def _recent_trace_events_cached(project_root: Path) -> list[TraceEvent]:
137
+ key = str(project_root)
138
+ cursor, buffer = _TRACE_EVENTS_CACHE.get(key, (0, []))
139
+ new_events, next_cursor = read_trace_events_since(project_root, cursor)
140
+ if next_cursor < cursor:
141
+ # File was truncated/rotated: discard the stale buffer and start over.
142
+ buffer = new_events[-_TRACE_EVENTS_LIMIT:]
143
+ elif new_events:
144
+ buffer = (buffer + new_events)[-_TRACE_EVENTS_LIMIT:]
145
+ _TRACE_EVENTS_CACHE[key] = (next_cursor, buffer)
146
+ return buffer
147
+
148
+
149
+ def logo_svg() -> str:
150
+ return resources.files("devcouncil.assets").joinpath(LEGACY_LOGO_ASSET).read_text(encoding="utf-8")
151
+
152
+
153
+ def logo_asset_bytes() -> bytes:
154
+ return resources.files("devcouncil.assets").joinpath(LOGO_ASSET).read_bytes()
12
155
 
13
156
 
14
157
  def dashboard_payload(project_root: Path) -> dict:
15
158
  db = get_db(project_root)
16
159
  if not db:
17
- return {"initialized": False, "phase": "UNINITIALIZED", "tasks": [], "coverage": {}, "events": []}
160
+ return {
161
+ "initialized": False,
162
+ "phase": "UNINITIALIZED",
163
+ "tasks": [],
164
+ "coverage": {},
165
+ "events": [],
166
+ "integrations": _integration_summary_cached(project_root),
167
+ "recent_runs": recent_run_artifacts(project_root),
168
+ }
18
169
  with db.get_session() as session:
19
- graph = ArtifactGraphRepository(session).load_graph()
170
+ graph = _artifact_graph_cached(project_root, session)
20
171
  state = StateRepository(session).get_state()
21
172
  phase = compute_phase(graph, state.current_phase if state else None)
22
- tasks = [task.model_dump() for task in TaskRepository(session).get_all()]
173
+ tasks = [task.model_dump() for task in graph.tasks.values()]
23
174
  return {
24
175
  "initialized": True,
25
176
  "phase": phase,
26
177
  "coverage": graph.coverage_summary(),
27
178
  "tasks": tasks,
28
- "events": [event.model_dump(by_alias=True) for event in list(read_trace_events(project_root))[-50:]],
179
+ "events": [event.model_dump(by_alias=True) for event in _recent_trace_events_cached(project_root)],
180
+ "integrations": _integration_summary_cached(project_root),
181
+ "recent_runs": recent_run_artifacts(project_root),
29
182
  }
30
183
 
31
184
 
32
- def dashboard_html() -> str:
33
- return """<!doctype html>
185
+ def _json_response(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
186
+ body = json.dumps(payload).encode("utf-8")
187
+ handler.send_response(status)
188
+ handler.send_header("Content-Type", "application/json")
189
+ handler.send_header("Content-Length", str(len(body)))
190
+ handler.end_headers()
191
+ handler.wfile.write(body)
192
+
193
+
194
+ def _is_loopback_client(handler: BaseHTTPRequestHandler) -> bool:
195
+ host = handler.client_address[0] if handler.client_address else ""
196
+ return host in {"127.0.0.1", "::1", "localhost"}
197
+
198
+
199
+ def dashboard_apply_payload(
200
+ project_root: Path,
201
+ raw_body: bytes,
202
+ *,
203
+ token: str,
204
+ provided_token: str | None,
205
+ ) -> dict:
206
+ if not token or provided_token != token:
207
+ return {"ok": False, "error": "invalid dashboard token"}
208
+ try:
209
+ request = json.loads(raw_body.decode("utf-8") or "{}")
210
+ except json.JSONDecodeError:
211
+ return {"ok": False, "error": "invalid JSON body"}
212
+ if not isinstance(request, dict):
213
+ return {"ok": False, "error": "request body must be a JSON object"}
214
+ target = str(request.get("target") or "").strip()
215
+ include_hooks = bool(request.get("include_hooks", True))
216
+ strict = bool(request.get("strict", False))
217
+ try:
218
+ report = apply_integration_target(
219
+ project_root,
220
+ target,
221
+ include_hooks=include_hooks,
222
+ strict=strict,
223
+ )
224
+ except ValueError as exc:
225
+ return {"ok": False, "error": str(exc)}
226
+ # The next poll should reflect the freshly applied integration.
227
+ _invalidate_integration_summary(project_root)
228
+ return report.as_dict()
229
+
230
+
231
+ def dashboard_html(token: str = "") -> str:
232
+ safe_token = token.replace('"', "")
233
+ return f"""<!doctype html>
34
234
  <html lang="en">
35
235
  <head>
36
236
  <meta charset="utf-8">
37
237
  <meta name="viewport" content="width=device-width, initial-scale=1">
238
+ <meta name="devcouncil-dashboard-token" content="{safe_token}">
38
239
  <title>DevCouncil Dashboard</title>
39
240
  <style>
40
- body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f7f7f5; color: #202124; }
41
- header { padding: 20px 28px; border-bottom: 1px solid #d9d9d4; background: #ffffff; display: flex; align-items: center; justify-content: space-between; }
42
- main { padding: 24px 28px; display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
43
- section { background: #ffffff; border: 1px solid #d9d9d4; border-radius: 8px; padding: 16px; }
44
- h1 { font-size: 20px; margin: 0; }
45
- h2 { font-size: 15px; margin: 0 0 12px; }
46
- .phase { font-weight: 700; }
47
- table { width: 100%; border-collapse: collapse; font-size: 13px; }
48
- th, td { text-align: left; border-bottom: 1px solid #ededeb; padding: 8px; vertical-align: top; }
49
- pre { margin: 0; white-space: pre-wrap; font-size: 12px; }
50
- @media (max-width: 800px) { main { grid-template-columns: 1fr; padding: 16px; } header { padding: 16px; } }
241
+ body {{ margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f7f7f5; color: #202124; }}
242
+ header {{ padding: 18px 28px; border-bottom: 1px solid #d9d9d4; background: #ffffff; display: flex; align-items: center; justify-content: space-between; gap: 18px; }}
243
+ main {{ padding: 24px 28px; display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }}
244
+ section {{ background: #ffffff; border: 1px solid #d9d9d4; border-radius: 8px; padding: 16px; }}
245
+ h1 {{ font-size: 20px; margin: 0; }}
246
+ h2 {{ font-size: 15px; margin: 0 0 12px; }}
247
+ .brand {{ display: flex; align-items: center; gap: 12px; min-width: 0; }}
248
+ .brand img {{ width: 54px; height: 54px; flex: 0 0 auto; filter: drop-shadow(0 8px 12px rgba(12, 36, 52, 0.22)); }}
249
+ .phase-line {{ white-space: nowrap; }}
250
+ .phase {{ font-weight: 700; }}
251
+ table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
252
+ th, td {{ text-align: left; border-bottom: 1px solid #ededeb; padding: 8px; vertical-align: top; }}
253
+ pre {{ margin: 0; white-space: pre-wrap; font-size: 12px; }}
254
+ .toolbar {{ display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }}
255
+ button {{ border: 1px solid #b9b9b2; background: #ffffff; color: #202124; border-radius: 6px; padding: 6px 10px; font: inherit; cursor: pointer; }}
256
+ button:disabled {{ color: #898984; cursor: default; background: #f3f3f0; }}
257
+ .status-pill {{ display: inline-block; min-width: 58px; padding: 2px 6px; border-radius: 999px; font-size: 12px; text-align: center; background: #ededeb; }}
258
+ .status-ok {{ background: #dff3e4; color: #155724; }}
259
+ .status-warn {{ background: #fff3cd; color: #664d03; }}
260
+ .status-fail {{ background: #f8d7da; color: #842029; }}
261
+ #integration-result {{ margin-top: 10px; font-size: 12px; }}
262
+ @media (max-width: 800px) {{ main {{ grid-template-columns: 1fr; padding: 16px; }} header {{ padding: 16px; }} }}
51
263
  </style>
52
264
  </head>
53
265
  <body>
54
- <header><h1>DevCouncil Dashboard</h1><div>Phase: <span id="phase" class="phase">loading</span></div></header>
266
+ <header><div class="brand"><img src="/assets/devcouncil_logo_premium.png" alt="DevCouncil logo"><h1>DevCouncil Dashboard</h1></div><div class="phase-line">Phase: <span id="phase" class="phase">loading</span></div></header>
55
267
  <main>
56
- <section><h2>Coverage</h2><pre id="coverage">{}</pre></section>
268
+ <section><h2>Coverage</h2><pre id="coverage">{{}}</pre></section>
57
269
  <section><h2>Tasks</h2><table><thead><tr><th>ID</th><th>Status</th><th>Title</th></tr></thead><tbody id="tasks"></tbody></table></section>
270
+ <section style="grid-column: 1 / -1;">
271
+ <h2>CLI Integrations</h2>
272
+ <div class="toolbar">
273
+ <button type="button" data-target="all">Apply Detected</button>
274
+ <button type="button" data-target="hooks">Install Hooks</button>
275
+ <button type="button" id="run-check">Run Check</button>
276
+ </div>
277
+ <pre id="integration-result"></pre>
278
+ <table>
279
+ <thead><tr><th>Client</th><th>PATH</th><th>MCP</th><th>Hooks</th><th>Launcher</th><th>Configured</th><th>Notes</th><th>Action</th></tr></thead>
280
+ <tbody id="integrations"></tbody>
281
+ </table>
282
+ </section>
283
+ <section style="grid-column: 1 / -1;">
284
+ <h2>Recent Agent Runs</h2>
285
+ <table>
286
+ <thead><tr><th>Run</th><th>Task</th><th>Agent</th><th>Status</th><th>Transcript</th></tr></thead>
287
+ <tbody id="runs"></tbody>
288
+ </table>
289
+ </section>
58
290
  <section style="grid-column: 1 / -1;"><h2>Recent Trace Events</h2><pre id="events"></pre></section>
59
291
  </main>
60
292
  <script>
61
- function setText(cell, value) {
293
+ function setText(cell, value) {{
62
294
  cell.textContent = value == null ? '' : String(value);
63
295
  return cell;
64
- }
65
- async function refresh() {
296
+ }}
297
+
298
+ function statusClass(value) {{
299
+ if (value === 'ok') return 'status-pill status-ok';
300
+ if (value === 'missing' || value === 'drifted') return 'status-pill status-warn';
301
+ return 'status-pill';
302
+ }}
303
+
304
+ function statusCell(value) {{
305
+ const cell = document.createElement('td');
306
+ const pill = document.createElement('span');
307
+ pill.className = statusClass(value);
308
+ pill.textContent = value || 'n/a';
309
+ cell.appendChild(pill);
310
+ return cell;
311
+ }}
312
+
313
+ const token = document.querySelector('meta[name="devcouncil-dashboard-token"]').content;
314
+
315
+ async function applyIntegration(target) {{
316
+ const resultBox = document.getElementById('integration-result');
317
+ resultBox.textContent = `Running ${{target}}...`;
318
+ const res = await fetch('/api/integrations/apply', {{
319
+ method: 'POST',
320
+ headers: {{
321
+ 'Content-Type': 'application/json',
322
+ 'X-DevCouncil-Dashboard-Token': token,
323
+ }},
324
+ body: JSON.stringify({{ target }}),
325
+ }});
326
+ const payload = await res.json();
327
+ resultBox.textContent = JSON.stringify(payload, null, 2);
328
+ await refresh();
329
+ }}
330
+
331
+ async function runIntegrationCheck() {{
332
+ const resultBox = document.getElementById('integration-result');
333
+ const res = await fetch('/api/integrations/check');
334
+ const payload = await res.json();
335
+ resultBox.textContent = JSON.stringify(payload, null, 2);
336
+ }}
337
+
338
+ async function refresh() {{
66
339
  const res = await fetch('/api/status');
67
340
  const data = await res.json();
68
341
  document.getElementById('phase').textContent = data.phase;
69
342
  document.getElementById('coverage').textContent = JSON.stringify(data.coverage, null, 2);
70
343
  const body = document.getElementById('tasks');
71
- body.replaceChildren(...(data.tasks || []).map(t => {
344
+ body.replaceChildren(...(data.tasks || []).map(t => {{
72
345
  const row = document.createElement('tr');
73
346
  row.appendChild(setText(document.createElement('td'), t.id));
74
347
  row.appendChild(setText(document.createElement('td'), t.status));
75
348
  row.appendChild(setText(document.createElement('td'), t.title));
76
349
  return row;
77
- }));
350
+ }}));
351
+ const integrationsBody = document.getElementById('integrations');
352
+ const capabilities = ((data.integrations || {{}}).capabilities || []);
353
+ integrationsBody.replaceChildren(...capabilities.map(item => {{
354
+ const row = document.createElement('tr');
355
+ row.appendChild(setText(document.createElement('td'), item.label || item.name));
356
+ row.appendChild(setText(document.createElement('td'), item.on_path ? 'yes' : 'no'));
357
+ row.appendChild(setText(document.createElement('td'), item.mcp ? 'yes' : 'no'));
358
+ row.appendChild(setText(document.createElement('td'), item.hooks ? 'yes' : 'verification'));
359
+ row.appendChild(setText(document.createElement('td'), item.launcher_shim ? 'yes' : 'no'));
360
+ row.appendChild(statusCell(item.config_status));
361
+ row.appendChild(setText(document.createElement('td'), item.notes || ''));
362
+ const action = document.createElement('td');
363
+ const button = document.createElement('button');
364
+ button.type = 'button';
365
+ button.dataset.target = item.apply_target || item.name;
366
+ button.textContent = item.config_status === 'ok' ? 'Reapply' : 'Fix';
367
+ button.disabled = !item.fixable;
368
+ action.appendChild(button);
369
+ row.appendChild(action);
370
+ return row;
371
+ }}));
372
+
373
+ const runsBody = document.getElementById('runs');
374
+ runsBody.replaceChildren(...(data.recent_runs || []).map(run => {{
375
+ const row = document.createElement('tr');
376
+ row.appendChild(setText(document.createElement('td'), run.run_id));
377
+ row.appendChild(setText(document.createElement('td'), run.task_id));
378
+ row.appendChild(setText(document.createElement('td'), run.agent));
379
+ row.appendChild(setText(document.createElement('td'), run.status || 'unknown'));
380
+ row.appendChild(setText(document.createElement('td'), run.transcript || ''));
381
+ return row;
382
+ }}));
383
+
78
384
  document.getElementById('events').textContent = JSON.stringify(data.events || [], null, 2);
79
- }
385
+ }}
386
+
387
+ document.addEventListener('click', event => {{
388
+ const button = event.target;
389
+ if (!(button instanceof HTMLButtonElement)) return;
390
+ if (button.id === 'run-check') {{
391
+ runIntegrationCheck();
392
+ return;
393
+ }}
394
+ const target = button.dataset.target;
395
+ if (target) applyIntegration(target);
396
+ }});
397
+
80
398
  refresh();
81
399
  setInterval(refresh, 2000);
82
400
  </script>
@@ -85,6 +403,8 @@ def dashboard_html() -> str:
85
403
 
86
404
 
87
405
  def run_dashboard(project_root: Path, host: str = "127.0.0.1", port: int = 8765) -> None:
406
+ dashboard_token = secrets.token_urlsafe(24)
407
+
88
408
  class DashboardServer(ThreadingHTTPServer):
89
409
  allow_reuse_address = True
90
410
  daemon_threads = True
@@ -92,6 +412,32 @@ def run_dashboard(project_root: Path, host: str = "127.0.0.1", port: int = 8765)
92
412
  class Handler(BaseHTTPRequestHandler):
93
413
  def do_GET(self): # noqa: N802
94
414
  parsed = urlparse(self.path)
415
+ if parsed.path == f"/assets/{LOGO_ASSET}":
416
+ body = logo_asset_bytes()
417
+ self.send_response(200)
418
+ self.send_header("Content-Type", "image/png")
419
+ self.send_header("Cache-Control", "public, max-age=3600")
420
+ self.send_header("Content-Length", str(len(body)))
421
+ self.end_headers()
422
+ self.wfile.write(body)
423
+ return
424
+ if parsed.path == f"/assets/{LEGACY_LOGO_ASSET}":
425
+ body = logo_svg().encode("utf-8")
426
+ self.send_response(200)
427
+ self.send_header("Content-Type", "image/svg+xml; charset=utf-8")
428
+ self.send_header("Cache-Control", "public, max-age=3600")
429
+ self.send_header("Content-Length", str(len(body)))
430
+ self.end_headers()
431
+ self.wfile.write(body)
432
+ return
433
+ if parsed.path == "/api/integrations/check":
434
+ body = json.dumps(build_integration_check_report(project_root).as_dict()).encode("utf-8")
435
+ self.send_response(200)
436
+ self.send_header("Content-Type", "application/json")
437
+ self.send_header("Content-Length", str(len(body)))
438
+ self.end_headers()
439
+ self.wfile.write(body)
440
+ return
95
441
  if parsed.path == "/api/status":
96
442
  body = json.dumps(dashboard_payload(project_root)).encode("utf-8")
97
443
  self.send_response(200)
@@ -108,13 +454,32 @@ def run_dashboard(project_root: Path, host: str = "127.0.0.1", port: int = 8765)
108
454
  self.end_headers()
109
455
  self.wfile.write(body)
110
456
  return
111
- body = dashboard_html().encode("utf-8")
457
+ body = dashboard_html(dashboard_token).encode("utf-8")
112
458
  self.send_response(200)
113
459
  self.send_header("Content-Type", "text/html; charset=utf-8")
114
460
  self.send_header("Content-Length", str(len(body)))
115
461
  self.end_headers()
116
462
  self.wfile.write(body)
117
463
 
464
+ def do_POST(self): # noqa: N802
465
+ parsed = urlparse(self.path)
466
+ if parsed.path != "/api/integrations/apply":
467
+ _json_response(self, 404, {"ok": False, "error": "Not found"})
468
+ return
469
+ if not _is_loopback_client(self):
470
+ _json_response(self, 403, {"ok": False, "error": "dashboard mutations require loopback client"})
471
+ return
472
+ length = int(self.headers.get("Content-Length") or "0")
473
+ raw_body = self.rfile.read(length)
474
+ provided = self.headers.get("X-DevCouncil-Dashboard-Token")
475
+ payload = dashboard_apply_payload(
476
+ project_root,
477
+ raw_body,
478
+ token=dashboard_token,
479
+ provided_token=provided,
480
+ )
481
+ _json_response(self, 200 if payload.get("ok") else 403, payload)
482
+
118
483
  def log_message(self, format, *args): # noqa: A002
119
484
  return
120
485
 
@@ -8,13 +8,19 @@ Two entry points:
8
8
  """
9
9
 
10
10
  import re
11
- from typing import Dict, List, Optional, Pattern
11
+ from typing import Any, Dict, List, Optional, Pattern
12
12
 
13
13
  # Common patterns for sensitive data — each with a human-readable label
14
14
  SECRET_PATTERNS: Dict[str, Pattern] = {
15
15
  "aws_access_key": re.compile(r"(?i)\b(AKIA[0-9A-Z]{16})\b"),
16
16
  "aws_secret_key": re.compile(r"(?i)(?:aws_secret_access_key|aws_secret|secret_key)\s*[=:]\s*([0-9a-zA-Z/+]{40})"),
17
17
  "github_token": re.compile(r"(?i)\b(gh[pusr]_[A-Za-z0-9_]{36})\b"),
18
+ # Anthropic keys are matched before the generic OpenAI-style sk- pattern so the
19
+ # more specific label wins.
20
+ "anthropic_key": re.compile(r"\b(sk-ant-[A-Za-z0-9_-]{20,})\b"),
21
+ "openai_key": re.compile(r"\b(sk-[A-Za-z0-9_-]{20,})\b"),
22
+ "google_api_key": re.compile(r"\b(AIza[0-9A-Za-z_-]{35})\b"),
23
+ "stripe_live_key": re.compile(r"\b((?:sk|rk)_live_[0-9A-Za-z]{16,})\b"),
18
24
  "slack_token": re.compile(r"(?i)\b(xox[baprs]-[0-9]{12}-[0-9]{12}-[0-9]{12}-[a-z0-9]{32})\b"),
19
25
  "jwt": re.compile(r"(?i)\b(ey[a-zA-Z0-9_-]{10,}\.ey[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,})\b"),
20
26
  "generic_api_key": re.compile(r"(?i)(api[_-]?key|secret|token|password)[\"'\s]*[:=][\"'\s]*([a-zA-Z0-9_\-\.]{16,})"),
@@ -121,9 +127,9 @@ def redact_env_vars(text: str) -> str:
121
127
  return env_pattern.sub(_env_replacer, text)
122
128
 
123
129
 
124
- def redact_dict(data: dict) -> dict:
130
+ def redact_dict(data: dict[str, Any]) -> dict[str, Any]:
125
131
  """Recursively redact sensitive patterns from a dictionary (e.g. JSON response)."""
126
- result = {}
132
+ result: dict[str, Any] = {}
127
133
  for k, v in data.items():
128
134
  if isinstance(v, str):
129
135
  result[k] = redact_text(v)
@@ -0,0 +1,69 @@
1
+ """A sanitized environment for spawning *external* executables.
2
+
3
+ When DevCouncil runs from a virtualenv (a project ``.venv`` or a ``uv tool
4
+ install``), its interpreter exports markers — ``VIRTUAL_ENV``, ``PYTHONHOME``,
5
+ ``PYTHONPATH``, ``UV_INTERNAL__PYTHONHOME`` — that any child process inherits.
6
+ Those markers forcibly re-point a freshly-spawned, *differently-built* Python
7
+ (e.g. the globally-installed ``devcouncil`` CLI, or a project's own
8
+ interpreter) at DevCouncil's stdlib/site-packages. The classic symptoms are
9
+ ``AssertionError: SRE module mismatch`` (the child loads its own ``_sre`` C
10
+ extension against our ``re`` stdlib) and spurious ``No module named pytest``.
11
+
12
+ This mirrors :meth:`Verifier._verification_env` but is dependency-free so the
13
+ integration probes (``dev integrate check``) and any other call-site that
14
+ shells out to an *external* program can share one correct implementation.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Dict
23
+
24
+
25
+ def clean_subprocess_env() -> Dict[str, str]:
26
+ """Return a copy of ``os.environ`` with DevCouncil's own virtualenv stripped.
27
+
28
+ No-op (returns a plain copy) when DevCouncil is not running inside a venv,
29
+ so behaviour is unchanged for system / pipx-style installs.
30
+ """
31
+ env = dict(os.environ)
32
+ venv_prefix = Path(sys.prefix).resolve()
33
+ base_prefix = Path(getattr(sys, "base_prefix", sys.prefix)).resolve()
34
+ if venv_prefix == base_prefix:
35
+ return env # Not inside a venv; nothing to strip.
36
+
37
+ venv_dirs = {
38
+ str(venv_prefix).lower(),
39
+ str((venv_prefix / "Scripts").resolve()).lower(),
40
+ str((venv_prefix / "bin").resolve()).lower(),
41
+ }
42
+ path = env.get("PATH", "")
43
+ kept = []
44
+ for entry in path.split(os.pathsep):
45
+ if not entry:
46
+ continue
47
+ try:
48
+ normalized = str(Path(entry).resolve()).lower()
49
+ except Exception:
50
+ normalized = entry.lower()
51
+ if normalized in venv_dirs:
52
+ continue
53
+ kept.append(entry)
54
+ env["PATH"] = os.pathsep.join(kept)
55
+
56
+ own_prefixes = {str(venv_prefix), str(base_prefix)}
57
+ for marker in ("VIRTUAL_ENV", "PYTHONHOME"):
58
+ value = env.get(marker)
59
+ if not value:
60
+ continue
61
+ try:
62
+ resolved = str(Path(value).resolve())
63
+ except Exception:
64
+ resolved = value
65
+ if resolved in own_prefixes:
66
+ env.pop(marker, None)
67
+ # uv stashes the interpreter home here and re-applies it to child pythons.
68
+ env.pop("UV_INTERNAL__PYTHONHOME", None)
69
+ return env