devcouncil 0.1.1 → 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 (129) hide show
  1. package/README.md +190 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +167 -5
  5. package/src/devcouncil/artifacts/graph.py +23 -3
  6. package/src/devcouncil/assets/__init__.py +1 -0
  7. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  8. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  9. package/src/devcouncil/cli/commands/agents.py +292 -0
  10. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  11. package/src/devcouncil/cli/commands/check.py +209 -0
  12. package/src/devcouncil/cli/commands/config.py +43 -4
  13. package/src/devcouncil/cli/commands/cost.py +57 -0
  14. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  15. package/src/devcouncil/cli/commands/doctor.py +221 -21
  16. package/src/devcouncil/cli/commands/evidence.py +48 -0
  17. package/src/devcouncil/cli/commands/go.py +452 -33
  18. package/src/devcouncil/cli/commands/handoff.py +69 -0
  19. package/src/devcouncil/cli/commands/hook.py +124 -15
  20. package/src/devcouncil/cli/commands/init.py +154 -18
  21. package/src/devcouncil/cli/commands/integrate.py +894 -105
  22. package/src/devcouncil/cli/commands/map.py +80 -10
  23. package/src/devcouncil/cli/commands/plan.py +212 -51
  24. package/src/devcouncil/cli/commands/prompt.py +18 -7
  25. package/src/devcouncil/cli/commands/repair.py +40 -23
  26. package/src/devcouncil/cli/commands/report.py +8 -0
  27. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  28. package/src/devcouncil/cli/commands/rollback.py +27 -28
  29. package/src/devcouncil/cli/commands/run.py +69 -49
  30. package/src/devcouncil/cli/commands/runs.py +223 -0
  31. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  32. package/src/devcouncil/cli/commands/semantic.py +47 -0
  33. package/src/devcouncil/cli/commands/setup.py +145 -6
  34. package/src/devcouncil/cli/commands/shell.py +73 -0
  35. package/src/devcouncil/cli/commands/skills.py +88 -0
  36. package/src/devcouncil/cli/commands/status.py +25 -1
  37. package/src/devcouncil/cli/commands/trace.py +47 -3
  38. package/src/devcouncil/cli/commands/verify.py +138 -3
  39. package/src/devcouncil/cli/commands/watch.py +9 -9
  40. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  41. package/src/devcouncil/cli/main.py +56 -7
  42. package/src/devcouncil/domain/evidence.py +22 -2
  43. package/src/devcouncil/domain/gap.py +27 -1
  44. package/src/devcouncil/domain/task.py +31 -2
  45. package/src/devcouncil/execution/checkpoints.py +246 -0
  46. package/src/devcouncil/execution/context_builder.py +1 -1
  47. package/src/devcouncil/execution/fs_watcher.py +180 -0
  48. package/src/devcouncil/execution/handoff.py +102 -0
  49. package/src/devcouncil/execution/hook_policy.py +162 -74
  50. package/src/devcouncil/execution/patch.py +59 -10
  51. package/src/devcouncil/execution/permissions.py +17 -24
  52. package/src/devcouncil/execution/policy_engine.py +343 -0
  53. package/src/devcouncil/execution/prompt_builder.py +633 -21
  54. package/src/devcouncil/execution/shell_session.py +225 -0
  55. package/src/devcouncil/execution/task_runner.py +6 -2
  56. package/src/devcouncil/executors/agent_registry.py +575 -0
  57. package/src/devcouncil/executors/coding_cli.py +663 -39
  58. package/src/devcouncil/executors/native/agent.py +121 -20
  59. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  60. package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
  61. package/src/devcouncil/gating/policy.py +158 -10
  62. package/src/devcouncil/hardware.py +184 -0
  63. package/src/devcouncil/indexing/ast_matcher.py +1 -1
  64. package/src/devcouncil/indexing/lsp.py +45 -4
  65. package/src/devcouncil/indexing/repo_mapper.py +1256 -9
  66. package/src/devcouncil/indexing/semantic_index.py +205 -0
  67. package/src/devcouncil/integrations/actions.py +146 -0
  68. package/src/devcouncil/integrations/check.py +423 -0
  69. package/src/devcouncil/integrations/github_intent.py +142 -0
  70. package/src/devcouncil/integrations/gitnexus.py +35 -0
  71. package/src/devcouncil/integrations/mcp/server.py +1552 -29
  72. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  73. package/src/devcouncil/live/cards.py +161 -19
  74. package/src/devcouncil/live/signals.py +2 -2
  75. package/src/devcouncil/live/transcripts.py +9 -6
  76. package/src/devcouncil/llm/cache.py +10 -6
  77. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  78. package/src/devcouncil/llm/provider.py +515 -34
  79. package/src/devcouncil/llm/router.py +231 -46
  80. package/src/devcouncil/optimization/__init__.py +1 -0
  81. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  82. package/src/devcouncil/planning/correction_manifest.py +303 -0
  83. package/src/devcouncil/planning/critique_service.py +7 -2
  84. package/src/devcouncil/planning/plan_service.py +17 -3
  85. package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
  86. package/src/devcouncil/planning/spec_service.py +27 -1
  87. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  88. package/src/devcouncil/repo/gitignore.py +123 -0
  89. package/src/devcouncil/repo/sca.py +374 -0
  90. package/src/devcouncil/reporting/json_report.py +11 -1
  91. package/src/devcouncil/reporting/markdown_report.py +15 -0
  92. package/src/devcouncil/skills/__init__.py +19 -0
  93. package/src/devcouncil/skills/library/README.md +46 -0
  94. package/src/devcouncil/skills/library/ai-training.md +50 -0
  95. package/src/devcouncil/skills/library/android.md +50 -0
  96. package/src/devcouncil/skills/library/backend.md +52 -0
  97. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  98. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  99. package/src/devcouncil/skills/library/desktop.md +46 -0
  100. package/src/devcouncil/skills/library/devops.md +48 -0
  101. package/src/devcouncil/skills/library/game-dev.md +46 -0
  102. package/src/devcouncil/skills/library/ios.md +48 -0
  103. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  104. package/src/devcouncil/skills/library/security.md +48 -0
  105. package/src/devcouncil/skills/library/systems.md +48 -0
  106. package/src/devcouncil/skills/library/web.md +47 -0
  107. package/src/devcouncil/skills/library/windows.md +47 -0
  108. package/src/devcouncil/skills/registry.py +330 -0
  109. package/src/devcouncil/storage/db.py +83 -2
  110. package/src/devcouncil/storage/models.py +121 -0
  111. package/src/devcouncil/storage/native.py +557 -0
  112. package/src/devcouncil/storage/repositories.py +137 -75
  113. package/src/devcouncil/telemetry/cost.py +123 -17
  114. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  115. package/src/devcouncil/telemetry/pricing.py +28 -0
  116. package/src/devcouncil/telemetry/traces.py +62 -7
  117. package/src/devcouncil/telemetry/tracker.py +12 -9
  118. package/src/devcouncil/ui/dashboard.py +324 -23
  119. package/src/devcouncil/utils/redaction.py +9 -3
  120. package/src/devcouncil/utils/subprocess_env.py +69 -0
  121. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  122. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  123. package/src/devcouncil/verification/diff_coverage.py +353 -0
  124. package/src/devcouncil/verification/next_actions.py +189 -0
  125. package/src/devcouncil/verification/sandbox.py +178 -0
  126. package/src/devcouncil/verification/test_resolver.py +91 -0
  127. package/src/devcouncil/verification/verifier.py +1065 -47
  128. package/uv.lock +205 -64
  129. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -2,12 +2,7 @@ import json
