devcouncil 0.1.0 → 0.1.1
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/LICENSE +201 -201
- package/README.md +62 -543
- package/package.json +1 -1
- package/pyproject.toml +29 -26
- package/src/devcouncil/__main__.py +4 -4
- package/src/devcouncil/app/__init__.py +28 -28
- package/src/devcouncil/app/config.py +135 -108
- package/src/devcouncil/app/errors.py +23 -23
- package/src/devcouncil/app/events.py +44 -44
- package/src/devcouncil/app/orchestrator.py +67 -67
- package/src/devcouncil/app/project_status.py +29 -0
- package/src/devcouncil/app/run_context.py +39 -39
- package/src/devcouncil/app/state_machine.py +108 -108
- package/src/devcouncil/artifacts/__init__.py +1 -1
- package/src/devcouncil/artifacts/coverage.py +96 -96
- package/src/devcouncil/artifacts/graph.py +143 -143
- package/src/devcouncil/artifacts/migrations.py +20 -20
- package/src/devcouncil/artifacts/schemas.py +23 -23
- package/src/devcouncil/artifacts/serializer.py +21 -21
- package/src/devcouncil/artifacts/validators.py +27 -27
- package/src/devcouncil/cli/commands/artifacts.py +51 -48
- package/src/devcouncil/cli/commands/ast.py +22 -0
- package/src/devcouncil/cli/commands/baseline.py +35 -32
- package/src/devcouncil/cli/commands/config.py +76 -54
- package/src/devcouncil/cli/commands/dashboard.py +26 -0
- package/src/devcouncil/cli/commands/doctor.py +86 -42
- package/src/devcouncil/cli/commands/go.py +237 -0
- package/src/devcouncil/cli/commands/hook.py +96 -29
- package/src/devcouncil/cli/commands/init.py +67 -56
- package/src/devcouncil/cli/commands/integrate.py +320 -14
- package/src/devcouncil/cli/commands/lsp.py +20 -0
- package/src/devcouncil/cli/commands/map.py +25 -21
- package/src/devcouncil/cli/commands/plan.py +257 -206
- package/src/devcouncil/cli/commands/prompt.py +36 -33
- package/src/devcouncil/cli/commands/repair.py +72 -69
- package/src/devcouncil/cli/commands/report.py +112 -54
- package/src/devcouncil/cli/commands/reset_demo_state.py +31 -28
- package/src/devcouncil/cli/commands/rollback.py +49 -47
- package/src/devcouncil/cli/commands/run.py +252 -207
- package/src/devcouncil/cli/commands/setup.py +159 -18
- package/src/devcouncil/cli/commands/show.py +76 -57
- package/src/devcouncil/cli/commands/status.py +117 -105
- package/src/devcouncil/cli/commands/tasks.py +55 -41
- package/src/devcouncil/cli/commands/trace.py +2 -1
- package/src/devcouncil/cli/commands/verify.py +158 -128
- package/src/devcouncil/cli/commands/version.py +20 -20
- package/src/devcouncil/cli/commands/watch.py +574 -0
- package/src/devcouncil/cli/main.py +42 -24
- package/src/devcouncil/council/prompts/arbiter.md +19 -19
- package/src/devcouncil/council/prompts/critic_a.md +10 -10
- package/src/devcouncil/council/prompts/critic_b.md +10 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
- package/src/devcouncil/council/prompts/planner_a.md +16 -16
- package/src/devcouncil/council/prompts/planner_b.md +16 -16
- package/src/devcouncil/council/prompts/rebuttal.md +10 -10
- package/src/devcouncil/council/prompts/spec_writer.md +12 -12
- package/src/devcouncil/domain/assumption.py +17 -17
- package/src/devcouncil/domain/critique.py +32 -32
- package/src/devcouncil/domain/evidence.py +27 -27
- package/src/devcouncil/domain/gap.py +26 -26
- package/src/devcouncil/domain/requirement.py +22 -22
- package/src/devcouncil/domain/task.py +26 -26
- package/src/devcouncil/execution/__init__.py +1 -1
- package/src/devcouncil/execution/context_builder.py +54 -54
- package/src/devcouncil/execution/executor.py +15 -15
- package/src/devcouncil/execution/hook_policy.py +24 -3
- package/src/devcouncil/execution/patch.py +28 -28
- package/src/devcouncil/execution/permissions.py +44 -44
- package/src/devcouncil/execution/prompt_builder.py +23 -23
- package/src/devcouncil/execution/task_runner.py +63 -63
- package/src/devcouncil/executors/__init__.py +1 -1
- package/src/devcouncil/executors/coding_cli.py +112 -0
- package/src/devcouncil/executors/mini_swe.py +63 -63
- package/src/devcouncil/executors/native/agent.py +81 -81
- package/src/devcouncil/executors/openhands.py +56 -56
- package/src/devcouncil/gating/__init__.py +1 -1
- package/src/devcouncil/gating/checks/clean_git.py +50 -45
- package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +34 -34
- package/src/devcouncil/gating/policy.py +157 -157
- package/src/devcouncil/indexing/__init__.py +1 -1
- package/src/devcouncil/indexing/ast_matcher.py +168 -0
- package/src/devcouncil/indexing/graph_index.py +48 -48
- package/src/devcouncil/indexing/lsp.py +120 -0
- package/src/devcouncil/indexing/repo_mapper.py +208 -204
- package/src/devcouncil/integrations/github.py +35 -35
- package/src/devcouncil/integrations/gitnexus.py +27 -27
- package/src/devcouncil/integrations/graphify.py +34 -34
- package/src/devcouncil/integrations/mcp/server.py +549 -96
- package/src/devcouncil/integrations/pr_comments.py +62 -0
- package/src/devcouncil/live/__init__.py +2 -0
- package/src/devcouncil/live/cards.py +207 -0
- package/src/devcouncil/live/models.py +63 -0
- package/src/devcouncil/live/repair_prompt.py +83 -0
- package/src/devcouncil/live/reviewer.py +70 -0
- package/src/devcouncil/live/signals.py +135 -0
- package/src/devcouncil/live/summary.py +34 -0
- package/src/devcouncil/live/tasks.py +18 -0
- package/src/devcouncil/live/transcripts.py +138 -0
- package/src/devcouncil/llm/__init__.py +1 -1
- package/src/devcouncil/llm/cache.py +38 -38
- package/src/devcouncil/llm/provider.py +146 -125
- package/src/devcouncil/llm/router.py +111 -111
- package/src/devcouncil/planning/__init__.py +1 -1
- package/src/devcouncil/planning/arbiter_service.py +57 -57
- package/src/devcouncil/planning/critique_service.py +66 -66
- package/src/devcouncil/planning/plan_service.py +46 -46
- package/src/devcouncil/planning/prompt_enhancer_service.py +86 -0
- package/src/devcouncil/planning/repair_service.py +39 -39
- package/src/devcouncil/planning/spec_service.py +44 -44
- package/src/devcouncil/reporting/github_check.py +32 -32
- package/src/devcouncil/reporting/json_report.py +20 -17
- package/src/devcouncil/reporting/markdown_report.py +68 -46
- package/src/devcouncil/reporting/report_builder.py +14 -14
- package/src/devcouncil/storage/db.py +66 -66
- package/src/devcouncil/storage/models.py +83 -83
- package/src/devcouncil/storage/repositories.py +299 -222
- package/src/devcouncil/telemetry/cost.py +34 -34
- package/src/devcouncil/telemetry/tracker.py +49 -49
- package/src/devcouncil/ui/__init__.py +1 -0
- package/src/devcouncil/ui/dashboard.py +122 -0
- package/src/devcouncil/utils/__init__.py +1 -1
- package/src/devcouncil/utils/redaction.py +141 -141
- package/src/devcouncil/verification/__init__.py +1 -1
- package/src/devcouncil/verification/implementation_reviewer.py +55 -55
- package/src/devcouncil/verification/verifier.py +319 -302
- package/uv.lock +1 -1
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
from typing import Dict, Any
|
|
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
|
-
}
|
|
11
|
-
|
|
12
|
-
class TelemetryTracker:
|
|
13
|
-
def __init__(self, project_root: Path):
|
|
14
|
-
self.log_file = project_root / ".devcouncil" / "logs" / "telemetry.json"
|
|
15
|
-
self.stats = self._load()
|
|
16
|
-
|
|
17
|
-
def _load(self) -> Dict[str, Any]:
|
|
18
|
-
if self.log_file.exists():
|
|
19
|
-
try:
|
|
20
|
-
with open(self.log_file, "r") as f:
|
|
21
|
-
return json.load(f)
|
|
22
|
-
except Exception:
|
|
23
|
-
pass
|
|
24
|
-
return {"total_cost": 0.0, "total_prompt_tokens": 0, "total_completion_tokens": 0, "models": {}}
|
|
25
|
-
|
|
26
|
-
def _save(self):
|
|
27
|
-
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
-
with open(self.log_file, "w") as f:
|
|
29
|
-
json.dump(self.stats, f, indent=2)
|
|
30
|
-
|
|
31
|
-
def log_usage(self, model: str, usage: Dict[str, int]):
|
|
32
|
-
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
33
|
-
completion_tokens = usage.get("completion_tokens", 0)
|
|
34
|
-
|
|
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"]
|
|
37
|
-
|
|
38
|
-
self.stats["total_cost"] += cost
|
|
39
|
-
self.stats["total_prompt_tokens"] += prompt_tokens
|
|
40
|
-
self.stats["total_completion_tokens"] += completion_tokens
|
|
41
|
-
|
|
42
|
-
if model not in self.stats["models"]:
|
|
43
|
-
self.stats["models"][model] = {"cost": 0.0, "prompt_tokens": 0, "completion_tokens": 0}
|
|
44
|
-
|
|
45
|
-
self.stats["models"][model]["cost"] += cost
|
|
46
|
-
self.stats["models"][model]["prompt_tokens"] += prompt_tokens
|
|
47
|
-
self.stats["models"][model]["completion_tokens"] += completion_tokens
|
|
48
|
-
|
|
49
|
-
self._save()
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Dict, Any
|
|
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
|
+
}
|
|
11
|
+
|
|
12
|
+
class TelemetryTracker:
|
|
13
|
+
def __init__(self, project_root: Path):
|
|
14
|
+
self.log_file = project_root / ".devcouncil" / "logs" / "telemetry.json"
|
|
15
|
+
self.stats = self._load()
|
|
16
|
+
|
|
17
|
+
def _load(self) -> Dict[str, Any]:
|
|
18
|
+
if self.log_file.exists():
|
|
19
|
+
try:
|
|
20
|
+
with open(self.log_file, "r") as f:
|
|
21
|
+
return json.load(f)
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
return {"total_cost": 0.0, "total_prompt_tokens": 0, "total_completion_tokens": 0, "models": {}}
|
|
25
|
+
|
|
26
|
+
def _save(self):
|
|
27
|
+
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
with open(self.log_file, "w") as f:
|
|
29
|
+
json.dump(self.stats, f, indent=2)
|
|
30
|
+
|
|
31
|
+
def log_usage(self, model: str, usage: Dict[str, int]):
|
|
32
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
33
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
34
|
+
|
|
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"]
|
|
37
|
+
|
|
38
|
+
self.stats["total_cost"] += cost
|
|
39
|
+
self.stats["total_prompt_tokens"] += prompt_tokens
|
|
40
|
+
self.stats["total_completion_tokens"] += completion_tokens
|
|
41
|
+
|
|
42
|
+
if model not in self.stats["models"]:
|
|
43
|
+
self.stats["models"][model] = {"cost": 0.0, "prompt_tokens": 0, "completion_tokens": 0}
|
|
44
|
+
|
|
45
|
+
self.stats["models"][model]["cost"] += cost
|
|
46
|
+
self.stats["models"][model]["prompt_tokens"] += prompt_tokens
|
|
47
|
+
self.stats["models"][model]["completion_tokens"] += completion_tokens
|
|
48
|
+
|
|
49
|
+
self._save()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Live DevCouncil dashboard helpers."""
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from devcouncil.app.project_status import compute_phase
|
|
9
|
+
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
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def dashboard_payload(project_root: Path) -> dict:
|
|
15
|
+
db = get_db(project_root)
|
|
16
|
+
if not db:
|
|
17
|
+
return {"initialized": False, "phase": "UNINITIALIZED", "tasks": [], "coverage": {}, "events": []}
|
|
18
|
+
with db.get_session() as session:
|
|
19
|
+
graph = ArtifactGraphRepository(session).load_graph()
|
|
20
|
+
state = StateRepository(session).get_state()
|
|
21
|
+
phase = compute_phase(graph, state.current_phase if state else None)
|
|
22
|
+
tasks = [task.model_dump() for task in TaskRepository(session).get_all()]
|
|
23
|
+
return {
|
|
24
|
+
"initialized": True,
|
|
25
|
+
"phase": phase,
|
|
26
|
+
"coverage": graph.coverage_summary(),
|
|
27
|
+
"tasks": tasks,
|
|
28
|
+
"events": [event.model_dump(by_alias=True) for event in list(read_trace_events(project_root))[-50:]],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def dashboard_html() -> str:
|
|
33
|
+
return """<!doctype html>
|
|
34
|
+
<html lang="en">
|
|
35
|
+
<head>
|
|
36
|
+
<meta charset="utf-8">
|
|
37
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
38
|
+
<title>DevCouncil Dashboard</title>
|
|
39
|
+
<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; } }
|
|
51
|
+
</style>
|
|
52
|
+
</head>
|
|
53
|
+
<body>
|
|
54
|
+
<header><h1>DevCouncil Dashboard</h1><div>Phase: <span id="phase" class="phase">loading</span></div></header>
|
|
55
|
+
<main>
|
|
56
|
+
<section><h2>Coverage</h2><pre id="coverage">{}</pre></section>
|
|
57
|
+
<section><h2>Tasks</h2><table><thead><tr><th>ID</th><th>Status</th><th>Title</th></tr></thead><tbody id="tasks"></tbody></table></section>
|
|
58
|
+
<section style="grid-column: 1 / -1;"><h2>Recent Trace Events</h2><pre id="events"></pre></section>
|
|
59
|
+
</main>
|
|
60
|
+
<script>
|
|
61
|
+
function setText(cell, value) {
|
|
62
|
+
cell.textContent = value == null ? '' : String(value);
|
|
63
|
+
return cell;
|
|
64
|
+
}
|
|
65
|
+
async function refresh() {
|
|
66
|
+
const res = await fetch('/api/status');
|
|
67
|
+
const data = await res.json();
|
|
68
|
+
document.getElementById('phase').textContent = data.phase;
|
|
69
|
+
document.getElementById('coverage').textContent = JSON.stringify(data.coverage, null, 2);
|
|
70
|
+
const body = document.getElementById('tasks');
|
|
71
|
+
body.replaceChildren(...(data.tasks || []).map(t => {
|
|
72
|
+
const row = document.createElement('tr');
|
|
73
|
+
row.appendChild(setText(document.createElement('td'), t.id));
|
|
74
|
+
row.appendChild(setText(document.createElement('td'), t.status));
|
|
75
|
+
row.appendChild(setText(document.createElement('td'), t.title));
|
|
76
|
+
return row;
|
|
77
|
+
}));
|
|
78
|
+
document.getElementById('events').textContent = JSON.stringify(data.events || [], null, 2);
|
|
79
|
+
}
|
|
80
|
+
refresh();
|
|
81
|
+
setInterval(refresh, 2000);
|
|
82
|
+
</script>
|
|
83
|
+
</body>
|
|
84
|
+
</html>"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run_dashboard(project_root: Path, host: str = "127.0.0.1", port: int = 8765) -> None:
|
|
88
|
+
class DashboardServer(ThreadingHTTPServer):
|
|
89
|
+
allow_reuse_address = True
|
|
90
|
+
daemon_threads = True
|
|
91
|
+
|
|
92
|
+
class Handler(BaseHTTPRequestHandler):
|
|
93
|
+
def do_GET(self): # noqa: N802
|
|
94
|
+
parsed = urlparse(self.path)
|
|
95
|
+
if parsed.path == "/api/status":
|
|
96
|
+
body = json.dumps(dashboard_payload(project_root)).encode("utf-8")
|
|
97
|
+
self.send_response(200)
|
|
98
|
+
self.send_header("Content-Type", "application/json")
|
|
99
|
+
self.send_header("Content-Length", str(len(body)))
|
|
100
|
+
self.end_headers()
|
|
101
|
+
self.wfile.write(body)
|
|
102
|
+
return
|
|
103
|
+
if parsed.path.startswith("/api/"):
|
|
104
|
+
body = json.dumps({"error": "Not found"}).encode("utf-8")
|
|
105
|
+
self.send_response(404)
|
|
106
|
+
self.send_header("Content-Type", "application/json")
|
|
107
|
+
self.send_header("Content-Length", str(len(body)))
|
|
108
|
+
self.end_headers()
|
|
109
|
+
self.wfile.write(body)
|
|
110
|
+
return
|
|
111
|
+
body = dashboard_html().encode("utf-8")
|
|
112
|
+
self.send_response(200)
|
|
113
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
114
|
+
self.send_header("Content-Length", str(len(body)))
|
|
115
|
+
self.end_headers()
|
|
116
|
+
self.wfile.write(body)
|
|
117
|
+
|
|
118
|
+
def log_message(self, format, *args): # noqa: A002
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
server = DashboardServer((host, port), Handler)
|
|
122
|
+
server.serve_forever()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"""Utilities package: redaction, paths, hashing, subprocess helpers."""
|
|
1
|
+
"""Utilities package: redaction, paths, hashing, subprocess helpers."""
|
|
@@ -1,141 +1,141 @@
|
|
|
1
|
-
"""Proactive secret and PII redaction for LLM-bound prompts.
|
|
2
|
-
|
|
3
|
-
Two entry points:
|
|
4
|
-
- redact_string(text) -- regex-based redaction of known secret patterns
|
|
5
|
-
- redact_text(text, extra_patterns) -- enhanced version with custom patterns and typed labels
|
|
6
|
-
- redact_env_vars(text) -- redact values in environment variable assignments
|
|
7
|
-
- redact_dict(data) -- recursively redact all string values in a dict
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import re
|
|
11
|
-
from typing import Dict, List, Optional, Pattern
|
|
12
|
-
|
|
13
|
-
# Common patterns for sensitive data — each with a human-readable label
|
|
14
|
-
SECRET_PATTERNS: Dict[str, Pattern] = {
|
|
15
|
-
"aws_access_key": re.compile(r"(?i)\b(AKIA[0-9A-Z]{16})\b"),
|
|
16
|
-
"aws_secret_key": re.compile(r"(?i)(?:aws_secret_access_key|aws_secret|secret_key)\s*[=:]\s*([0-9a-zA-Z/+]{40})"),
|
|
17
|
-
"github_token": re.compile(r"(?i)\b(gh[pusr]_[A-Za-z0-9_]{36})\b"),
|
|
18
|
-
"slack_token": re.compile(r"(?i)\b(xox[baprs]-[0-9]{12}-[0-9]{12}-[0-9]{12}-[a-z0-9]{32})\b"),
|
|
19
|
-
"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
|
-
"generic_api_key": re.compile(r"(?i)(api[_-]?key|secret|token|password)[\"'\s]*[:=][\"'\s]*([a-zA-Z0-9_\-\.]{16,})"),
|
|
21
|
-
"private_key": re.compile(r"(?s)-----BEGIN [A-Z]+ PRIVATE KEY-----.*?-----END [A-Z]+ PRIVATE KEY-----"),
|
|
22
|
-
"bearer": re.compile(r"(?i)\b(Bearer\s+)([a-zA-Z0-9_\-\.]{16,})\b"),
|
|
23
|
-
"database_url": re.compile(r"(?i)((?:postgresql|mysql|mongodb|redis)://[^:]+:)([^@]+)(@.+)"),
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
# For backward compatibility
|
|
27
|
-
def redact_string(text: str) -> str:
|
|
28
|
-
"""Redact known sensitive patterns from a string (legacy API)."""
|
|
29
|
-
return redact_text(text)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def redact_text(text: str, extra_patterns: Optional[List[str]] = None) -> str:
|
|
33
|
-
"""Redact known sensitive patterns from a string.
|
|
34
|
-
|
|
35
|
-
Args:
|
|
36
|
-
text: The input text to redact.
|
|
37
|
-
extra_patterns: Optional list of regex patterns to additionally redact,
|
|
38
|
-
labeled as [REDACTED:custom_N].
|
|
39
|
-
|
|
40
|
-
Returns:
|
|
41
|
-
Text with all sensitive patterns replaced with [REDACTED:type] labels.
|
|
42
|
-
"""
|
|
43
|
-
if not isinstance(text, str):
|
|
44
|
-
return text
|
|
45
|
-
|
|
46
|
-
redacted_text = text
|
|
47
|
-
|
|
48
|
-
for key_type, pattern in SECRET_PATTERNS.items():
|
|
49
|
-
if key_type == "generic_api_key":
|
|
50
|
-
# For the generic pattern, replace the value part (group 2)
|
|
51
|
-
def _make_generic_replacer(kt: str):
|
|
52
|
-
def replacer(match):
|
|
53
|
-
prefix = match.group(1)
|
|
54
|
-
separator = match.group(0)[len(match.group(1)):-len(match.group(2))]
|
|
55
|
-
return f"{prefix}{separator}[REDACTED:{kt}]"
|
|
56
|
-
return replacer
|
|
57
|
-
redacted_text = pattern.sub(_make_generic_replacer(key_type), redacted_text)
|
|
58
|
-
elif key_type == "bearer":
|
|
59
|
-
# Preserve "Bearer " prefix, redact the token
|
|
60
|
-
def _bearer_replacer(match):
|
|
61
|
-
return f"{match.group(1)}[REDACTED:bearer]"
|
|
62
|
-
redacted_text = pattern.sub(_bearer_replacer, redacted_text)
|
|
63
|
-
elif key_type == "database_url":
|
|
64
|
-
# Preserve protocol and host, redact password
|
|
65
|
-
def _db_url_replacer(match):
|
|
66
|
-
return f"{match.group(1)}[REDACTED:database_url]{match.group(3)}"
|
|
67
|
-
redacted_text = pattern.sub(_db_url_replacer, redacted_text)
|
|
68
|
-
else:
|
|
69
|
-
redacted_text = pattern.sub(f"[REDACTED:{key_type}]", redacted_text)
|
|
70
|
-
|
|
71
|
-
# Apply custom extra patterns
|
|
72
|
-
if extra_patterns:
|
|
73
|
-
for i, pat_str in enumerate(extra_patterns):
|
|
74
|
-
try:
|
|
75
|
-
pat = re.compile(pat_str)
|
|
76
|
-
redacted_text = pat.sub(f"[REDACTED:custom_{i}]", redacted_text)
|
|
77
|
-
except re.error:
|
|
78
|
-
pass # Skip invalid patterns
|
|
79
|
-
|
|
80
|
-
return redacted_text
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def redact_env_vars(text: str) -> str:
|
|
84
|
-
"""Redact values in environment variable assignments.
|
|
85
|
-
|
|
86
|
-
Handles patterns like:
|
|
87
|
-
export KEY=value
|
|
88
|
-
KEY='value'
|
|
89
|
-
KEY="value"
|
|
90
|
-
"""
|
|
91
|
-
if not isinstance(text, str):
|
|
92
|
-
return text
|
|
93
|
-
|
|
94
|
-
# Match: optional export, VAR_NAME = value (with optional quotes)
|
|
95
|
-
env_pattern = re.compile(
|
|
96
|
-
r"(?m)^(\s*(?:export\s+)?)" # optional export
|
|
97
|
-
r"([A-Z_][A-Z0-9_]*)" # variable name
|
|
98
|
-
r"(\s*=\s*)" # equals sign
|
|
99
|
-
r"(?:'([^']*)'|\"([^\"]*)\"|(\S+))" # value (quoted or unquoted)
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
sensitive_keys = {
|
|
103
|
-
"api_key", "secret", "token", "password", "passwd", "credential",
|
|
104
|
-
"private_key", "access_key", "secret_key", "auth",
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
def _env_replacer(match):
|
|
108
|
-
prefix = match.group(1)
|
|
109
|
-
var_name = match.group(2)
|
|
110
|
-
eq_sign = match.group(3)
|
|
111
|
-
|
|
112
|
-
# Check if the variable name contains a sensitive keyword
|
|
113
|
-
var_lower = var_name.lower()
|
|
114
|
-
is_sensitive = any(kw in var_lower for kw in sensitive_keys)
|
|
115
|
-
|
|
116
|
-
if is_sensitive:
|
|
117
|
-
return f"{prefix}{var_name}{eq_sign}[REDACTED]"
|
|
118
|
-
|
|
119
|
-
return match.group(0)
|
|
120
|
-
|
|
121
|
-
return env_pattern.sub(_env_replacer, text)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def redact_dict(data: dict) -> dict:
|
|
125
|
-
"""Recursively redact sensitive patterns from a dictionary (e.g. JSON response)."""
|
|
126
|
-
result = {}
|
|
127
|
-
for k, v in data.items():
|
|
128
|
-
if isinstance(v, str):
|
|
129
|
-
result[k] = redact_text(v)
|
|
130
|
-
elif isinstance(v, dict):
|
|
131
|
-
result[k] = redact_dict(v)
|
|
132
|
-
elif isinstance(v, list):
|
|
133
|
-
result[k] = [
|
|
134
|
-
redact_dict(item) if isinstance(item, dict)
|
|
135
|
-
else redact_text(item) if isinstance(item, str)
|
|
136
|
-
else item
|
|
137
|
-
for item in v
|
|
138
|
-
]
|
|
139
|
-
else:
|
|
140
|
-
result[k] = v
|
|
141
|
-
return result
|
|
1
|
+
"""Proactive secret and PII redaction for LLM-bound prompts.
|
|
2
|
+
|
|
3
|
+
Two entry points:
|
|
4
|
+
- redact_string(text) -- regex-based redaction of known secret patterns
|
|
5
|
+
- redact_text(text, extra_patterns) -- enhanced version with custom patterns and typed labels
|
|
6
|
+
- redact_env_vars(text) -- redact values in environment variable assignments
|
|
7
|
+
- redact_dict(data) -- recursively redact all string values in a dict
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Dict, List, Optional, Pattern
|
|
12
|
+
|
|
13
|
+
# Common patterns for sensitive data — each with a human-readable label
|
|
14
|
+
SECRET_PATTERNS: Dict[str, Pattern] = {
|
|
15
|
+
"aws_access_key": re.compile(r"(?i)\b(AKIA[0-9A-Z]{16})\b"),
|
|
16
|
+
"aws_secret_key": re.compile(r"(?i)(?:aws_secret_access_key|aws_secret|secret_key)\s*[=:]\s*([0-9a-zA-Z/+]{40})"),
|
|
17
|
+
"github_token": re.compile(r"(?i)\b(gh[pusr]_[A-Za-z0-9_]{36})\b"),
|
|
18
|
+
"slack_token": re.compile(r"(?i)\b(xox[baprs]-[0-9]{12}-[0-9]{12}-[0-9]{12}-[a-z0-9]{32})\b"),
|
|
19
|
+
"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
|
+
"generic_api_key": re.compile(r"(?i)(api[_-]?key|secret|token|password)[\"'\s]*[:=][\"'\s]*([a-zA-Z0-9_\-\.]{16,})"),
|
|
21
|
+
"private_key": re.compile(r"(?s)-----BEGIN [A-Z]+ PRIVATE KEY-----.*?-----END [A-Z]+ PRIVATE KEY-----"),
|
|
22
|
+
"bearer": re.compile(r"(?i)\b(Bearer\s+)([a-zA-Z0-9_\-\.]{16,})\b"),
|
|
23
|
+
"database_url": re.compile(r"(?i)((?:postgresql|mysql|mongodb|redis)://[^:]+:)([^@]+)(@.+)"),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# For backward compatibility
|
|
27
|
+
def redact_string(text: str) -> str:
|
|
28
|
+
"""Redact known sensitive patterns from a string (legacy API)."""
|
|
29
|
+
return redact_text(text)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def redact_text(text: str, extra_patterns: Optional[List[str]] = None) -> str:
|
|
33
|
+
"""Redact known sensitive patterns from a string.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
text: The input text to redact.
|
|
37
|
+
extra_patterns: Optional list of regex patterns to additionally redact,
|
|
38
|
+
labeled as [REDACTED:custom_N].
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Text with all sensitive patterns replaced with [REDACTED:type] labels.
|
|
42
|
+
"""
|
|
43
|
+
if not isinstance(text, str):
|
|
44
|
+
return text
|
|
45
|
+
|
|
46
|
+
redacted_text = text
|
|
47
|
+
|
|
48
|
+
for key_type, pattern in SECRET_PATTERNS.items():
|
|
49
|
+
if key_type == "generic_api_key":
|
|
50
|
+
# For the generic pattern, replace the value part (group 2)
|
|
51
|
+
def _make_generic_replacer(kt: str):
|
|
52
|
+
def replacer(match):
|
|
53
|
+
prefix = match.group(1)
|
|
54
|
+
separator = match.group(0)[len(match.group(1)):-len(match.group(2))]
|
|
55
|
+
return f"{prefix}{separator}[REDACTED:{kt}]"
|
|
56
|
+
return replacer
|
|
57
|
+
redacted_text = pattern.sub(_make_generic_replacer(key_type), redacted_text)
|
|
58
|
+
elif key_type == "bearer":
|
|
59
|
+
# Preserve "Bearer " prefix, redact the token
|
|
60
|
+
def _bearer_replacer(match):
|
|
61
|
+
return f"{match.group(1)}[REDACTED:bearer]"
|
|
62
|
+
redacted_text = pattern.sub(_bearer_replacer, redacted_text)
|
|
63
|
+
elif key_type == "database_url":
|
|
64
|
+
# Preserve protocol and host, redact password
|
|
65
|
+
def _db_url_replacer(match):
|
|
66
|
+
return f"{match.group(1)}[REDACTED:database_url]{match.group(3)}"
|
|
67
|
+
redacted_text = pattern.sub(_db_url_replacer, redacted_text)
|
|
68
|
+
else:
|
|
69
|
+
redacted_text = pattern.sub(f"[REDACTED:{key_type}]", redacted_text)
|
|
70
|
+
|
|
71
|
+
# Apply custom extra patterns
|
|
72
|
+
if extra_patterns:
|
|
73
|
+
for i, pat_str in enumerate(extra_patterns):
|
|
74
|
+
try:
|
|
75
|
+
pat = re.compile(pat_str)
|
|
76
|
+
redacted_text = pat.sub(f"[REDACTED:custom_{i}]", redacted_text)
|
|
77
|
+
except re.error:
|
|
78
|
+
pass # Skip invalid patterns
|
|
79
|
+
|
|
80
|
+
return redacted_text
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def redact_env_vars(text: str) -> str:
|
|
84
|
+
"""Redact values in environment variable assignments.
|
|
85
|
+
|
|
86
|
+
Handles patterns like:
|
|
87
|
+
export KEY=value
|
|
88
|
+
KEY='value'
|
|
89
|
+
KEY="value"
|
|
90
|
+
"""
|
|
91
|
+
if not isinstance(text, str):
|
|
92
|
+
return text
|
|
93
|
+
|
|
94
|
+
# Match: optional export, VAR_NAME = value (with optional quotes)
|
|
95
|
+
env_pattern = re.compile(
|
|
96
|
+
r"(?m)^(\s*(?:export\s+)?)" # optional export
|
|
97
|
+
r"([A-Z_][A-Z0-9_]*)" # variable name
|
|
98
|
+
r"(\s*=\s*)" # equals sign
|
|
99
|
+
r"(?:'([^']*)'|\"([^\"]*)\"|(\S+))" # value (quoted or unquoted)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
sensitive_keys = {
|
|
103
|
+
"api_key", "secret", "token", "password", "passwd", "credential",
|
|
104
|
+
"private_key", "access_key", "secret_key", "auth",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
def _env_replacer(match):
|
|
108
|
+
prefix = match.group(1)
|
|
109
|
+
var_name = match.group(2)
|
|
110
|
+
eq_sign = match.group(3)
|
|
111
|
+
|
|
112
|
+
# Check if the variable name contains a sensitive keyword
|
|
113
|
+
var_lower = var_name.lower()
|
|
114
|
+
is_sensitive = any(kw in var_lower for kw in sensitive_keys)
|
|
115
|
+
|
|
116
|
+
if is_sensitive:
|
|
117
|
+
return f"{prefix}{var_name}{eq_sign}[REDACTED]"
|
|
118
|
+
|
|
119
|
+
return match.group(0)
|
|
120
|
+
|
|
121
|
+
return env_pattern.sub(_env_replacer, text)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def redact_dict(data: dict) -> dict:
|
|
125
|
+
"""Recursively redact sensitive patterns from a dictionary (e.g. JSON response)."""
|
|
126
|
+
result = {}
|
|
127
|
+
for k, v in data.items():
|
|
128
|
+
if isinstance(v, str):
|
|
129
|
+
result[k] = redact_text(v)
|
|
130
|
+
elif isinstance(v, dict):
|
|
131
|
+
result[k] = redact_dict(v)
|
|
132
|
+
elif isinstance(v, list):
|
|
133
|
+
result[k] = [
|
|
134
|
+
redact_dict(item) if isinstance(item, dict)
|
|
135
|
+
else redact_text(item) if isinstance(item, str)
|
|
136
|
+
else item
|
|
137
|
+
for item in v
|
|
138
|
+
]
|
|
139
|
+
else:
|
|
140
|
+
result[k] = v
|
|
141
|
+
return result
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from typing import List
|
|
3
|
-
from pydantic import BaseModel
|
|
4
|
-
from devcouncil.domain.task import Task
|
|
5
|
-
from devcouncil.domain.requirement import Requirement
|
|
6
|
-
from devcouncil.domain.gap import Gap
|
|
7
|
-
from devcouncil.llm.router import ModelRouter
|
|
8
|
-
from devcouncil.utils.redaction import redact_string
|
|
9
|
-
|
|
10
|
-
class ReviewOutput(BaseModel):
|
|
11
|
-
is_satisfactory: bool
|
|
12
|
-
findings: List[Gap]
|
|
13
|
-
|
|
14
|
-
class ImplementationReviewer:
|
|
15
|
-
"""Uses LLM to review code changes against task requirements."""
|
|
16
|
-
|
|
17
|
-
def __init__(self, router: ModelRouter):
|
|
18
|
-
self.router = router
|
|
19
|
-
|
|
20
|
-
async def review_changes(
|
|
21
|
-
self,
|
|
22
|
-
task: Task,
|
|
23
|
-
requirements: List[Requirement],
|
|
24
|
-
diff: str
|
|
25
|
-
) -> ReviewOutput:
|
|
26
|
-
linked_reqs = [r for r in requirements if r.id in task.requirement_ids]
|
|
27
|
-
if not linked_reqs:
|
|
28
|
-
linked_reqs = requirements
|
|
29
|
-
requirements_json = json.dumps([r.model_dump() for r in linked_reqs], indent=2)
|
|
30
|
-
redacted_diff = redact_string(diff)
|
|
31
|
-
prompt = f"""
|
|
32
|
-
You are an expert software reviewer. Review the following code changes against the task requirements.
|
|
33
|
-
Task: {task.title}
|
|
34
|
-
Description: {task.description}
|
|
35
|
-
|
|
36
|
-
Requirements:
|
|
37
|
-
{requirements_json}
|
|
38
|
-
|
|
39
|
-
Code Diff:
|
|
40
|
-
{redacted_diff}
|
|
41
|
-
|
|
42
|
-
Your task is to identify if the implementation is complete, correct, and follows best practices.
|
|
43
|
-
- Identify missing edge cases.
|
|
44
|
-
- Identify architectural drift.
|
|
45
|
-
- Identify security risks not caught by static scans.
|
|
46
|
-
|
|
47
|
-
Return a JSON object with 'is_satisfactory' and a list of 'findings' (as Gap objects).
|
|
48
|
-
"""
|
|
49
|
-
messages = [{"role": "user", "content": prompt}]
|
|
50
|
-
|
|
51
|
-
return await self.router.complete_structured(
|
|
52
|
-
role="implementation_reviewer",
|
|
53
|
-
messages=messages,
|
|
54
|
-
schema=ReviewOutput
|
|
55
|
-
)
|
|
1
|
+
import json
|
|
2
|
+
from typing import List
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from devcouncil.domain.task import Task
|
|
5
|
+
from devcouncil.domain.requirement import Requirement
|
|
6
|
+
from devcouncil.domain.gap import Gap
|
|
7
|
+
from devcouncil.llm.router import ModelRouter
|
|
8
|
+
from devcouncil.utils.redaction import redact_string
|
|
9
|
+
|
|
10
|
+
class ReviewOutput(BaseModel):
|
|
11
|
+
is_satisfactory: bool
|
|
12
|
+
findings: List[Gap]
|
|
13
|
+
|
|
14
|
+
class ImplementationReviewer:
|
|
15
|
+
"""Uses LLM to review code changes against task requirements."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, router: ModelRouter):
|
|
18
|
+
self.router = router
|
|
19
|
+
|
|
20
|
+
async def review_changes(
|
|
21
|
+
self,
|
|
22
|
+
task: Task,
|
|
23
|
+
requirements: List[Requirement],
|
|
24
|
+
diff: str
|
|
25
|
+
) -> ReviewOutput:
|
|
26
|
+
linked_reqs = [r for r in requirements if r.id in task.requirement_ids]
|
|
27
|
+
if not linked_reqs:
|
|
28
|
+
linked_reqs = requirements
|
|
29
|
+
requirements_json = json.dumps([r.model_dump() for r in linked_reqs], indent=2)
|
|
30
|
+
redacted_diff = redact_string(diff)
|
|
31
|
+
prompt = f"""
|
|
32
|
+
You are an expert software reviewer. Review the following code changes against the task requirements.
|
|
33
|
+
Task: {task.title}
|
|
34
|
+
Description: {task.description}
|
|
35
|
+
|
|
36
|
+
Requirements:
|
|
37
|
+
{requirements_json}
|
|
38
|
+
|
|
39
|
+
Code Diff:
|
|
40
|
+
{redacted_diff}
|
|
41
|
+
|
|
42
|
+
Your task is to identify if the implementation is complete, correct, and follows best practices.
|
|
43
|
+
- Identify missing edge cases.
|
|
44
|
+
- Identify architectural drift.
|
|
45
|
+
- Identify security risks not caught by static scans.
|
|
46
|
+
|
|
47
|
+
Return a JSON object with 'is_satisfactory' and a list of 'findings' (as Gap objects).
|
|
48
|
+
"""
|
|
49
|
+
messages = [{"role": "user", "content": prompt}]
|
|
50
|
+
|
|
51
|
+
return await self.router.complete_structured(
|
|
52
|
+
role="implementation_reviewer",
|
|
53
|
+
messages=messages,
|
|
54
|
+
schema=ReviewOutput
|
|
55
|
+
)
|