devcouncil 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +201 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +348 -12
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +32 -5
  8. package/src/devcouncil/assets/__init__.py +1 -0
  9. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  10. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  11. package/src/devcouncil/cli/commands/agents.py +292 -0
  12. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  13. package/src/devcouncil/cli/commands/check.py +220 -0
  14. package/src/devcouncil/cli/commands/config.py +43 -4
  15. package/src/devcouncil/cli/commands/cost.py +57 -0
  16. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  17. package/src/devcouncil/cli/commands/design.py +186 -0
  18. package/src/devcouncil/cli/commands/doctor.py +379 -22
  19. package/src/devcouncil/cli/commands/evidence.py +48 -0
  20. package/src/devcouncil/cli/commands/go.py +532 -33
  21. package/src/devcouncil/cli/commands/handoff.py +69 -0
  22. package/src/devcouncil/cli/commands/hook.py +296 -15
  23. package/src/devcouncil/cli/commands/init.py +161 -20
  24. package/src/devcouncil/cli/commands/integrate.py +1371 -124
  25. package/src/devcouncil/cli/commands/logs.py +106 -0
  26. package/src/devcouncil/cli/commands/map.py +80 -10
  27. package/src/devcouncil/cli/commands/okf.py +245 -0
  28. package/src/devcouncil/cli/commands/plan.py +256 -55
  29. package/src/devcouncil/cli/commands/prompt.py +18 -7
  30. package/src/devcouncil/cli/commands/repair.py +50 -24
  31. package/src/devcouncil/cli/commands/report.py +8 -0
  32. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  33. package/src/devcouncil/cli/commands/rollback.py +27 -28
  34. package/src/devcouncil/cli/commands/run.py +195 -54
  35. package/src/devcouncil/cli/commands/runs.py +223 -0
  36. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  37. package/src/devcouncil/cli/commands/semantic.py +47 -0
  38. package/src/devcouncil/cli/commands/setup.py +145 -6
  39. package/src/devcouncil/cli/commands/shell.py +73 -0
  40. package/src/devcouncil/cli/commands/skills.py +267 -0
  41. package/src/devcouncil/cli/commands/status.py +30 -15
  42. package/src/devcouncil/cli/commands/trace.py +47 -3
  43. package/src/devcouncil/cli/commands/verify.py +144 -3
  44. package/src/devcouncil/cli/commands/watch.py +32 -12
  45. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  46. package/src/devcouncil/cli/main.py +91 -7
  47. package/src/devcouncil/domain/evidence.py +29 -2
  48. package/src/devcouncil/domain/gap.py +27 -1
  49. package/src/devcouncil/domain/task.py +31 -2
  50. package/src/devcouncil/execution/checkpoints.py +256 -0
  51. package/src/devcouncil/execution/context_builder.py +1 -1
  52. package/src/devcouncil/execution/fs_watcher.py +205 -0
  53. package/src/devcouncil/execution/handoff.py +102 -0
  54. package/src/devcouncil/execution/hook_policy.py +162 -74
  55. package/src/devcouncil/execution/patch.py +65 -10
  56. package/src/devcouncil/execution/permissions.py +24 -24
  57. package/src/devcouncil/execution/policy_engine.py +350 -0
  58. package/src/devcouncil/execution/prompt_builder.py +751 -23
  59. package/src/devcouncil/execution/shell_session.py +231 -0
  60. package/src/devcouncil/execution/task_runner.py +24 -9
  61. package/src/devcouncil/executors/agent_registry.py +596 -0
  62. package/src/devcouncil/executors/coding_cli.py +791 -39
  63. package/src/devcouncil/executors/mini_swe.py +6 -0
  64. package/src/devcouncil/executors/native/agent.py +135 -19
  65. package/src/devcouncil/executors/openhands.py +6 -0
  66. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  67. package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
  68. package/src/devcouncil/gating/policy.py +190 -11
  69. package/src/devcouncil/hardware.py +184 -0
  70. package/src/devcouncil/indexing/ast_matcher.py +17 -7
  71. package/src/devcouncil/indexing/lsp.py +45 -4
  72. package/src/devcouncil/indexing/repo_mapper.py +1284 -15
  73. package/src/devcouncil/indexing/semantic_index.py +221 -0
  74. package/src/devcouncil/integrations/actions.py +166 -0
  75. package/src/devcouncil/integrations/check.py +426 -0
  76. package/src/devcouncil/integrations/claude_assets.py +444 -0
  77. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  78. package/src/devcouncil/integrations/github_intent.py +149 -0
  79. package/src/devcouncil/integrations/gitnexus.py +45 -2
  80. package/src/devcouncil/integrations/mcp/server.py +1944 -32
  81. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  82. package/src/devcouncil/integrations/pr_comments.py +9 -0
  83. package/src/devcouncil/knowledge/__init__.py +23 -0
  84. package/src/devcouncil/knowledge/design.py +374 -0
  85. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  86. package/src/devcouncil/knowledge/fetch.py +223 -0
  87. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  88. package/src/devcouncil/knowledge/okf.py +202 -0
  89. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  90. package/src/devcouncil/knowledge/sources.py +239 -0
  91. package/src/devcouncil/live/cards.py +181 -25
  92. package/src/devcouncil/live/repair_prompt.py +29 -6
  93. package/src/devcouncil/live/reviewer.py +72 -13
  94. package/src/devcouncil/live/signals.py +2 -2
  95. package/src/devcouncil/live/summary.py +18 -8
  96. package/src/devcouncil/live/transcripts.py +47 -11
  97. package/src/devcouncil/llm/cache.py +20 -8
  98. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  99. package/src/devcouncil/llm/provider.py +617 -49
  100. package/src/devcouncil/llm/router.py +337 -53
  101. package/src/devcouncil/optimization/__init__.py +1 -0
  102. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  103. package/src/devcouncil/optimization/skillopt.py +673 -0
  104. package/src/devcouncil/planning/arbiter_service.py +10 -2
  105. package/src/devcouncil/planning/correction_manifest.py +346 -0
  106. package/src/devcouncil/planning/critique_service.py +16 -4
  107. package/src/devcouncil/planning/plan_service.py +86 -6
  108. package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
  109. package/src/devcouncil/planning/repair_service.py +8 -2
  110. package/src/devcouncil/planning/spec_service.py +37 -3
  111. package/src/devcouncil/repo/ci_scaffold.py +165 -0
  112. package/src/devcouncil/repo/gitignore.py +123 -0
  113. package/src/devcouncil/repo/sca.py +384 -0
  114. package/src/devcouncil/reporting/json_report.py +22 -1
  115. package/src/devcouncil/reporting/markdown_report.py +29 -1
  116. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  117. package/src/devcouncil/reporting/okf_html.py +323 -0
  118. package/src/devcouncil/reporting/report_builder.py +18 -1
  119. package/src/devcouncil/skills/__init__.py +19 -0
  120. package/src/devcouncil/skills/library/README.md +46 -0
  121. package/src/devcouncil/skills/library/ai-training.md +50 -0
  122. package/src/devcouncil/skills/library/android.md +50 -0
  123. package/src/devcouncil/skills/library/backend.md +52 -0
  124. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  125. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  126. package/src/devcouncil/skills/library/desktop.md +46 -0
  127. package/src/devcouncil/skills/library/devops.md +48 -0
  128. package/src/devcouncil/skills/library/game-dev.md +46 -0
  129. package/src/devcouncil/skills/library/ios.md +48 -0
  130. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  131. package/src/devcouncil/skills/library/security.md +48 -0
  132. package/src/devcouncil/skills/library/systems.md +48 -0
  133. package/src/devcouncil/skills/library/web.md +47 -0
  134. package/src/devcouncil/skills/library/windows.md +47 -0
  135. package/src/devcouncil/skills/registry.py +408 -0
  136. package/src/devcouncil/storage/db.py +140 -3
  137. package/src/devcouncil/storage/models.py +125 -0
  138. package/src/devcouncil/storage/native.py +559 -0
  139. package/src/devcouncil/storage/repositories.py +157 -78
  140. package/src/devcouncil/telemetry/cost.py +123 -17
  141. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  142. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  143. package/src/devcouncil/telemetry/pricing.py +28 -0
  144. package/src/devcouncil/telemetry/stages.py +141 -0
  145. package/src/devcouncil/telemetry/traces.py +62 -7
  146. package/src/devcouncil/telemetry/tracker.py +24 -10
  147. package/src/devcouncil/ui/dashboard.py +393 -28
  148. package/src/devcouncil/utils/redaction.py +9 -3
  149. package/src/devcouncil/utils/subprocess_env.py +69 -0
  150. package/src/devcouncil/verification/acceptance_compiler.py +253 -0
  151. package/src/devcouncil/verification/ad_hoc_check.py +135 -0
  152. package/src/devcouncil/verification/diff_coverage.py +353 -0
  153. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  154. package/src/devcouncil/verification/next_actions.py +189 -0
  155. package/src/devcouncil/verification/sandbox.py +181 -0
  156. package/src/devcouncil/verification/test_resolver.py +91 -0
  157. package/src/devcouncil/verification/verifier.py +1549 -143
  158. package/uv.lock +205 -64
  159. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -0,0 +1,384 @@
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
+ # project_root is immutable per instance, so lockfile existence is stable;
157
+ # memoize per filename to avoid repeated stat calls across auditors.
158
+ self._lockfile_cache: dict[str, bool] = {}
159
+
160
+ # -- detection -----------------------------------------------------------
161
+
162
+ def _check_cached_lockfile(self, name: str) -> bool:
163
+ cached = self._lockfile_cache.get(name)
164
+ if cached is None:
165
+ cached = (self.project_root / name).exists()
166
+ self._lockfile_cache[name] = cached
167
+ return cached
168
+
169
+ def _has_lockfile(self, auditor: _Auditor) -> bool:
170
+ return any(self._check_cached_lockfile(name) for name in auditor.lockfiles)
171
+
172
+ def _is_runnable(self, auditor: _Auditor) -> bool:
173
+ """Relevant to the repo (lockfile present) and runnable (on PATH or injected)."""
174
+ if not self._has_lockfile(auditor):
175
+ return False
176
+ return self._injected or self._which(auditor.executable) is not None
177
+
178
+ def available_auditors(self) -> list[str]:
179
+ """Names of auditors that are both runnable AND relevant to this repo.
180
+
181
+ An auditor counts as runnable when its executable is on PATH (real
182
+ installs) OR when a custom runner is injected (tests/offline).
183
+ """
184
+ seen: set[str] = set()
185
+ ordered: list[str] = []
186
+ for auditor in _AUDITORS:
187
+ if self._is_runnable(auditor) and auditor.name not in seen:
188
+ seen.add(auditor.name)
189
+ ordered.append(auditor.name)
190
+ return ordered
191
+
192
+ # -- scanning ------------------------------------------------------------
193
+
194
+ def scan(self) -> list[DependencyRisk]:
195
+ """Run every available auditor and merge their findings. Never raises."""
196
+ risks: list[DependencyRisk] = []
197
+ seen: set[tuple[str, str, str]] = set()
198
+ for auditor in _AUDITORS:
199
+ if not self._is_runnable(auditor):
200
+ continue
201
+ try:
202
+ result = self._runner(self._argv_for(auditor), self.project_root)
203
+ parsed = self._parse(auditor, result)
204
+ except Exception:
205
+ # Best-effort contract: a broken auditor/parse must not propagate.
206
+ continue
207
+ for risk in parsed:
208
+ key = (risk.package, risk.installed_version, risk.advisory_id)
209
+ if key in seen:
210
+ continue
211
+ seen.add(key)
212
+ risks.append(risk)
213
+ return risks
214
+
215
+ @staticmethod
216
+ def _argv_for(auditor: _Auditor) -> list[str]:
217
+ if auditor.name == "pip-audit":
218
+ return ["pip-audit", "--format", "json"]
219
+ if auditor.name == "npm":
220
+ return ["npm", "audit", "--json"]
221
+ if auditor.name == "osv-scanner":
222
+ return ["osv-scanner", "--format", "json", "."]
223
+ return [auditor.executable]
224
+
225
+ def _parse(self, auditor: _Auditor, result: AuditorResult) -> list[DependencyRisk]:
226
+ if result.returncode < 0 or not result.stdout.strip():
227
+ return []
228
+ try:
229
+ data = json.loads(result.stdout)
230
+ except (json.JSONDecodeError, ValueError):
231
+ return []
232
+ if auditor.name == "pip-audit":
233
+ return _parse_pip_audit(data)
234
+ if auditor.name == "npm":
235
+ return _parse_npm_audit(data)
236
+ if auditor.name == "osv-scanner":
237
+ return _parse_osv_scanner(data)
238
+ return []
239
+
240
+
241
+ # ----------------------------------------------------------------------------
242
+ # Per-auditor parsers. Each is defensive: unknown shapes yield no risks.
243
+ # ----------------------------------------------------------------------------
244
+
245
+
246
+ def _coerce_str(value: object, default: str = "") -> str:
247
+ if value is None:
248
+ return default
249
+ if isinstance(value, str):
250
+ return value
251
+ return str(value)
252
+
253
+
254
+ def _parse_pip_audit(data: object) -> list[DependencyRisk]:
255
+ """pip-audit ``--format json`` -> dependencies[].vulns[]."""
256
+ risks: list[DependencyRisk] = []
257
+ deps: object
258
+ if isinstance(data, dict):
259
+ deps = data.get("dependencies", [])
260
+ elif isinstance(data, list):
261
+ deps = data # older pip-audit emitted a bare list
262
+ else:
263
+ return []
264
+ if not isinstance(deps, list):
265
+ return []
266
+ for dep in deps:
267
+ if not isinstance(dep, dict):
268
+ continue
269
+ name = _coerce_str(dep.get("name"))
270
+ version = _coerce_str(dep.get("version"))
271
+ vulns = dep.get("vulns") or []
272
+ if not isinstance(vulns, list):
273
+ continue
274
+ for vuln in vulns:
275
+ if not isinstance(vuln, dict):
276
+ continue
277
+ risks.append(
278
+ DependencyRisk(
279
+ package=name,
280
+ installed_version=version,
281
+ severity=_coerce_str(vuln.get("severity"), "unknown") or "unknown",
282
+ advisory_id=_coerce_str(vuln.get("id"), "UNKNOWN") or "UNKNOWN",
283
+ summary=_coerce_str(vuln.get("description") or vuln.get("summary")),
284
+ )
285
+ )
286
+ return risks
287
+
288
+
289
+ def _parse_npm_audit(data: object) -> list[DependencyRisk]:
290
+ """npm audit ``--json`` (npm v7+) -> vulnerabilities{ name: {...} }."""
291
+ if not isinstance(data, dict):
292
+ return []
293
+ vulnerabilities = data.get("vulnerabilities")
294
+ if not isinstance(vulnerabilities, dict):
295
+ return []
296
+ risks: list[DependencyRisk] = []
297
+ for name, info in vulnerabilities.items():
298
+ if not isinstance(info, dict):
299
+ continue
300
+ severity = _coerce_str(info.get("severity"), "unknown") or "unknown"
301
+ version = _coerce_str(info.get("range"))
302
+ via = info.get("via") or []
303
+ advisory_id = "UNKNOWN"
304
+ summary = ""
305
+ if isinstance(via, list):
306
+ for entry in via:
307
+ if isinstance(entry, dict):
308
+ source = entry.get("source") or entry.get("url")
309
+ advisory_id = _coerce_str(source, "UNKNOWN") or "UNKNOWN"
310
+ summary = _coerce_str(entry.get("title"))
311
+ break
312
+ risks.append(
313
+ DependencyRisk(
314
+ package=_coerce_str(name),
315
+ installed_version=version,
316
+ severity=severity,
317
+ advisory_id=advisory_id,
318
+ summary=summary,
319
+ )
320
+ )
321
+ return risks
322
+
323
+
324
+ def _parse_osv_scanner(data: object) -> list[DependencyRisk]:
325
+ """osv-scanner ``--format json`` -> results[].packages[].vulnerabilities[]."""
326
+ if not isinstance(data, dict):
327
+ return []
328
+ results = data.get("results")
329
+ if not isinstance(results, list):
330
+ return []
331
+ risks: list[DependencyRisk] = []
332
+ for result in results:
333
+ if not isinstance(result, dict):
334
+ continue
335
+ packages = result.get("packages") or []
336
+ if not isinstance(packages, list):
337
+ continue
338
+ for package in packages:
339
+ if not isinstance(package, dict):
340
+ continue
341
+ pkg_info = package.get("package") or {}
342
+ name = _coerce_str(pkg_info.get("name")) if isinstance(pkg_info, dict) else ""
343
+ version = _coerce_str(pkg_info.get("version")) if isinstance(pkg_info, dict) else ""
344
+ vulns = package.get("vulnerabilities") or []
345
+ if not isinstance(vulns, list):
346
+ continue
347
+ for vuln in vulns:
348
+ if not isinstance(vuln, dict):
349
+ continue
350
+ risks.append(
351
+ DependencyRisk(
352
+ package=name,
353
+ installed_version=version,
354
+ severity=_osv_severity(vuln),
355
+ advisory_id=_coerce_str(vuln.get("id"), "UNKNOWN") or "UNKNOWN",
356
+ summary=_coerce_str(vuln.get("summary") or vuln.get("details")),
357
+ )
358
+ )
359
+ return risks
360
+
361
+
362
+ def _osv_severity(vuln: dict) -> str:
363
+ severity = vuln.get("severity")
364
+ if isinstance(severity, list) and severity:
365
+ first = severity[0]
366
+ if isinstance(first, dict):
367
+ return _coerce_str(first.get("type") or first.get("score"), "unknown") or "unknown"
368
+ if isinstance(severity, str) and severity:
369
+ return severity
370
+ return "unknown"
371
+
372
+
373
+ def scan_dependency_risks(
374
+ project_root: Path,
375
+ *,
376
+ auditor_runner: AuditorRunner | None = None,
377
+ timeout: int = DEFAULT_TIMEOUT,
378
+ ) -> list[dict[str, str]]:
379
+ """Convenience entry point: returns dependency risks as plain dicts. Never raises."""
380
+ try:
381
+ scanner = ScaScanner(project_root, auditor_runner=auditor_runner, timeout=timeout)
382
+ return [risk.as_dict() for risk in scanner.scan()]
383
+ except Exception:
384
+ return []
@@ -9,9 +9,30 @@ class JsonReportGenerator:
9
9
  summary = graph.coverage_summary()
