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
@@ -0,0 +1,374 @@
1
+ """Best-effort software-composition analysis (dependency vulnerability awareness).
2
+
3
+ DevCouncil does not bundle a vulnerability database and must never reach the
4
+ network on its own initiative. This module is a thin, *offline-safe* wrapper
5
+ around whatever auditors the developer already has installed (``pip-audit``,
6
+ ``npm audit``, ``osv-scanner``): it detects the auditor + the project's
7
+ lockfiles, runs the local tool with a bounded timeout and a sanitized subprocess
8
+ environment, and parses the output into a structured list of dependency risks.
9
+
10
+ Design contract:
11
+ - **Never raises.** Every public entry point swallows tool/parse/OS errors and
12
+ degrades to an empty result, so a missing tool or malformed output can never
13
+ break ``dev map`` / prompt building / CI scaffolding.
14
+ - **Opt-in / local-only by default.** Nothing here runs unless a caller asks for
15
+ it; ``ScaScanner.scan`` is the only thing that shells out.
16
+ - **Injectable runner.** ``ScaScanner`` takes an ``auditor_runner`` callable so
17
+ tests (and offline environments) can feed canned auditor output without any
18
+ network access or installed tools.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import shutil
25
+ import subprocess
26
+ from collections.abc import Callable
27
+ from dataclasses import asdict, dataclass
28
+ from pathlib import Path
29
+
30
+ from devcouncil.utils.subprocess_env import clean_subprocess_env
31
+
32
+ # Default per-auditor timeout. Bounded so a slow/hung auditor can't stall the map.
33
+ DEFAULT_TIMEOUT = 60
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class DependencyRisk:
38
+ """A single dependency vulnerability finding (auditor-agnostic shape)."""
39
+
40
+ package: str
41
+ installed_version: str
42
+ severity: str
43
+ advisory_id: str
44
+ summary: str
45
+
46
+ def as_dict(self) -> dict[str, str]:
47
+ return asdict(self)
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class AuditorResult:
52
+ """Raw output of one auditor invocation; ``returncode`` < 0 means it didn't run."""
53
+
54
+ returncode: int
55
+ stdout: str
56
+ stderr: str
57
+
58
+
59
+ # An auditor runner takes the auditor's argv and the project root, and returns the
60
+ # captured result. Injected so tests need no installed tools or network access.
61
+ AuditorRunner = Callable[[list[str], Path], AuditorResult]
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class _Auditor:
66
+ name: str # logical auditor name (pip-audit / npm / osv-scanner)
67
+ executable: str # the executable to look for on PATH
68
+ stack: str # which stack it covers (python / node)
69
+ # Lockfiles whose presence makes this auditor relevant for the repo.
70
+ lockfiles: tuple[str, ...]
71
+
72
+
73
+ # Ordered so the most specific / preferred auditor for each stack comes first.
74
+ _AUDITORS: tuple[_Auditor, ...] = (
75
+ _Auditor(
76
+ name="pip-audit",
77
+ executable="pip-audit",
78
+ stack="python",
79
+ lockfiles=("uv.lock", "requirements.txt", "poetry.lock"),
80
+ ),
81
+ _Auditor(
82
+ name="npm",
83
+ executable="npm",
84
+ stack="node",
85
+ lockfiles=("package-lock.json", "yarn.lock", "pnpm-lock.yaml"),
86
+ ),
87
+ _Auditor(
88
+ name="osv-scanner",
89
+ executable="osv-scanner",
90
+ stack="any",
91
+ lockfiles=(
92
+ "uv.lock",
93
+ "requirements.txt",
94
+ "poetry.lock",
95
+ "package-lock.json",
96
+ "yarn.lock",
97
+ "pnpm-lock.yaml",
98
+ "go.sum",
99
+ "Cargo.lock",
100
+ ),
101
+ ),
102
+ )
103
+
104
+
105
+ def _default_runner(timeout: int) -> AuditorRunner:
106
+ """Real subprocess runner: bounded timeout + sanitized env; never raises."""
107
+
108
+ def run(argv: list[str], project_root: Path) -> AuditorResult:
109
+ try:
110
+ completed = subprocess.run(
111
+ argv,
112
+ cwd=project_root,
113
+ capture_output=True,
114
+ text=True,
115
+ encoding="utf-8",
116
+ errors="replace",
117
+ timeout=timeout,
118
+ env=clean_subprocess_env(),
119
+ )
120
+ except subprocess.TimeoutExpired:
121
+ return AuditorResult(returncode=-1, stdout="", stderr="timed out")
122
+ except (FileNotFoundError, OSError) as exc:
123
+ return AuditorResult(returncode=-1, stdout="", stderr=str(exc))
124
+ return AuditorResult(
125
+ returncode=completed.returncode,
126
+ stdout=completed.stdout or "",
127
+ stderr=completed.stderr or "",
128
+ )
129
+
130
+ return run
131
+
132
+
133
+ class ScaScanner:
134
+ """Detects locally-available auditors + lockfiles and runs them best-effort.
135
+
136
+ Pass ``auditor_runner`` to inject the subprocess behaviour (tests do this to
137
+ return canned auditor output offline). When omitted, a real bounded-timeout,
138
+ clean-env subprocess runner is used.
139
+ """
140
+
141
+ def __init__(
142
+ self,
143
+ project_root: Path,
144
+ *,
145
+ auditor_runner: AuditorRunner | None = None,
146
+ timeout: int = DEFAULT_TIMEOUT,
147
+ which: Callable[[str], str | None] = shutil.which,
148
+ ):
149
+ self.project_root = Path(project_root)
150
+ self.timeout = timeout
151
+ self._which = which
152
+ # When a runner is injected (tests / offline), the runner itself decides
153
+ # whether a tool "exists"; we skip the PATH gate so canned output flows.
154
+ self._injected = auditor_runner is not None
155
+ self._runner: AuditorRunner = auditor_runner or _default_runner(timeout)
156
+
157
+ # -- detection -----------------------------------------------------------
158
+
159
+ def _has_lockfile(self, auditor: _Auditor) -> bool:
160
+ return any((self.project_root / name).exists() for name in auditor.lockfiles)
161
+
162
+ def _is_runnable(self, auditor: _Auditor) -> bool:
163
+ """Relevant to the repo (lockfile present) and runnable (on PATH or injected)."""
164
+ if not self._has_lockfile(auditor):
165
+ return False
166
+ return self._injected or self._which(auditor.executable) is not None
167
+
168
+ def available_auditors(self) -> list[str]:
169
+ """Names of auditors that are both runnable AND relevant to this repo.
170
+
171
+ An auditor counts as runnable when its executable is on PATH (real
172
+ installs) OR when a custom runner is injected (tests/offline).
173
+ """
174
+ seen: set[str] = set()
175
+ ordered: list[str] = []
176
+ for auditor in _AUDITORS:
177
+ if self._is_runnable(auditor) and auditor.name not in seen:
178
+ seen.add(auditor.name)
179
+ ordered.append(auditor.name)
180
+ return ordered
181
+
182
+ # -- scanning ------------------------------------------------------------
183
+
184
+ def scan(self) -> list[DependencyRisk]:
185
+ """Run every available auditor and merge their findings. Never raises."""
186
+ risks: list[DependencyRisk] = []
187
+ seen: set[tuple[str, str, str]] = set()
188
+ for auditor in _AUDITORS:
189
+ if not self._is_runnable(auditor):
190
+ continue
191
+ try:
192
+ result = self._runner(self._argv_for(auditor), self.project_root)
193
+ parsed = self._parse(auditor, result)
194
+ except Exception:
195
+ # Best-effort contract: a broken auditor/parse must not propagate.
196
+ continue
197
+ for risk in parsed:
198
+ key = (risk.package, risk.installed_version, risk.advisory_id)
199
+ if key in seen:
200
+ continue
201
+ seen.add(key)
202
+ risks.append(risk)
203
+ return risks
204
+
205
+ @staticmethod
206
+ def _argv_for(auditor: _Auditor) -> list[str]:
207
+ if auditor.name == "pip-audit":
208
+ return ["pip-audit", "--format", "json"]
209
+ if auditor.name == "npm":
210
+ return ["npm", "audit", "--json"]
211
+ if auditor.name == "osv-scanner":
212
+ return ["osv-scanner", "--format", "json", "."]
213
+ return [auditor.executable]
214
+
215
+ def _parse(self, auditor: _Auditor, result: AuditorResult) -> list[DependencyRisk]:
216
+ if result.returncode < 0 or not result.stdout.strip():
217
+ return []
218
+ try:
219
+ data = json.loads(result.stdout)
220
+ except (json.JSONDecodeError, ValueError):
221
+ return []
222
+ if auditor.name == "pip-audit":
223
+ return _parse_pip_audit(data)
224
+ if auditor.name == "npm":
225
+ return _parse_npm_audit(data)
226
+ if auditor.name == "osv-scanner":
227
+ return _parse_osv_scanner(data)
228
+ return []
229
+
230
+
231
+ # ----------------------------------------------------------------------------
232
+ # Per-auditor parsers. Each is defensive: unknown shapes yield no risks.
233
+ # ----------------------------------------------------------------------------
234
+
235
+
236
+ def _coerce_str(value: object, default: str = "") -> str:
237
+ if value is None:
238
+ return default
239
+ if isinstance(value, str):
240
+ return value
241
+ return str(value)
242
+
243
+
244
+ def _parse_pip_audit(data: object) -> list[DependencyRisk]:
245
+ """pip-audit ``--format json`` -> dependencies[].vulns[]."""
246
+ risks: list[DependencyRisk] = []
247
+ deps: object
248
+ if isinstance(data, dict):
249
+ deps = data.get("dependencies", [])
250
+ elif isinstance(data, list):
251
+ deps = data # older pip-audit emitted a bare list
252
+ else:
253
+ return []
254
+ if not isinstance(deps, list):
255
+ return []
256
+ for dep in deps:
257
+ if not isinstance(dep, dict):
258
+ continue
259
+ name = _coerce_str(dep.get("name"))
260
+ version = _coerce_str(dep.get("version"))
261
+ vulns = dep.get("vulns") or []
262
+ if not isinstance(vulns, list):
263
+ continue
264
+ for vuln in vulns:
265
+ if not isinstance(vuln, dict):
266
+ continue
267
+ risks.append(
268
+ DependencyRisk(
269
+ package=name,
270
+ installed_version=version,
271
+ severity=_coerce_str(vuln.get("severity"), "unknown") or "unknown",
272
+ advisory_id=_coerce_str(vuln.get("id"), "UNKNOWN") or "UNKNOWN",
273
+ summary=_coerce_str(vuln.get("description") or vuln.get("summary")),
274
+ )
275
+ )
276
+ return risks
277
+
278
+
279
+ def _parse_npm_audit(data: object) -> list[DependencyRisk]:
280
+ """npm audit ``--json`` (npm v7+) -> vulnerabilities{ name: {...} }."""
281
+ if not isinstance(data, dict):
282
+ return []
283
+ vulnerabilities = data.get("vulnerabilities")
284
+ if not isinstance(vulnerabilities, dict):
285
+ return []
286
+ risks: list[DependencyRisk] = []
287
+ for name, info in vulnerabilities.items():
288
+ if not isinstance(info, dict):
289
+ continue
290
+ severity = _coerce_str(info.get("severity"), "unknown") or "unknown"
291
+ version = _coerce_str(info.get("range"))
292
+ via = info.get("via") or []
293
+ advisory_id = "UNKNOWN"
294
+ summary = ""
295
+ if isinstance(via, list):
296
+ for entry in via:
297
+ if isinstance(entry, dict):
298
+ source = entry.get("source") or entry.get("url")
299
+ advisory_id = _coerce_str(source, "UNKNOWN") or "UNKNOWN"
300
+ summary = _coerce_str(entry.get("title"))
301
+ break
302
+ risks.append(
303
+ DependencyRisk(
304
+ package=_coerce_str(name),
305
+ installed_version=version,
306
+ severity=severity,
307
+ advisory_id=advisory_id,
308
+ summary=summary,
309
+ )
310
+ )
311
+ return risks
312
+
313
+
314
+ def _parse_osv_scanner(data: object) -> list[DependencyRisk]:
315
+ """osv-scanner ``--format json`` -> results[].packages[].vulnerabilities[]."""
316
+ if not isinstance(data, dict):
317
+ return []
318
+ results = data.get("results")
319
+ if not isinstance(results, list):
320
+ return []
321
+ risks: list[DependencyRisk] = []
322
+ for result in results:
323
+ if not isinstance(result, dict):
324
+ continue
325
+ packages = result.get("packages") or []
326
+ if not isinstance(packages, list):
327
+ continue
328
+ for package in packages:
329
+ if not isinstance(package, dict):
330
+ continue
331
+ pkg_info = package.get("package") or {}
332
+ name = _coerce_str(pkg_info.get("name")) if isinstance(pkg_info, dict) else ""
333
+ version = _coerce_str(pkg_info.get("version")) if isinstance(pkg_info, dict) else ""
334
+ vulns = package.get("vulnerabilities") or []
335
+ if not isinstance(vulns, list):
336
+ continue
337
+ for vuln in vulns:
338
+ if not isinstance(vuln, dict):
339
+ continue
340
+ risks.append(
341
+ DependencyRisk(
342
+ package=name,
343
+ installed_version=version,
344
+ severity=_osv_severity(vuln),
345
+ advisory_id=_coerce_str(vuln.get("id"), "UNKNOWN") or "UNKNOWN",
346
+ summary=_coerce_str(vuln.get("summary") or vuln.get("details")),
347
+ )
348
+ )
349
+ return risks
350
+
351
+
352
+ def _osv_severity(vuln: dict) -> str:
353
+ severity = vuln.get("severity")
354
+ if isinstance(severity, list) and severity:
355
+ first = severity[0]
356
+ if isinstance(first, dict):
357
+ return _coerce_str(first.get("type") or first.get("score"), "unknown") or "unknown"
358
+ if isinstance(severity, str) and severity:
359
+ return severity
360
+ return "unknown"
361
+
362
+
363
+ def scan_dependency_risks(
364
+ project_root: Path,
365
+ *,
366
+ auditor_runner: AuditorRunner | None = None,
367
+ timeout: int = DEFAULT_TIMEOUT,
368
+ ) -> list[dict[str, str]]:
369
+ """Convenience entry point: returns dependency risks as plain dicts. Never raises."""
370
+ try:
371
+ scanner = ScaScanner(project_root, auditor_runner=auditor_runner, timeout=timeout)
372
+ return [risk.as_dict() for risk in scanner.scan()]
373
+ except Exception:
374
+ return []
@@ -1,32 +1,32 @@
1
- from devcouncil.artifacts.graph import ArtifactGraph
2
-
3
- class GitHubCheckGenerator:
4
- """Generates GitHub Checks API payloads."""
5
-
6
- @staticmethod
7
- def generate(graph: ArtifactGraph) -> dict:
8
- summary = graph.coverage_summary()
9
- blocking_gaps = graph.blocking_gaps()
10
-
11
- status = "completed"
12
- conclusion = "failure" if summary["blocking_gaps"] > 0 else "success"
13
-
14
- text = f"**Requirements**: {summary['total_requirements']} | "
15
- text += f"**Tasks**: {summary['total_tasks']} | "
16
- text += f"**Gaps**: {summary['blocking_gaps']} blocking\n\n"
17
-
18
- if blocking_gaps:
19
- text += "### Blocking Gaps\n"
20
- for gap in blocking_gaps:
21
- text += f"- **{gap.id}**: {gap.description}\n"
22
-
23
- return {
24
- "name": "DevCouncil Verification",
25
- "status": status,
26
- "conclusion": conclusion,
27
- "output": {
28
- "title": f"DevCouncil: {conclusion.capitalize()}",
29
- "summary": f"Found {summary['blocking_gaps']} blocking gaps.",
30
- "text": text
31
- }
32
- }
1
+ from devcouncil.artifacts.graph import ArtifactGraph
2
+
3
+ class GitHubCheckGenerator:
4
+ """Generates GitHub Checks API payloads."""
5
+
6
+ @staticmethod
7
+ def generate(graph: ArtifactGraph) -> dict:
8
+ summary = graph.coverage_summary()
9
+ blocking_gaps = graph.blocking_gaps()
10
+
11
+ status = "completed"
12
+ conclusion = "failure" if summary["blocking_gaps"] > 0 else "success"
13
+
14
+ text = f"**Requirements**: {summary['total_requirements']} | "
15
+ text += f"**Tasks**: {summary['total_tasks']} | "
16
+ text += f"**Gaps**: {summary['blocking_gaps']} blocking\n\n"
17
+
18
+ if blocking_gaps:
19
+ text += "### Blocking Gaps\n"
20
+ for gap in blocking_gaps:
21
+ text += f"- **{gap.id}**: {gap.description}\n"
22
+
23
+ return {
24
+ "name": "DevCouncil Verification",
25
+ "status": status,
26
+ "conclusion": conclusion,
27
+ "output": {
28
+ "title": f"DevCouncil: {conclusion.capitalize()}",
29
+ "summary": f"Found {summary['blocking_gaps']} blocking gaps.",
30
+ "text": text
31
+ }
32
+ }
@@ -1,17 +1,30 @@
1
- import json
2
- from devcouncil.artifacts.graph import ArtifactGraph
3
-
4
- class JsonReportGenerator:
5
- """Generates a JSON evidence report."""
6
-
7
- @staticmethod
8
- def generate(graph: ArtifactGraph) -> str:
9
- summary = graph.coverage_summary()
10
-
11
- report = {
12
- "verdict": "blocked" if summary["blocking_gaps"] > 0 else "passed",
13
- "coverage_summary": summary,
14
- "blocking_gaps": [g.model_dump() for g in graph.blocking_gaps()]
15
- }
16
-
17
- return json.dumps(report, indent=2)
1
+ import json
2
+ from devcouncil.artifacts.graph import ArtifactGraph
3
+
4
+ class JsonReportGenerator:
5
+ """Generates a JSON evidence report."""
6
+
7
+ @staticmethod
8
+ def generate(graph: ArtifactGraph, live_review: dict | None = None) -> str:
9
+ summary = graph.coverage_summary()
10
+ live_blockers = len((live_review or {}).get("blocking_cards", []))
11
+
12
+ # Three honest states (see markdown_report for rationale):
13
+ # blocked - positive evidence of a problem.
14
+ # incomplete - nothing failing, but not every AC has passing evidence.
15
+ # passed - no blocking gaps and every AC proven.
16
+ if summary["blocking_gaps"] > 0 or live_blockers > 0:
17
+ verdict = "blocked"
18
+ elif summary["ac_without_evidence"] > 0:
19
+ verdict = "incomplete"
20
+ else:
21
+ verdict = "passed"
22
+ report = {
23
+ "verdict": verdict,
24
+ "coverage_summary": summary,
25
+ "blocking_gaps": [g.model_dump() for g in graph.blocking_gaps()]
26
+ }
27
+ if live_review is not None:
28
+ report["live_review"] = live_review
29
+
30
+ return json.dumps(report, indent=2)
@@ -1,46 +1,83 @@
1
- from devcouncil.artifacts.graph import ArtifactGraph
2
-
3
- class MarkdownReportGenerator:
4
- """Generates a Markdown evidence report."""
5
-
6
- MAX_INLINE_GAPS = 25
7
-
8
- @staticmethod
9
- def generate(graph: ArtifactGraph) -> str:
10
- summary = graph.coverage_summary()
11
-
12
- md_output = "# DevCouncil Report\n\n"
13
- md_output += "## Verdict\n"
14
- if summary["blocking_gaps"] > 0:
15
- md_output += f"**Blocked**: {summary['blocking_gaps']} high-severity gaps remain.\n\n"
16
- else:
17
- md_output += "**Passed**: Ready for release.\n\n"
18
-
19
- md_output += "## Coverage Summary\n"
20
- md_output += f"- **Requirements**: {summary['total_requirements']} ({summary['requirements_without_tasks']} unmapped)\n"
21
- md_output += f"- **Tasks**: {summary['total_tasks']} ({summary['tasks_without_requirements']} orphaned)\n"
22
- md_output += f"- **Evidence**: {summary['total_ac'] - summary['ac_without_evidence']}/{summary['total_ac']} AC verified\n\n"
23
-
24
- md_output += "## Requirements Coverage Table\n"
25
- md_output += "| Requirement | Task Mapping | Status |\n"
26
- md_output += "|---|---|---|\n"
27
-
28
- for req in graph.requirements.values():
29
- linked_tasks = [t for t in graph.tasks.values() if req.id in t.requirement_ids]
30
- task_str = ", ".join([t.id for t in linked_tasks]) if linked_tasks else "*None*"
31
- status_str = "Covered" if linked_tasks else "**Unmapped**"
32
- md_output += f"| {req.id} {req.title} | {task_str} | {status_str} |\n"
33
-
34
- md_output += "\n## Blocking Gaps\n"
35
- blocking_gaps = graph.blocking_gaps()
36
- if not blocking_gaps:
37
- md_output += "None.\n"
38
- else:
39
- for gap in blocking_gaps[:MarkdownReportGenerator.MAX_INLINE_GAPS]:
40
- md_output += f"### {gap.id}: {gap.description}\n"
41
- md_output += f"**Recommended fix**: {gap.recommended_fix}\n\n"
42
- if len(blocking_gaps) > MarkdownReportGenerator.MAX_INLINE_GAPS:
43
- remaining = len(blocking_gaps) - MarkdownReportGenerator.MAX_INLINE_GAPS
44
- md_output += f"_Omitted {remaining} additional blocking gap(s). Use JSON output for the full list._\n"
45
-
46
- return md_output
1
+ from devcouncil.artifacts.graph import ArtifactGraph
2
+
3
+ class MarkdownReportGenerator:
4
+ """Generates a Markdown evidence report."""
5
+
6
+ MAX_INLINE_GAPS = 25
7
+
8
+ @staticmethod
9
+ def generate(graph: ArtifactGraph, live_review: dict | None = None) -> str:
10
+ summary = graph.coverage_summary()
11
+ live_blockers = (live_review or {}).get("blocking_cards", [])
12
+
13
+ unverified_ac = summary["ac_without_evidence"]
14
+
15
+ md_output = "# DevCouncil Report\n\n"
16
+ md_output += "## Verdict\n"
17
+ # Three honest states:
18
+ # Blocked - positive evidence of a problem (blocking gaps / live blockers).
19
+ # Incomplete - nothing is failing, but not every acceptance criterion has
20
+ # passing evidence yet (un-run, or could not be verified). NOT
21
+ # a failure distinguishing this from Blocked is what keeps the
22
+ # "blocked" signal trustworthy (no false negatives on correct work).
23
+ # Passed - no blocking gaps and every acceptance criterion is proven.
24
+ if summary["blocking_gaps"] > 0 or live_blockers:
25
+ parts = []
26
+ if summary["blocking_gaps"] > 0:
27
+ parts.append(f"{summary['blocking_gaps']} high-severity gap(s)")
28
+ if live_blockers:
29
+ parts.append(f"{len(live_blockers)} live-review blocker(s)")
30
+ md_output += f"**Blocked**: {', '.join(parts)} remain.\n\n"
31
+ elif unverified_ac > 0:
32
+ md_output += (
33
+ f"**Incomplete**: nothing is failing, but {unverified_ac} acceptance "
34
+ "criterion(s) lack passing evidence (un-run or unverifiable). Not ready "
35
+ "for release.\n\n"
36
+ )
37
+ else:
38
+ md_output += "**Passed**: Ready for release.\n\n"
39
+
40
+ md_output += "## Coverage Summary\n"
41
+ md_output += f"- **Requirements**: {summary['total_requirements']} ({summary['requirements_without_tasks']} unmapped)\n"
42
+ md_output += f"- **Tasks**: {summary['total_tasks']} ({summary['tasks_without_requirements']} orphaned)\n"
43
+ md_output += f"- **Evidence**: {summary['total_ac'] - summary['ac_without_evidence']}/{summary['total_ac']} AC verified\n\n"
44
+
45
+ md_output += "## Requirements Coverage Table\n"
46
+ md_output += "| Requirement | Task Mapping | Status |\n"
47
+ md_output += "|---|---|---|\n"
48
+
49
+ for req in graph.requirements.values():
50
+ linked_tasks = [t for t in graph.tasks.values() if req.id in t.requirement_ids]
51
+ task_str = ", ".join([t.id for t in linked_tasks]) if linked_tasks else "*None*"
52
+ status_str = "Covered" if linked_tasks else "**Unmapped**"
53
+ md_output += f"| {req.id} {req.title} | {task_str} | {status_str} |\n"
54
+
55
+ md_output += "\n## Blocking Gaps\n"
56
+ blocking_gaps = graph.blocking_gaps()
57
+ if not blocking_gaps:
58
+ md_output += "None.\n"
59
+ else:
60
+ for gap in blocking_gaps[:MarkdownReportGenerator.MAX_INLINE_GAPS]:
61
+ md_output += f"### {gap.id}: {gap.description}\n"
62
+ md_output += f"**Recommended fix**: {gap.recommended_fix}\n\n"
63
+ if len(blocking_gaps) > MarkdownReportGenerator.MAX_INLINE_GAPS:
64
+ remaining = len(blocking_gaps) - MarkdownReportGenerator.MAX_INLINE_GAPS
65
+ md_output += f"_Omitted {remaining} additional blocking gap(s). Use JSON output for the full list._\n"
66
+
67
+ if live_review is not None:
68
+ md_output += "\n## Live Review\n"
69
+ cards = live_review.get("cards", {})
70
+ md_output += f"- **Pending signals**: {live_review.get('pending_signals', 0)}\n"
71
+ md_output += f"- **Open cards**: {cards.get('open', 0)}\n"
72
+ md_output += f"- **Open critical cards**: {cards.get('critical_open', 0)}\n"
73
+ if not live_blockers:
74
+ md_output += "- **Blocking cards in scope**: None.\n"
75
+ else:
76
+ md_output += "\n### Blocking Live-Review Cards\n"
77
+ for card in live_blockers[:MarkdownReportGenerator.MAX_INLINE_GAPS]:
78
+ md_output += f"- **{card['id']}**"
79
+ if card.get("task_id"):
80
+ md_output += f" (`{card['task_id']}`)"
81
+ md_output += f": {card['summary']}\n"
82
+
83
+ return md_output
@@ -1,14 +1,14 @@
1
- from devcouncil.artifacts.graph import ArtifactGraph
2
- from devcouncil.reporting.markdown_report import MarkdownReportGenerator
3
- from devcouncil.reporting.json_report import JsonReportGenerator
4
-
5
- class ReportBuilder:
6
- """Builds reports in various formats from the artifact graph."""
7
-
8
- @staticmethod
9
- def build_markdown(graph: ArtifactGraph) -> str:
10
- return MarkdownReportGenerator.generate(graph)
11
-
12
- @staticmethod
13
- def build_json(graph: ArtifactGraph) -> str:
14
- return JsonReportGenerator.generate(graph)
1
+ from devcouncil.artifacts.graph import ArtifactGraph
2
+ from devcouncil.reporting.markdown_report import MarkdownReportGenerator
3
+ from devcouncil.reporting.json_report import JsonReportGenerator
4
+
5
+ class ReportBuilder:
6
+ """Builds reports in various formats from the artifact graph."""
7
+
8
+ @staticmethod
9
+ def build_markdown(graph: ArtifactGraph, live_review: dict | None = None) -> str:
10
+ return MarkdownReportGenerator.generate(graph, live_review=live_review)
11
+
12
+ @staticmethod
13
+ def build_json(graph: ArtifactGraph, live_review: dict | None = None) -> str:
14
+ return JsonReportGenerator.generate(graph, live_review=live_review)
@@ -0,0 +1,19 @@
1
+ """DevCouncil skills library."""
2
+
3
+ from devcouncil.skills.registry import (
4
+ Skill,
5
+ get_skill,
6
+ load_skills,
7
+ render_preamble,
8
+ scaffold_skills,
9
+ select_skills,
10
+ )
11
+
12
+ __all__ = [
13
+ "Skill",
14
+ "get_skill",
15
+ "load_skills",
16
+ "render_preamble",
17
+ "scaffold_skills",
18
+ "select_skills",
19
+ ]