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.
- package/README.md +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +220 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +195 -54
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- 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
|
|
11
|
-
from devcouncil.telemetry.traces import
|
|
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 {
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
33
|
-
|
|
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:
|
|
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
|
-
.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|