10
10
  live_blockers = len((live_review or {}).get("blocking_cards", []))
11
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
+ # Proof-rigor breakdown: of the criteria that ARE proven, how were they proven?
23
+ # ``compiled``/``vote`` are precise per-criterion checks (trustworthy); ``coarse``
24
+ # means proven only by a passing acceptance-capable command (weak). Surfacing this
25
+ # lets an auditor see that a "passed" verdict rests on rigorous, not coarse, evidence
26
+ # — the difference that matters most when a weak/local reviewer compiled the checks.
27
+ proof_modes: dict[str, int] = {}
28
+ for ev in getattr(graph, "test_evidence", []):
29
+ if getattr(ev, "status", "") == "passed":
30
+ key = getattr(ev, "mode", "") or "unspecified"
31
+ proof_modes[key] = proof_modes.get(key, 0) + 1
12
32
  report = {
13
- "verdict": "blocked" if summary["blocking_gaps"] > 0 or live_blockers > 0 else "passed",
33
+ "verdict": verdict,
14
34
  "coverage_summary": summary,
35
+ "proof_modes": proof_modes,
15
36
  "blocking_gaps": [g.model_dump() for g in graph.blocking_gaps()]
16
37
  }
17
38
  if live_review is not None:
@@ -10,8 +10,17 @@ class MarkdownReportGenerator:
10
10
  summary = graph.coverage_summary()