2
2
  from pathlib import Path
3
3
  from typing import Dict, Any
4
4
 
5
- COST_PER_1K_TOKENS = {
6
- "anthropic/claude-3-opus": {"prompt": 0.015, "completion": 0.075},
7
- "anthropic/claude-3.5-sonnet": {"prompt": 0.003, "completion": 0.015},
8
- "openai/gpt-4o": {"prompt": 0.005, "completion": 0.015},
9
- "google/gemini-pro-1.5": {"prompt": 0.00125, "completion": 0.00375},
10
- }
5
+ from devcouncil.telemetry.pricing import pricing_for_model
11
6
 
12
7
  class TelemetryTracker:
13
8
  def __init__(self, project_root: Path):
@@ -28,12 +23,20 @@ class TelemetryTracker:
28
23
  with open(self.log_file, "w") as f:
29
24
  json.dump(self.stats, f, indent=2)
30
25
 
31
- def log_usage(self, model: str, usage: Dict[str, int]):
26
+ def log_usage(self, model: str, usage: Dict[str, int], *, local: bool = False):
32
27
  prompt_tokens = usage.get("prompt_tokens", 0)
33
28
  completion_tokens = usage.get("completion_tokens", 0)
34
29
 
35
- rates = COST_PER_1K_TOKENS.get(model, {"prompt": 0.0, "completion": 0.0})
36
- cost = (prompt_tokens / 1000.0) * rates["prompt"] + (completion_tokens / 1000.0) * rates["completion"]
30
+ if local:
31
+ # On-device providers (Ollama) incur no per-token cost. Zero by provider, not
32
+ # by model-id matching — consistent with telemetry/cost.py — so a local tag
33
+ # that happens to collide with a priced entry is never billed.
34
+ cost = 0.0
35
+ else:
36
+ rates = pricing_for_model(model)
37
+ cost = (prompt_tokens / 1000.0) * rates["prompt_per_1k"] + (
38
+ completion_tokens / 1000.0
39
+ ) * rates["completion_per_1k"]
37
40
 
