devcouncil 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +197 -494
  3. package/package.json +9 -2
  4. package/pyproject.toml +62 -27
  5. package/src/devcouncil/__main__.py +4 -4
  6. package/src/devcouncil/app/__init__.py +28 -28
  7. package/src/devcouncil/app/config.py +297 -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 +163 -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/assets/__init__.py +1 -0
  22. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  23. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  24. package/src/devcouncil/cli/commands/agents.py +292 -0
  25. package/src/devcouncil/cli/commands/artifacts.py +54 -48
  26. package/src/devcouncil/cli/commands/ast.py +22 -0
  27. package/src/devcouncil/cli/commands/baseline.py +35 -32
  28. package/src/devcouncil/cli/commands/check.py +209 -0
  29. package/src/devcouncil/cli/commands/config.py +115 -54
  30. package/src/devcouncil/cli/commands/cost.py +57 -0
  31. package/src/devcouncil/cli/commands/dashboard.py +31 -0
  32. package/src/devcouncil/cli/commands/doctor.py +291 -47
  33. package/src/devcouncil/cli/commands/evidence.py +48 -0
  34. package/src/devcouncil/cli/commands/go.py +656 -0
  35. package/src/devcouncil/cli/commands/handoff.py +69 -0
  36. package/src/devcouncil/cli/commands/hook.py +209 -33
  37. package/src/devcouncil/cli/commands/init.py +204 -57
  38. package/src/devcouncil/cli/commands/integrate.py +1171 -76
  39. package/src/devcouncil/cli/commands/lsp.py +20 -0
  40. package/src/devcouncil/cli/commands/map.py +96 -22
  41. package/src/devcouncil/cli/commands/plan.py +422 -210
  42. package/src/devcouncil/cli/commands/prompt.py +48 -34
  43. package/src/devcouncil/cli/commands/repair.py +89 -69
  44. package/src/devcouncil/cli/commands/report.py +120 -54
  45. package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
  46. package/src/devcouncil/cli/commands/rollback.py +55 -54
  47. package/src/devcouncil/cli/commands/run.py +285 -220
  48. package/src/devcouncil/cli/commands/runs.py +223 -0
  49. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  50. package/src/devcouncil/cli/commands/semantic.py +47 -0
  51. package/src/devcouncil/cli/commands/setup.py +300 -20
  52. package/src/devcouncil/cli/commands/shell.py +73 -0
  53. package/src/devcouncil/cli/commands/show.py +76 -57
  54. package/src/devcouncil/cli/commands/skills.py +88 -0
  55. package/src/devcouncil/cli/commands/status.py +141 -105
  56. package/src/devcouncil/cli/commands/tasks.py +55 -41
  57. package/src/devcouncil/cli/commands/trace.py +49 -4
  58. package/src/devcouncil/cli/commands/verify.py +293 -128
  59. package/src/devcouncil/cli/commands/version.py +20 -20
  60. package/src/devcouncil/cli/commands/watch.py +574 -0
  61. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  62. package/src/devcouncil/cli/main.py +92 -25
  63. package/src/devcouncil/council/prompts/arbiter.md +19 -19
  64. package/src/devcouncil/council/prompts/critic_a.md +10 -10
  65. package/src/devcouncil/council/prompts/critic_b.md +10 -10
  66. package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
  67. package/src/devcouncil/council/prompts/planner_a.md +16 -16
  68. package/src/devcouncil/council/prompts/planner_b.md +16 -16
  69. package/src/devcouncil/council/prompts/rebuttal.md +10 -10
  70. package/src/devcouncil/council/prompts/spec_writer.md +12 -12
  71. package/src/devcouncil/domain/assumption.py +17 -17
  72. package/src/devcouncil/domain/critique.py +32 -32
  73. package/src/devcouncil/domain/evidence.py +47 -27
  74. package/src/devcouncil/domain/gap.py +52 -26
  75. package/src/devcouncil/domain/requirement.py +22 -22
  76. package/src/devcouncil/domain/task.py +55 -26
  77. package/src/devcouncil/execution/__init__.py +1 -1
  78. package/src/devcouncil/execution/checkpoints.py +246 -0
  79. package/src/devcouncil/execution/context_builder.py +54 -54
  80. package/src/devcouncil/execution/executor.py +15 -15
  81. package/src/devcouncil/execution/fs_watcher.py +180 -0
  82. package/src/devcouncil/execution/handoff.py +102 -0
  83. package/src/devcouncil/execution/hook_policy.py +186 -77
  84. package/src/devcouncil/execution/patch.py +77 -28
  85. package/src/devcouncil/execution/permissions.py +52 -59
  86. package/src/devcouncil/execution/policy_engine.py +343 -0
  87. package/src/devcouncil/execution/prompt_builder.py +650 -38
  88. package/src/devcouncil/execution/shell_session.py +225 -0
  89. package/src/devcouncil/execution/task_runner.py +68 -64
  90. package/src/devcouncil/executors/__init__.py +1 -1
  91. package/src/devcouncil/executors/agent_registry.py +575 -0
  92. package/src/devcouncil/executors/coding_cli.py +736 -0
  93. package/src/devcouncil/executors/mini_swe.py +63 -63
  94. package/src/devcouncil/executors/native/agent.py +186 -85
  95. package/src/devcouncil/executors/openhands.py +56 -56
  96. package/src/devcouncil/gating/__init__.py +1 -1
  97. package/src/devcouncil/gating/checks/clean_git.py +52 -45
  98. package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
  99. package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
  100. package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
  101. package/src/devcouncil/gating/policy.py +315 -167
  102. package/src/devcouncil/hardware.py +184 -0
  103. package/src/devcouncil/indexing/__init__.py +1 -1
  104. package/src/devcouncil/indexing/ast_matcher.py +168 -0
  105. package/src/devcouncil/indexing/graph_index.py +48 -48
  106. package/src/devcouncil/indexing/lsp.py +161 -0
  107. package/src/devcouncil/indexing/repo_mapper.py +1455 -204
  108. package/src/devcouncil/indexing/semantic_index.py +205 -0
  109. package/src/devcouncil/integrations/actions.py +146 -0
  110. package/src/devcouncil/integrations/check.py +423 -0
  111. package/src/devcouncil/integrations/github.py +35 -35
  112. package/src/devcouncil/integrations/github_intent.py +142 -0
  113. package/src/devcouncil/integrations/gitnexus.py +62 -27
  114. package/src/devcouncil/integrations/graphify.py +34 -34
  115. package/src/devcouncil/integrations/mcp/server.py +2072 -96
  116. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  117. package/src/devcouncil/integrations/pr_comments.py +62 -0
  118. package/src/devcouncil/live/__init__.py +2 -0
  119. package/src/devcouncil/live/cards.py +349 -0
  120. package/src/devcouncil/live/models.py +63 -0
  121. package/src/devcouncil/live/repair_prompt.py +83 -0
  122. package/src/devcouncil/live/reviewer.py +70 -0
  123. package/src/devcouncil/live/signals.py +135 -0
  124. package/src/devcouncil/live/summary.py +34 -0
  125. package/src/devcouncil/live/tasks.py +18 -0
  126. package/src/devcouncil/live/transcripts.py +141 -0
  127. package/src/devcouncil/llm/__init__.py +1 -1
  128. package/src/devcouncil/llm/cache.py +42 -38
  129. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  130. package/src/devcouncil/llm/provider.py +627 -125
  131. package/src/devcouncil/llm/router.py +303 -118
  132. package/src/devcouncil/optimization/__init__.py +1 -0
  133. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  134. package/src/devcouncil/planning/__init__.py +1 -1
  135. package/src/devcouncil/planning/arbiter_service.py +57 -57
  136. package/src/devcouncil/planning/correction_manifest.py +303 -0
  137. package/src/devcouncil/planning/critique_service.py +71 -66
  138. package/src/devcouncil/planning/plan_service.py +60 -46
  139. package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
  140. package/src/devcouncil/planning/repair_service.py +39 -39
  141. package/src/devcouncil/planning/spec_service.py +70 -44
  142. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  143. package/src/devcouncil/repo/gitignore.py +123 -0
  144. package/src/devcouncil/repo/sca.py +374 -0
  145. package/src/devcouncil/reporting/github_check.py +32 -32
  146. package/src/devcouncil/reporting/json_report.py +30 -17
  147. package/src/devcouncil/reporting/markdown_report.py +83 -46
  148. package/src/devcouncil/reporting/report_builder.py +14 -14
  149. package/src/devcouncil/skills/__init__.py +19 -0
  150. package/src/devcouncil/skills/library/README.md +46 -0
  151. package/src/devcouncil/skills/library/ai-training.md +50 -0
  152. package/src/devcouncil/skills/library/android.md +50 -0
  153. package/src/devcouncil/skills/library/backend.md +52 -0
  154. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  155. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  156. package/src/devcouncil/skills/library/desktop.md +46 -0
  157. package/src/devcouncil/skills/library/devops.md +48 -0
  158. package/src/devcouncil/skills/library/game-dev.md +46 -0
  159. package/src/devcouncil/skills/library/ios.md +48 -0
  160. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  161. package/src/devcouncil/skills/library/security.md +48 -0
  162. package/src/devcouncil/skills/library/systems.md +48 -0
  163. package/src/devcouncil/skills/library/web.md +47 -0
  164. package/src/devcouncil/skills/library/windows.md +47 -0
  165. package/src/devcouncil/skills/registry.py +330 -0
  166. package/src/devcouncil/storage/db.py +147 -66
  167. package/src/devcouncil/storage/models.py +204 -83
  168. package/src/devcouncil/storage/native.py +557 -0
  169. package/src/devcouncil/storage/repositories.py +388 -249
  170. package/src/devcouncil/telemetry/cost.py +140 -34
  171. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  172. package/src/devcouncil/telemetry/pricing.py +28 -0
  173. package/src/devcouncil/telemetry/traces.py +62 -7
  174. package/src/devcouncil/telemetry/tracker.py +52 -49
  175. package/src/devcouncil/ui/__init__.py +1 -0
  176. package/src/devcouncil/ui/dashboard.py +423 -0
  177. package/src/devcouncil/utils/__init__.py +1 -1
  178. package/src/devcouncil/utils/redaction.py +147 -141
  179. package/src/devcouncil/utils/subprocess_env.py +69 -0
  180. package/src/devcouncil/verification/__init__.py +1 -1
  181. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  182. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  183. package/src/devcouncil/verification/diff_coverage.py +353 -0
  184. package/src/devcouncil/verification/implementation_reviewer.py +55 -55
  185. package/src/devcouncil/verification/next_actions.py +189 -0
  186. package/src/devcouncil/verification/sandbox.py +178 -0
  187. package/src/devcouncil/verification/test_resolver.py +91 -0
  188. package/src/devcouncil/verification/verifier.py +1342 -307
  189. package/uv.lock +205 -64
  190. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,141 +1,147 @@
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 Any, 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
+ # 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"),
24
+ "slack_token": re.compile(r"(?i)\b(xox[baprs]-[0-9]{12}-[0-9]{12}-[0-9]{12}-[a-z0-9]{32})\b"),
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"),
26
+ "generic_api_key": re.compile(r"(?i)(api[_-]?key|secret|token|password)[\"'\s]*[:=][\"'\s]*([a-zA-Z0-9_\-\.]{16,})"),
27
+ "private_key": re.compile(r"(?s)-----BEGIN [A-Z]+ PRIVATE KEY-----.*?-----END [A-Z]+ PRIVATE KEY-----"),
28
+ "bearer": re.compile(r"(?i)\b(Bearer\s+)([a-zA-Z0-9_\-\.]{16,})\b"),
29
+ "database_url": re.compile(r"(?i)((?:postgresql|mysql|mongodb|redis)://[^:]+:)([^@]+)(@.+)"),
30
+ }
31
+
32
+ # For backward compatibility
33
+ def redact_string(text: str) -> str:
34
+ """Redact known sensitive patterns from a string (legacy API)."""
35
+ return redact_text(text)
36
+
37
+
38
+ def redact_text(text: str, extra_patterns: Optional[List[str]] = None) -> str:
39
+ """Redact known sensitive patterns from a string.
40
+
41
+ Args:
42
+ text: The input text to redact.
43
+ extra_patterns: Optional list of regex patterns to additionally redact,
44
+ labeled as [REDACTED:custom_N].
45
+
46
+ Returns:
47
+ Text with all sensitive patterns replaced with [REDACTED:type] labels.
48
+ """
49
+ if not isinstance(text, str):
50
+ return text
51
+
52
+ redacted_text = text
53
+
54
+ for key_type, pattern in SECRET_PATTERNS.items():
55
+ if key_type == "generic_api_key":
56
+ # For the generic pattern, replace the value part (group 2)
57
+ def _make_generic_replacer(kt: str):
58
+ def replacer(match):
59
+ prefix = match.group(1)
60
+ separator = match.group(0)[len(match.group(1)):-len(match.group(2))]
61
+ return f"{prefix}{separator}[REDACTED:{kt}]"
62
+ return replacer
63
+ redacted_text = pattern.sub(_make_generic_replacer(key_type), redacted_text)
64
+ elif key_type == "bearer":
65
+ # Preserve "Bearer " prefix, redact the token
66
+ def _bearer_replacer(match):
67
+ return f"{match.group(1)}[REDACTED:bearer]"
68
+ redacted_text = pattern.sub(_bearer_replacer, redacted_text)
69
+ elif key_type == "database_url":
70
+ # Preserve protocol and host, redact password
71
+ def _db_url_replacer(match):
72
+ return f"{match.group(1)}[REDACTED:database_url]{match.group(3)}"
73
+ redacted_text = pattern.sub(_db_url_replacer, redacted_text)
74
+ else:
75
+ redacted_text = pattern.sub(f"[REDACTED:{key_type}]", redacted_text)
76
+
77
+ # Apply custom extra patterns
78
+ if extra_patterns:
79
+ for i, pat_str in enumerate(extra_patterns):
80
+ try:
81
+ pat = re.compile(pat_str)
82
+ redacted_text = pat.sub(f"[REDACTED:custom_{i}]", redacted_text)
83
+ except re.error:
84
+ pass # Skip invalid patterns
85
+
86
+ return redacted_text
87
+
88
+
89
+ def redact_env_vars(text: str) -> str:
90
+ """Redact values in environment variable assignments.
91
+
92
+ Handles patterns like:
93
+ export KEY=value
94
+ KEY='value'
95
+ KEY="value"
96
+ """
97
+ if not isinstance(text, str):
98
+ return text
99
+
100
+ # Match: optional export, VAR_NAME = value (with optional quotes)
101
+ env_pattern = re.compile(
102
+ r"(?m)^(\s*(?:export\s+)?)" # optional export
103
+ r"([A-Z_][A-Z0-9_]*)" # variable name
104
+ r"(\s*=\s*)" # equals sign
105
+ r"(?:'([^']*)'|\"([^\"]*)\"|(\S+))" # value (quoted or unquoted)
106
+ )
107
+
108
+ sensitive_keys = {
109
+ "api_key", "secret", "token", "password", "passwd", "credential",
110
+ "private_key", "access_key", "secret_key", "auth",
111
+ }
112
+
113
+ def _env_replacer(match):
114
+ prefix = match.group(1)
115
+ var_name = match.group(2)
116
+ eq_sign = match.group(3)
117
+
118
+ # Check if the variable name contains a sensitive keyword
119
+ var_lower = var_name.lower()
120
+ is_sensitive = any(kw in var_lower for kw in sensitive_keys)
121
+
122
+ if is_sensitive:
123
+ return f"{prefix}{var_name}{eq_sign}[REDACTED]"
124
+
125
+ return match.group(0)
126
+
127
+ return env_pattern.sub(_env_replacer, text)
128
+
129
+
130
+ def redact_dict(data: dict[str, Any]) -> dict[str, Any]:
131
+ """Recursively redact sensitive patterns from a dictionary (e.g. JSON response)."""
132
+ result: dict[str, Any] = {}
133
+ for k, v in data.items():
134
+ if isinstance(v, str):
135
+ result[k] = redact_text(v)
136
+ elif isinstance(v, dict):
137
+ result[k] = redact_dict(v)
138
+ elif isinstance(v, list):
139
+ result[k] = [
140
+ redact_dict(item) if isinstance(item, dict)
141
+ else redact_text(item) if isinstance(item, str)
142
+ else item
143
+ for item in v
144
+ ]
145
+ else:
146
+ result[k] = v
147
+ return result
@@ -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
@@ -1 +1 @@
1
-
1
+
@@ -0,0 +1,125 @@
1
+ """Compile natural-language acceptance criteria into self-contained executable
2
+ checks that DevCouncil owns and runs.
3
+
4
+ This is the difference between trusting the planner/agent's word and gathering
5
+ real evidence: instead of running planner-authored ``expected_tests`` (which the
6
+ benchmark showed often reference tools or test files that do not exist), DevCouncil
7
+ derives one runnable check per acceptance criterion directly from the criterion
8
+ text and the code under review, then maps each check 1:1 to its criterion.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Dict, List
15
+
16
+ from pydantic import BaseModel
17
+
18
+ from devcouncil.domain.requirement import Requirement
19
+ from devcouncil.domain.task import Task
20
+ from devcouncil.llm.router import ModelRouter
21
+
22
+
23
+ class CompiledCheck(BaseModel):
24
+ acceptance_criterion_id: str
25
+ command: str # a single shell command that exits 0 iff the criterion holds
26
+
27
+
28
+ class CompiledChecks(BaseModel):
29
+ checks: List[CompiledCheck]
30
+
31
+
32
+ class AcceptanceTestCompiler:
33
+ def __init__(self, router: ModelRouter, role: str = "implementation_reviewer"):
34
+ self.router = router
35
+ self.role = role
36
+
37
+ async def compile(
38
+ self,
39
+ task: Task,
40
+ requirements: List[Requirement],
41
+ code_context: str,
42
+ ) -> Dict[str, List[str]]:
43
+ """Return {acceptance_criterion_id: [self-contained check command(s)]}.
44
+
45
+ Best-effort: returns {} if the model cannot produce usable checks, so the
46
+ caller can fall back to the task's declared expected_tests.
47
+ """
48
+ ac_by_id = {ac.id: ac for req in requirements for ac in req.acceptance_criteria}
49
+ target = [ac_by_id[i] for i in task.acceptance_criterion_ids if i in ac_by_id]
50
+ if not target:
51
+ return {}
52
+
53
+ acs_json = json.dumps(
54
+ [{"id": ac.id, "description": ac.description, "method": ac.verification_method} for ac in target],
55
+ indent=2,
56
+ )
57
+ prompt = f"""
58
+ You are DevCouncil's acceptance-test compiler. Convert each acceptance criterion
59
+ below into exactly ONE shell command that EXITS 0 if and only if the BEHAVIOR
60
+ described by the criterion holds for the code shown.
61
+
62
+ Acceptance criteria:
63
+ {acs_json}
64
+
65
+ Code under review (the diff / current files):
66
+ {code_context}
67
+
68
+ What a check must verify — BEHAVIOR ONLY:
69
+ - A check exists to confirm the code DOES what the criterion describes when its
70
+ public API is exercised: import the module/symbol and call its function(s), or
71
+ run its CLI/entrypoint, and assert on the observable result (return value,
72
+ raised exception, stdout, exit code).
73
+ - DevCouncil already enforces scope, file ownership, and append-only/orphan-diff
74
+ constraints with its OWN gates. Acceptance checks must therefore NEVER re-assert
75
+ repository or filesystem STATE — that is not their job and it produces false
76
+ BLOCKED results because `dev` itself adds workspace files (AGENTS.md, CLAUDE.md,
77
+ .gitignore, .devcouncil/config.yaml, etc.).
78
+
79
+ Rules — the commands are executed verbatim by the verifier:
80
+ - One command per acceptance_criterion_id (reference the id exactly).
81
+ - Each command MUST be a single, SELF-CONTAINED, immediately-runnable command:
82
+ import the real module/symbol from the code and assert the behavior directly.
83
+ Do NOT depend on test files, fixtures, or any external setup.
84
+ - Prefer: python -c "import <module>; assert <expr>". For an expected exception,
85
+ use a one-line guard, e.g.
86
+ python -c "import m; \\ntry: m.f([])\\nexcept ValueError: pass\\nelse: raise SystemExit(1)"
87
+ (real newlines are fine; never put try/if/for after a ';').
88
+ - Use the actual module name implied by the code (e.g. file 'stats.py' -> import stats).
89
+
90
+ HARD PROHIBITIONS — a command that does any of these is INVALID; omit the
91
+ criterion instead of emitting such a command:
92
+ - NEVER assert exact git or filesystem state. Forbidden: `git status`,
93
+ `git status --porcelain`, `git diff`, `git diff --name-only`, `git show`,
94
+ `git ls-files`, `ls`/`find`/`os.listdir` equality checks, asserting a precise
95
+ set or count of changed/created files, or asserting a file does/does not exist
96
+ as the criterion's pass condition.
97
+ - NEVER do append-only or byte-level file/content comparisons (e.g.
98
+ `git show HEAD:file`, diffing bytes, asserting only N bytes/lines were added).
99
+ Assert the resulting BEHAVIOR instead, not how the file changed.
100
+ - NEVER invoke linters, type checkers, formatters, or build/package tools that
101
+ may be absent: flake8, mypy, ruff, pylint, black, isort, eslint, tsc, prettier,
102
+ npm, npx, yarn, pnpm, cargo, go vet, etc. Only use such a tool if the code
103
+ context clearly shows it is configured for this repo (e.g. a matching config
104
+ section/file is present in the context) AND it is essential to the criterion.
105
+ - If a criterion cannot be checked by a behavioral command (e.g. pure 'manual'
106
+ review, or it only describes repo/tooling state), OMIT it rather than inventing
107
+ a state-based or bogus command.
108
+ """
109
+ try:
110
+ result = await self.router.complete_structured(
111
+ role=self.role,
112
+ messages=[{"role": "user", "content": prompt}],
113
+ schema=CompiledChecks,
114
+ fallback=CompiledChecks(checks=[]),
115
+ )
116
+ except Exception:
117
+ return {}
118
+
119
+ out: Dict[str, List[str]] = {}
120
+ valid_ids = {ac.id for ac in target}
121
+ for check in result.checks:
122
+ cmd = (check.command or "").strip()
123
+ if check.acceptance_criterion_id in valid_ids and cmd:
124
+ out.setdefault(check.acceptance_criterion_id, []).append(cmd)
125
+ return out
@@ -0,0 +1,129 @@
1
+ """Verify an ad-hoc working-tree diff against an inline requirement — no planning, no keys.
2
+
3
+ This powers ``dev check``'s evidence-gate mode (the lite entry point): wrap whatever is
4
+ in the working tree as a synthetic Requirement→Task, run the *same* deterministic
5
+ :class:`~devcouncil.verification.verifier.Verifier` the full workflow uses — orphan-diff,
6
+ secret scan, acceptance evidence, and the diff↔coverage gate — and return the verdict
7
+ plus the typed next-actions contract. ``router=None`` keeps it provider-key-free so a
8
+ newcomer can taste the evidence gate before committing to the full council flow.
9
+
10
+ The logic lives here (not in the CLI command) so it is unit-testable without Typer and
11
+ resilient to churn in the command module.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import List, Optional
20
+
21
+ from devcouncil.domain.evidence import DiffCoverageEvidence
22
+ from devcouncil.domain.gap import Gap
23
+ from devcouncil.domain.requirement import AcceptanceCriterion, Requirement
24
+ from devcouncil.domain.task import PlannedFile, Task
25
+ from devcouncil.llm.router import ModelRouter
26
+ from devcouncil.verification.next_actions import NextAction, build_next_actions
27
+ from devcouncil.verification.verifier import Verifier
28
+
29
+ _REQ_ID = "REQ-CHECK"
30
+ _AC_ID = "AC-CHECK"
31
+ _TASK_ID = "CHECK"
32
+
33
+ _DEFAULT_CRITERION = "The working-tree changes are correct and exercised by tests."
34
+
35
+
36
+ @dataclass
37
+ class AdHocCheckResult:
38
+ requirement: str
39
+ changed_files: List[str] = field(default_factory=list)
40
+ gaps: List[Gap] = field(default_factory=list)
41
+ next_actions: List[NextAction] = field(default_factory=list)
42
+ diff_coverage: Optional[DiffCoverageEvidence] = None
43
+ passed: bool = True
44
+ reason: str = ""
45
+
46
+ def to_dict(self) -> dict:
47
+ return {
48
+ "ok": True,
49
+ "verified": self.passed,
50
+ "requirement": self.requirement,
51
+ "changed_files": self.changed_files,
52
+ "reason": self.reason,
53
+ "gap_count": len(self.gaps),
54
+ "blocking_gap_count": len([g for g in self.gaps if g.blocking]),
55
+ "gaps": [g.model_dump() for g in self.gaps],
56
+ "next_actions": [a.model_dump() for a in self.next_actions],
57
+ "diff_coverage": self.diff_coverage.model_dump() if self.diff_coverage else None,
58
+ }
59
+
60
+
61
+ def run_working_tree_check(
62
+ project_root: Path,
63
+ requirement: Optional[str] = None,
64
+ *,
65
+ test_commands: Optional[List[str]] = None,
66
+ enforce_coverage: bool = False,
67
+ min_ratio: float = 0.0,
68
+ router: Optional[ModelRouter] = None,
69
+ verifier: Optional[Verifier] = None,
70
+ ) -> AdHocCheckResult:
71
+ """Verify the current working-tree diff against a one-line requirement.
72
+
73
+ Builds a synthetic task whose planned files are exactly the changed files (so the
74
+ result is about evidence, not scope noise) and whose expected tests are
75
+ ``test_commands``. Diff coverage is always measured; pass ``enforce_coverage`` (or a
76
+ positive ``min_ratio``) to make an unexercised diff blocking.
77
+ """
78
+ verifier = verifier or Verifier(project_root, router=router)
79
+
80
+ diff = verifier.get_diff()
81
+ changed_files = verifier.get_changed_files()
82
+ if not diff or not changed_files:
83
+ return AdHocCheckResult(requirement="", passed=True, reason="no_changes")
84
+
85
+ criterion = requirement or _DEFAULT_CRITERION
86
+ req = Requirement(
87
+ id=_REQ_ID,
88
+ title=(requirement or "Working-tree change")[:80],
89
+ description=criterion,
90
+ priority="high",
91
+ source="user",
92
+ acceptance_criteria=[
93
+ AcceptanceCriterion(id=_AC_ID, description=criterion, verification_method="unit_test"),
94
+ ],
95
+ )
96
+ untracked = set(verifier._get_untracked_files())
97
+ task = Task(
98
+ id=_TASK_ID,
99
+ title="Ad-hoc working-tree check",
100
+ description=criterion,
101
+ requirement_ids=[_REQ_ID],
102
+ acceptance_criterion_ids=[_AC_ID],
103
+ planned_files=[
104
+ PlannedFile(
105
+ path=path,
106
+ reason="working-tree change",
107
+ allowed_change="create" if path in untracked else "modify",
108
+ )
109
+ for path in changed_files
110
+ ],
111
+ expected_tests=list(test_commands or []),
112
+ )
113
+
114
+ # Always measure diff coverage in lite mode; block on it only when asked. A positive
115
+ # --min-coverage implies enforcement so the flag is never silently inert.
116
+ enforce = enforce_coverage or min_ratio > 0
117
+ verifier._diff_coverage_override = (True, enforce, float(min_ratio))
118
+
119
+ gaps, evidence = asyncio.run(verifier.verify_task(task, [req]))
120
+ coverage = next((ev for ev in evidence if isinstance(ev, DiffCoverageEvidence)), None)
121
+ blocking = [g for g in gaps if g.blocking]
122
+ return AdHocCheckResult(
123
+ requirement=criterion,
124
+ changed_files=changed_files,
125
+ gaps=gaps,
126
+ next_actions=build_next_actions(gaps),
127
+ diff_coverage=coverage,
128
+ passed=not blocking,
129
+ )