11
11
  live_blockers = (live_review or {}).get("blocking_cards", [])
12
12
 
13
+ unverified_ac = summary["ac_without_evidence"]
14
+
13
15
  md_output = "# DevCouncil Report\n\n"
14
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.
15
24
  if summary["blocking_gaps"] > 0 or live_blockers:
16
25
  parts = []
17
26
  if summary["blocking_gaps"] > 0:
@@ -19,13 +28,32 @@ class MarkdownReportGenerator:
19
28
  if live_blockers:
20
29
  parts.append(f"{len(live_blockers)} live-review blocker(s)")
21
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
+ )
22
37
  else:
23
38
  md_output += "**Passed**: Ready for release.\n\n"
24
39
 
25
40
  md_output += "## Coverage Summary\n"
26
41
  md_output += f"- **Requirements**: {summary['total_requirements']} ({summary['requirements_without_tasks']} unmapped)\n"
27
42
  md_output += f"- **Tasks**: {summary['total_tasks']} ({summary['tasks_without_requirements']} orphaned)\n"
28
- md_output += f"- **Evidence**: {summary['total_ac'] - summary['ac_without_evidence']}/{summary['total_ac']} AC verified\n\n"
43
+ md_output += f"- **Evidence**: {summary['total_ac'] - summary['ac_without_evidence']}/{summary['total_ac']} AC verified\n"
44
+ # Proof rigor: HOW the verified criteria were proven. Precise per-criterion checks
45
+ # (compiled/vote) are trustworthy; ``coarse`` (a passing acceptance-capable command,
46
+ # not a check tied to the criterion) is weak evidence worth flagging to a reader.
47
+ proof_modes: dict[str, int] = {}
48
+ for ev in getattr(graph, "test_evidence", []):
49
+ if getattr(ev, "status", "") == "passed":
50
+ proof_modes[getattr(ev, "mode", "") or "unspecified"] = (
51
+ proof_modes.get(getattr(ev, "mode", "") or "unspecified", 0) + 1
52
+ )
53
+ if proof_modes:
54
+ rigor = ", ".join(f"{count} {mode}" for mode, count in sorted(proof_modes.items()))
55
+ md_output += f"- **Proof rigor**: {rigor}\n"
56
+ md_output += "\n"
29
57
 
30
58
  md_output += "## Requirements Coverage Table\n"
31
59
  md_output += "| Requirement | Task Mapping | Status |\n"