38
41
  self.stats["total_cost"] += cost
39
42
  self.stats["total_prompt_tokens"] += prompt_tokens
@@ -1,20 +1,107 @@
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
6
9
  from urllib.parse import urlparse
7
10
 
8
11
  from devcouncil.app.project_status import compute_phase
12
+ from devcouncil.integrations.actions import apply_integration_target
13
+ from devcouncil.integrations.check import build_integration_check_report, integration_status_summary
9
14
  from devcouncil.storage.db import get_db
10
15
  from devcouncil.storage.repositories import ArtifactGraphRepository, StateRepository, TaskRepository
11
16
  from devcouncil.telemetry.traces import read_trace_events
12
17
 
18
+ LOGO_ASSET = "devcouncil_logo_premium.png"
19
+ LEGACY_LOGO_ASSET = "devcouncil-logo.svg"
20
+
21
+
22
+ # mtime-keyed cache so the dashboard's poll loop doesn't re-read and re-parse
23
+ # every run manifest on each refresh.
24
+ _RUN_MANIFEST_CACHE: dict[str, tuple[float, dict]] = {}
25
+
26
+
27
+ def _load_run_manifest(manifest_path: Path) -> dict | None:
28
+ key = str(manifest_path)
29
+ try:
30
+ mtime = manifest_path.stat().st_mtime
31
+ except OSError:
32
+ return None
33
+ cached = _RUN_MANIFEST_CACHE.get(key)
34
+ if cached is not None and cached[0] == mtime:
35
+ return dict(cached[1])
36
+ try:
37
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8")) or {}
38
+ except (json.JSONDecodeError, OSError):
39
+ return None
40
+ _RUN_MANIFEST_CACHE[key] = (mtime, manifest)
41
+ return dict(manifest)
42
+
43
+
44
+ def recent_run_artifacts(project_root: Path, *, limit: int = 10) -> list[dict]:
45
+ runs_dir = project_root / ".devcouncil" / "runs"
46
+ if not runs_dir.exists():
47
+ return []
48
+ manifests: list[dict] = []
49
+ for manifest_path in sorted(
50
+ runs_dir.glob("*/agent-run.json"),
51
+ key=lambda path: path.stat().st_mtime,
52
+ reverse=True,
53
+ ):
54
+ manifest = _load_run_manifest(manifest_path)
55
+ if manifest is None:
56
+ continue
57
+ manifest["manifest_path"] = str(manifest_path)
58
+ manifests.append(manifest)
59
+ if len(manifests) >= limit:
60
+ break
61
+ return manifests
62
+
63
+
64
+ # Integration status probes the filesystem (and optionally CLIs) — far too
65
+ # expensive to recompute on every 2-second dashboard poll.
66
+ _INTEGRATION_SUMMARY_TTL_SECONDS = 5.0
67
+ _INTEGRATION_SUMMARY_CACHE: dict[str, tuple[float, dict]] = {}
68
+
69
+
70
+ def _integration_summary_cached(project_root: Path) -> dict:
71
+ key = str(project_root)
72
+ now = time.monotonic()
73
+ cached = _INTEGRATION_SUMMARY_CACHE.get(key)
74
+ if cached is not None and now - cached[0] < _INTEGRATION_SUMMARY_TTL_SECONDS:
75
+ return cached[1]
76
+ summary = integration_status_summary(project_root)
77
+ _INTEGRATION_SUMMARY_CACHE[key] = (now, summary)
78
+ return summary
79
+
80
+
81
+ def _invalidate_integration_summary(project_root: Path) -> None:
82
+ _INTEGRATION_SUMMARY_CACHE.pop(str(project_root), None)
83
+
84
+
85
+ def logo_svg() -> str:
86
+ return resources.files("devcouncil.assets").joinpath(LEGACY_LOGO_ASSET).read_text(encoding="utf-8")
87
+
88
+
89
+ def logo_asset_bytes() -> bytes:
90
+ return resources.files("devcouncil.assets").joinpath(LOGO_ASSET).read_bytes()
91
+
13
92
 
