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.
Files changed (128) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +62 -543
  3. package/package.json +1 -1
  4. package/pyproject.toml +29 -26
  5. package/src/devcouncil/__main__.py +4 -4
  6. package/src/devcouncil/app/__init__.py +28 -28
  7. package/src/devcouncil/app/config.py +135 -108
  8. package/src/devcouncil/app/errors.py +23 -23
  9. package/src/devcouncil/app/events.py +44 -44
  10. package/src/devcouncil/app/orchestrator.py +67 -67
  11. package/src/devcouncil/app/project_status.py +29 -0
  12. package/src/devcouncil/app/run_context.py +39 -39
  13. package/src/devcouncil/app/state_machine.py +108 -108
  14. package/src/devcouncil/artifacts/__init__.py +1 -1
  15. package/src/devcouncil/artifacts/coverage.py +96 -96
  16. package/src/devcouncil/artifacts/graph.py +143 -143
  17. package/src/devcouncil/artifacts/migrations.py +20 -20
  18. package/src/devcouncil/artifacts/schemas.py +23 -23
  19. package/src/devcouncil/artifacts/serializer.py +21 -21
  20. package/src/devcouncil/artifacts/validators.py +27 -27
  21. package/src/devcouncil/cli/commands/artifacts.py +51 -48
  22. package/src/devcouncil/cli/commands/ast.py +22 -0
  23. package/src/devcouncil/cli/commands/baseline.py +35 -32
  24. package/src/devcouncil/cli/commands/config.py +76 -54
  25. package/src/devcouncil/cli/commands/dashboard.py +26 -0
  26. package/src/devcouncil/cli/commands/doctor.py +86 -42
  27. package/src/devcouncil/cli/commands/go.py +237 -0
  28. package/src/devcouncil/cli/commands/hook.py +96 -29
  29. package/src/devcouncil/cli/commands/init.py +67 -56
  30. package/src/devcouncil/cli/commands/integrate.py +320 -14
  31. package/src/devcouncil/cli/commands/lsp.py +20 -0
  32. package/src/devcouncil/cli/commands/map.py +25 -21
  33. package/src/devcouncil/cli/commands/plan.py +257 -206
  34. package/src/devcouncil/cli/commands/prompt.py +36 -33
  35. package/src/devcouncil/cli/commands/repair.py +72 -69
  36. package/src/devcouncil/cli/commands/report.py +112 -54
  37. package/src/devcouncil/cli/commands/reset_demo_state.py +31 -28
  38. package/src/devcouncil/cli/commands/rollback.py +49 -47
  39. package/src/devcouncil/cli/commands/run.py +252 -207
  40. package/src/devcouncil/cli/commands/setup.py +159 -18
  41. package/src/devcouncil/cli/commands/show.py +76 -57
  42. package/src/devcouncil/cli/commands/status.py +117 -105
  43. package/src/devcouncil/cli/commands/tasks.py +55 -41
  44. package/src/devcouncil/cli/commands/trace.py +2 -1
  45. package/src/devcouncil/cli/commands/verify.py +158 -128
  46. package/src/devcouncil/cli/commands/version.py +20 -20
  47. package/src/devcouncil/cli/commands/watch.py +574 -0
  48. package/src/devcouncil/cli/main.py +42 -24
  49. package/src/devcouncil/council/prompts/arbiter.md +19 -19
  50. package/src/devcouncil/council/prompts/critic_a.md +10 -10
  51. package/src/devcouncil/council/prompts/critic_b.md +10 -10
  52. package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
  53. package/src/devcouncil/council/prompts/planner_a.md +16 -16
  54. package/src/devcouncil/council/prompts/planner_b.md +16 -16
  55. package/src/devcouncil/council/prompts/rebuttal.md +10 -10
  56. package/src/devcouncil/council/prompts/spec_writer.md +12 -12
  57. package/src/devcouncil/domain/assumption.py +17 -17
  58. package/src/devcouncil/domain/critique.py +32 -32
  59. package/src/devcouncil/domain/evidence.py +27 -27
  60. package/src/devcouncil/domain/gap.py +26 -26
  61. package/src/devcouncil/domain/requirement.py +22 -22
  62. package/src/devcouncil/domain/task.py +26 -26
  63. package/src/devcouncil/execution/__init__.py +1 -1
  64. package/src/devcouncil/execution/context_builder.py +54 -54
  65. package/src/devcouncil/execution/executor.py +15 -15
  66. package/src/devcouncil/execution/hook_policy.py +24 -3
  67. package/src/devcouncil/execution/patch.py +28 -28
  68. package/src/devcouncil/execution/permissions.py +44 -44
  69. package/src/devcouncil/execution/prompt_builder.py +23 -23
  70. package/src/devcouncil/execution/task_runner.py +63 -63
  71. package/src/devcouncil/executors/__init__.py +1 -1
  72. package/src/devcouncil/executors/coding_cli.py +112 -0
  73. package/src/devcouncil/executors/mini_swe.py +63 -63
  74. package/src/devcouncil/executors/native/agent.py +81 -81
  75. package/src/devcouncil/executors/openhands.py +56 -56
  76. package/src/devcouncil/gating/__init__.py +1 -1
  77. package/src/devcouncil/gating/checks/clean_git.py +50 -45
  78. package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
  79. package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
  80. package/src/devcouncil/gating/checks/secret_scan_check.py +34 -34
  81. package/src/devcouncil/gating/policy.py +157 -157
  82. package/src/devcouncil/indexing/__init__.py +1 -1
  83. package/src/devcouncil/indexing/ast_matcher.py +168 -0
  84. package/src/devcouncil/indexing/graph_index.py +48 -48
  85. package/src/devcouncil/indexing/lsp.py +120 -0
  86. package/src/devcouncil/indexing/repo_mapper.py +208 -204
  87. package/src/devcouncil/integrations/github.py +35 -35
  88. package/src/devcouncil/integrations/gitnexus.py +27 -27
  89. package/src/devcouncil/integrations/graphify.py +34 -34
  90. package/src/devcouncil/integrations/mcp/server.py +549 -96
  91. package/src/devcouncil/integrations/pr_comments.py +62 -0
  92. package/src/devcouncil/live/__init__.py +2 -0
  93. package/src/devcouncil/live/cards.py +207 -0
  94. package/src/devcouncil/live/models.py +63 -0
  95. package/src/devcouncil/live/repair_prompt.py +83 -0
  96. package/src/devcouncil/live/reviewer.py +70 -0
  97. package/src/devcouncil/live/signals.py +135 -0
  98. package/src/devcouncil/live/summary.py +34 -0
  99. package/src/devcouncil/live/tasks.py +18 -0
  100. package/src/devcouncil/live/transcripts.py +138 -0
  101. package/src/devcouncil/llm/__init__.py +1 -1
  102. package/src/devcouncil/llm/cache.py +38 -38
  103. package/src/devcouncil/llm/provider.py +146 -125
  104. package/src/devcouncil/llm/router.py +111 -111
  105. package/src/devcouncil/planning/__init__.py +1 -1
  106. package/src/devcouncil/planning/arbiter_service.py +57 -57
  107. package/src/devcouncil/planning/critique_service.py +66 -66
  108. package/src/devcouncil/planning/plan_service.py +46 -46
  109. package/src/devcouncil/planning/prompt_enhancer_service.py +86 -0
  110. package/src/devcouncil/planning/repair_service.py +39 -39
  111. package/src/devcouncil/planning/spec_service.py +44 -44
  112. package/src/devcouncil/reporting/github_check.py +32 -32
  113. package/src/devcouncil/reporting/json_report.py +20 -17
  114. package/src/devcouncil/reporting/markdown_report.py +68 -46
  115. package/src/devcouncil/reporting/report_builder.py +14 -14
  116. package/src/devcouncil/storage/db.py +66 -66
  117. package/src/devcouncil/storage/models.py +83 -83
  118. package/src/devcouncil/storage/repositories.py +299 -222
  119. package/src/devcouncil/telemetry/cost.py +34 -34
  120. package/src/devcouncil/telemetry/tracker.py +49 -49
  121. package/src/devcouncil/ui/__init__.py +1 -0
  122. package/src/devcouncil/ui/dashboard.py +122 -0
  123. package/src/devcouncil/utils/__init__.py +1 -1
  124. package/src/devcouncil/utils/redaction.py +141 -141
  125. package/src/devcouncil/verification/__init__.py +1 -1
  126. package/src/devcouncil/verification/implementation_reviewer.py +55 -55
  127. package/src/devcouncil/verification/verifier.py +319 -302
  128. 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
+ )