14
93
  def dashboard_payload(project_root: Path) -> dict:
15
94
  db = get_db(project_root)
16
95
  if not db:
17
- return {"initialized": False, "phase": "UNINITIALIZED", "tasks": [], "coverage": {}, "events": []}
96
+ return {
97
+ "initialized": False,
98
+ "phase": "UNINITIALIZED",
99
+ "tasks": [],
100
+ "coverage": {},
101
+ "events": [],
102
+ "integrations": _integration_summary_cached(project_root),
103
+ "recent_runs": recent_run_artifacts(project_root),
104
+ }
18
105
  with db.get_session() as session:
19
106
  graph = ArtifactGraphRepository(session).load_graph()
20
107
  state = StateRepository(session).get_state()
@@ -26,57 +113,224 @@ def dashboard_payload(project_root: Path) -> dict:
26
113
  "coverage": graph.coverage_summary(),
27
114
  "tasks": tasks,
28
115
  "events": [event.model_dump(by_alias=True) for event in list(read_trace_events(project_root))[-50:]],
116
+ "integrations": _integration_summary_cached(project_root),
117
+ "recent_runs": recent_run_artifacts(project_root),
29
118
  }
30
119
 
31
120
 
32
- def dashboard_html() -> str:
33
- return """<!doctype html>
121
+ def _json_response(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
122
+ body = json.dumps(payload).encode("utf-8")
123
+ handler.send_response(status)
124
+ handler.send_header("Content-Type", "application/json")
125
+ handler.send_header("Content-Length", str(len(body)))
126
+ handler.end_headers()
127
+ handler.wfile.write(body)
128
+
129
+
130
+ def _is_loopback_client(handler: BaseHTTPRequestHandler) -> bool:
131
+ host = handler.client_address[0] if handler.client_address else ""
132
+ return host in {"127.0.0.1", "::1", "localhost"}
133
+
134
+
135
+ def dashboard_apply_payload(
136
+ project_root: Path,
137
+ raw_body: bytes,
138
+ *,
139
+ token: str,
140
+ provided_token: str | None,
141
+ ) -> dict:
142
+ if not token or provided_token != token:
143
+ return {"ok": False, "error": "invalid dashboard token"}
144
+ try:
145
+ request = json.loads(raw_body.decode("utf-8") or "{}")
146
+ except json.JSONDecodeError:
147
+ return {"ok": False, "error": "invalid JSON body"}
148
+ if not isinstance(request, dict):
149
+ return {"ok": False, "error": "request body must be a JSON object"}
150
+ target = str(request.get("target") or "").strip()
151
+ include_hooks = bool(request.get("include_hooks", True))
152
+ strict = bool(request.get("strict", False))
153
+ try:
154
+ report = apply_integration_target(
155
+ project_root,
156
+ target,
157
+ include_hooks=include_hooks,
158
+ strict=strict,
159
+ )
160
+ except ValueError as exc:
161
+ return {"ok": False, "error": str(exc)}
162
+ # The next poll should reflect the freshly applied integration.
163
+ _invalidate_integration_summary(project_root)
164
+ return report.as_dict()
165
+
166
+
167
+ def dashboard_html(token: str = "") -> str:
168
+ safe_token = token.replace('"', "")
169
+ return f"""<!doctype html>
34
170
  <html lang="en">
35
171
  <head>
36
172
  <meta charset="utf-8">
37
173
  <meta name="viewport" content="width=device-width, initial-scale=1">
174
+ <meta name="devcouncil-dashboard-token" content="{safe_token}">
38
175
  <title>DevCouncil Dashboard</title>
39
176
  <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; } }
177
+ body {{ margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f7f7f5; color: #202124; }}
178
+ header {{ padding: 18px 28px; border-bottom: 1px solid #d9d9d4; background: #ffffff; display: flex; align-items: center; justify-content: space-between; gap: 18px; }}
179
+ main {{ padding: 24px 28px; display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }}
180
+ section {{ background: #ffffff; border: 1px solid #d9d9d4; border-radius: 8px; padding: 16px; }}
181
+ h1 {{ font-size: 20px; margin: 0; }}
182
+ h2 {{ font-size: 15px; margin: 0 0 12px; }}
183
+ .brand {{ display: flex; align-items: center; gap: 12px; min-width: 0; }}
184
+ .brand img {{ width: 54px; height: 54px; flex: 0 0 auto; filter: drop-shadow(0 8px 12px rgba(12, 36, 52, 0.22)); }}
185
+ .phase-line {{ white-space: nowrap; }}
186
+ .phase {{ font-weight: 700; }}
187
+ table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
188
+ th, td {{ text-align: left; border-bottom: 1px solid #ededeb; padding: 8px; vertical-align: top; }}
189
+ pre {{ margin: 0; white-space: pre-wrap; font-size: 12px; }}
190
+ .toolbar {{ display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }}
191
+ button {{ border: 1px solid #b9b9b2; background: #ffffff; color: #202124; border-radius: 6px; padding: 6px 10px; font: inherit; cursor: pointer; }}
192
+ button:disabled {{ color: #898984; cursor: default; background: #f3f3f0; }}
193
+ .status-pill {{ display: inline-block; min-width: 58px; padding: 2px 6px; border-radius: 999px; font-size: 12px; text-align: center; background: #ededeb; }}
194
+ .status-ok {{ background: #dff3e4; color: #155724; }}
195
+ .status-warn {{ background: #fff3cd; color: #664d03; }}
196
+ .status-fail {{ background: #f8d7da; color: #842029; }}
197
+ #integration-result {{ margin-top: 10px; font-size: 12px; }}
198
+ @media (max-width: 800px) {{ main {{ grid-template-columns: 1fr; padding: 16px; }} header {{ padding: 16px; }} }}
51
199
  </style>
52
200
  </head>
53
201
  <body>
54
- <header><h1>DevCouncil Dashboard</h1><div>Phase: <span id="phase" class="phase">loading</span></div></header>
202
+ <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
203
  <main>
56
- <section><h2>Coverage</h2><pre id="coverage">{}</pre></section>
204
+ <section><h2>Coverage</h2><pre id="coverage">{{}}</pre></section>
57
205
  <section><h2>Tasks</h2><table><thead><tr><th>ID</th><th>Status</th><th>Title</th></tr></thead><tbody id="tasks"></tbody></table></section>
206
+ <section style="grid-column: 1 / -1;">
207
+ <h2>CLI Integrations</h2>
208
+ <div class="toolbar">
209
+ <button type="button" data-target="all">Apply Detected</button>
210
+ <button type="button" data-target="hooks">Install Hooks</button>
211
+ <button type="button" id="run-check">Run Check</button>
212
+ </div>
213
+ <pre id="integration-result"></pre>
214
+ <table>
215
+ <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>
216
+ <tbody id="integrations"></tbody>
217
+ </table>
218
+ </section>
219
+ <section style="grid-column: 1 / -1;">
220
+ <h2>Recent Agent Runs</h2>
221
+ <table>
222
+ <thead><tr><th>Run</th><th>Task</th><th>Agent</th><th>Status</th><th>Transcript</th></tr></thead>
223
+ <tbody id="runs"></tbody>
224
+ </table>
225
+ </section>
58
226
  <section style="grid-column: 1 / -1;"><h2>Recent Trace Events</h2><pre id="events"></pre></section>
59
227
  </main>
60
228
  <script>
61
- function setText(cell, value) {
229
+ function setText(cell, value) {{
62
230
  cell.textContent = value == null ? '' : String(value);
63
231
  return cell;
64
- }
65
- async function refresh() {
232
+ }}
233
+
234
+ function statusClass(value) {{
235
+ if (value === 'ok') return 'status-pill status-ok';
236
+ if (value === 'missing' || value === 'drifted') return 'status-pill status-warn';
237
+ return 'status-pill';
238
+ }}
239
+
240
+ function statusCell(value) {{
241
+ const cell = document.createElement('td');
242
+ const pill = document.createElement('span');
243
+ pill.className = statusClass(value);
244
+ pill.textContent = value || 'n/a';
245
+ cell.appendChild(pill);
246
+ return cell;
247
+ }}
248
+
249
+ const token = document.querySelector('meta[name="devcouncil-dashboard-token"]').content;
250
+
251
+ async function applyIntegration(target) {{
252
+ const resultBox = document.getElementById('integration-result');
253
+ resultBox.textContent = `Running ${{target}}...`;
254
+ const res = await fetch('/api/integrations/apply', {{
255
+ method: 'POST',
256
+ headers: {{
257
+ 'Content-Type': 'application/json',
258
+ 'X-DevCouncil-Dashboard-Token': token,
259
+ }},
260
+ body: JSON.stringify({{ target }}),
261
+ }});
262
+ const payload = await res.json();
263
+ resultBox.textContent = JSON.stringify(payload, null, 2);
264
+ await refresh();
265
+ }}
266
+
267
+ async function runIntegrationCheck() {{
268
+ const resultBox = document.getElementById('integration-result');
269
+ const res = await fetch('/api/integrations/check');
270
+ const payload = await res.json();
271
+ resultBox.textContent = JSON.stringify(payload, null, 2);
272
+ }}
273
+
274
+ async function refresh() {{
66
275
  const res = await fetch('/api/status');
67
276
  const data = await res.json();
68
277
  document.getElementById('phase').textContent = data.phase;
69
278
  document.getElementById('coverage').textContent = JSON.stringify(data.coverage, null, 2);
70
279
  const body = document.getElementById('tasks');
71
- body.replaceChildren(...(data.tasks || []).map(t => {
280
+ body.replaceChildren(...(data.tasks || []).map(t => {{
72
281
  const row = document.createElement('tr');
73
282
  row.appendChild(setText(document.createElement('td'), t.id));
74
283
  row.appendChild(setText(document.createElement('td'), t.status));
75
284
  row.appendChild(setText(document.createElement('td'), t.title));
76
285
  return row;
77
- }));
286
+ }}));
287
+ const integrationsBody = document.getElementById('integrations');
288
+ const capabilities = ((data.integrations || {{}}).capabilities || []);
289
+ integrationsBody.replaceChildren(...capabilities.map(item => {{
290
+ const row = document.createElement('tr');
291
+ row.appendChild(setText(document.createElement('td'), item.label || item.name));
292
+ row.appendChild(setText(document.createElement('td'), item.on_path ? 'yes' : 'no'));
293
+ row.appendChild(setText(document.createElement('td'), item.mcp ? 'yes' : 'no'));
294
+ row.appendChild(setText(document.createElement('td'), item.hooks ? 'yes' : 'verification'));
295
+ row.appendChild(setText(document.createElement('td'), item.launcher_shim ? 'yes' : 'no'));
296
+ row.appendChild(statusCell(item.config_status));
297
+ row.appendChild(setText(document.createElement('td'), item.notes || ''));
298
+ const action = document.createElement('td');
299
+ const button = document.createElement('button');
300
+ button.type = 'button';
301
+ button.dataset.target = item.apply_target || item.name;
302
+ button.textContent = item.config_status === 'ok' ? 'Reapply' : 'Fix';
303
+ button.disabled = !item.fixable;
304
+ action.appendChild(button);
305
+ row.appendChild(action);
306
+ return row;
307
+ }}));
308
+
309
+ const runsBody = document.getElementById('runs');
310
+ runsBody.replaceChildren(...(data.recent_runs || []).map(run => {{
311
+ const row = document.createElement('tr');
312
+ row.appendChild(setText(document.createElement('td'), run.run_id));
313
+ row.appendChild(setText(document.createElement('td'), run.task_id));
314
+ row.appendChild(setText(document.createElement('td'), run.agent));
315
+ row.appendChild(setText(document.createElement('td'), run.status || 'unknown'));
316
+ row.appendChild(setText(document.createElement('td'), run.transcript || ''));
317
+ return row;
318
+ }}));
319
+
78
320
  document.getElementById('events').textContent = JSON.stringify(data.events || [], null, 2);
79
- }
321
+ }}
322
+
323
+ document.addEventListener('click', event => {{
324
+ const button = event.target;
325
+ if (!(button instanceof HTMLButtonElement)) return;
326
+ if (button.id === 'run-check') {{
327
+ runIntegrationCheck();
328
+ return;
329
+ }}
330
+ const target = button.dataset.target;
331
+ if (target) applyIntegration(target);
332
+ }});
333
+
80
334
  refresh();
81
335
  setInterval(refresh, 2000);
82
336
  </script>
@@ -85,6 +339,8 @@ def dashboard_html() -> str:
85
339
 
86
340
 
87
341
  def run_dashboard(project_root: Path, host: str = "127.0.0.1", port: int = 8765) -> None:
342
+ dashboard_token = secrets.token_urlsafe(24)
343
+
88
344
  class DashboardServer(ThreadingHTTPServer):
89
345
  allow_reuse_address = True
90
346
  daemon_threads = True
@@ -92,6 +348,32 @@ def run_dashboard(project_root: Path, host: str = "127.0.0.1", port: int = 8765)
92
348
  class Handler(BaseHTTPRequestHandler):
93
349
  def do_GET(self): # noqa: N802
94
350
  parsed = urlparse(self.path)
351
+ if parsed.path == f"/assets/{LOGO_ASSET}":
352
+ body = logo_asset_bytes()
353
+ self.send_response(200)
354
+ self.send_header("Content-Type", "image/png")
355
+ self.send_header("Cache-Control", "public, max-age=3600")
356
+ self.send_header("Content-Length", str(len(body)))
357
+ self.end_headers()
358
+ self.wfile.write(body)
359
+ return
360
+ if parsed.path == f"/assets/{LEGACY_LOGO_ASSET}":
361
+ body = logo_svg().encode("utf-8")
362
+ self.send_response(200)
363
+ self.send_header("Content-Type", "image/svg+xml; charset=utf-8")
364
+ self.send_header("Cache-Control", "public, max-age=3600")
365
+ self.send_header("Content-Length", str(len(body)))
366
+ self.end_headers()
367
+ self.wfile.write(body)
368
+ return
369
+ if parsed.path == "/api/integrations/check":
370
+ body = json.dumps(build_integration_check_report(project_root).as_dict()).encode("utf-8")
371
+ self.send_response(200)
372
+ self.send_header("Content-Type", "application/json")
373
+ self.send_header("Content-Length", str(len(body)))
374
+ self.end_headers()
375
+ self.wfile.write(body)
376
+ return
95
377
  if parsed.path == "/api/status":
96
378
  body = json.dumps(dashboard_payload(project_root)).encode("utf-8")
97
379
  self.send_response(200)
@@ -108,13 +390,32 @@ def run_dashboard(project_root: Path, host: str = "127.0.0.1", port: int = 8765)
108
390
  self.end_headers()
109
391
  self.wfile.write(body)
110
392
  return
111
- body = dashboard_html().encode("utf-8")
393
+ body = dashboard_html(dashboard_token).encode("utf-8")
112
394
  self.send_response(200)
113
395
  self.send_header("Content-Type", "text/html; charset=utf-8")
114
396
  self.send_header("Content-Length", str(len(body)))
115
397
  self.end_headers()
116
398
  self.wfile.write(body)
117
399
 
400
+ def do_POST(self): # noqa: N802
401
+ parsed = urlparse(self.path)
402
+ if parsed.path != "/api/integrations/apply":
403
+ _json_response(self, 404, {"ok": False, "error": "Not found"})
404
+ return
405
+ if not _is_loopback_client(self):
406
+ _json_response(self, 403, {"ok": False, "error": "dashboard mutations require loopback client"})
407
+ return
408
+ length = int(self.headers.get("Content-Length") or "0")
409
+ raw_body = self.rfile.read(length)
410
+ provided = self.headers.get("X-DevCouncil-Dashboard-Token")
411
+ payload = dashboard_apply_payload(
412
+ project_root,
413
+ raw_body,
414
+ token=dashboard_token,
415
+ provided_token=provided,
416
+ )
417
+ _json_response(self, 200 if payload.get("ok") else 403, payload)
418
+
118
419
  def log_message(self, format, *args): # noqa: A002
119
420
  return
120
421
 
@@ -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