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.
- package/LICENSE +201 -201
- package/README.md +197 -494
- package/package.json +9 -2
- package/pyproject.toml +62 -27
- package/src/devcouncil/__main__.py +4 -4
- package/src/devcouncil/app/__init__.py +28 -28
- package/src/devcouncil/app/config.py +297 -108
- package/src/devcouncil/app/errors.py +23 -23
- package/src/devcouncil/app/events.py +44 -44
- package/src/devcouncil/app/orchestrator.py +67 -67
- package/src/devcouncil/app/project_status.py +29 -0
- package/src/devcouncil/app/run_context.py +39 -39
- package/src/devcouncil/app/state_machine.py +108 -108
- package/src/devcouncil/artifacts/__init__.py +1 -1
- package/src/devcouncil/artifacts/coverage.py +96 -96
- package/src/devcouncil/artifacts/graph.py +163 -143
- package/src/devcouncil/artifacts/migrations.py +20 -20
- package/src/devcouncil/artifacts/schemas.py +23 -23
- package/src/devcouncil/artifacts/serializer.py +21 -21
- package/src/devcouncil/artifacts/validators.py +27 -27
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +54 -48
- package/src/devcouncil/cli/commands/ast.py +22 -0
- package/src/devcouncil/cli/commands/baseline.py +35 -32
- package/src/devcouncil/cli/commands/check.py +209 -0
- package/src/devcouncil/cli/commands/config.py +115 -54
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +31 -0
- package/src/devcouncil/cli/commands/doctor.py +291 -47
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +656 -0
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +209 -33
- package/src/devcouncil/cli/commands/init.py +204 -57
- package/src/devcouncil/cli/commands/integrate.py +1171 -76
- package/src/devcouncil/cli/commands/lsp.py +20 -0
- package/src/devcouncil/cli/commands/map.py +96 -22
- package/src/devcouncil/cli/commands/plan.py +422 -210
- package/src/devcouncil/cli/commands/prompt.py +48 -34
- package/src/devcouncil/cli/commands/repair.py +89 -69
- package/src/devcouncil/cli/commands/report.py +120 -54
- package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
- package/src/devcouncil/cli/commands/rollback.py +55 -54
- package/src/devcouncil/cli/commands/run.py +285 -220
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +300 -20
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/show.py +76 -57
- package/src/devcouncil/cli/commands/skills.py +88 -0
- package/src/devcouncil/cli/commands/status.py +141 -105
- package/src/devcouncil/cli/commands/tasks.py +55 -41
- package/src/devcouncil/cli/commands/trace.py +49 -4
- package/src/devcouncil/cli/commands/verify.py +293 -128
- package/src/devcouncil/cli/commands/version.py +20 -20
- package/src/devcouncil/cli/commands/watch.py +574 -0
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +92 -25
- package/src/devcouncil/council/prompts/arbiter.md +19 -19
- package/src/devcouncil/council/prompts/critic_a.md +10 -10
- package/src/devcouncil/council/prompts/critic_b.md +10 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
- package/src/devcouncil/council/prompts/planner_a.md +16 -16
- package/src/devcouncil/council/prompts/planner_b.md +16 -16
- package/src/devcouncil/council/prompts/rebuttal.md +10 -10
- package/src/devcouncil/council/prompts/spec_writer.md +12 -12
- package/src/devcouncil/domain/assumption.py +17 -17
- package/src/devcouncil/domain/critique.py +32 -32
- package/src/devcouncil/domain/evidence.py +47 -27
- package/src/devcouncil/domain/gap.py +52 -26
- package/src/devcouncil/domain/requirement.py +22 -22
- package/src/devcouncil/domain/task.py +55 -26
- package/src/devcouncil/execution/__init__.py +1 -1
- package/src/devcouncil/execution/checkpoints.py +246 -0
- package/src/devcouncil/execution/context_builder.py +54 -54
- package/src/devcouncil/execution/executor.py +15 -15
- package/src/devcouncil/execution/fs_watcher.py +180 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +186 -77
- package/src/devcouncil/execution/patch.py +77 -28
- package/src/devcouncil/execution/permissions.py +52 -59
- package/src/devcouncil/execution/policy_engine.py +343 -0
- package/src/devcouncil/execution/prompt_builder.py +650 -38
- package/src/devcouncil/execution/shell_session.py +225 -0
- package/src/devcouncil/execution/task_runner.py +68 -64
- package/src/devcouncil/executors/__init__.py +1 -1
- package/src/devcouncil/executors/agent_registry.py +575 -0
- package/src/devcouncil/executors/coding_cli.py +736 -0
- package/src/devcouncil/executors/mini_swe.py +63 -63
- package/src/devcouncil/executors/native/agent.py +186 -85
- package/src/devcouncil/executors/openhands.py +56 -56
- package/src/devcouncil/gating/__init__.py +1 -1
- package/src/devcouncil/gating/checks/clean_git.py +52 -45
- package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
- package/src/devcouncil/gating/policy.py +315 -167
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/__init__.py +1 -1
- package/src/devcouncil/indexing/ast_matcher.py +168 -0
- package/src/devcouncil/indexing/graph_index.py +48 -48
- package/src/devcouncil/indexing/lsp.py +161 -0
- package/src/devcouncil/indexing/repo_mapper.py +1455 -204
- package/src/devcouncil/indexing/semantic_index.py +205 -0
- package/src/devcouncil/integrations/actions.py +146 -0
- package/src/devcouncil/integrations/check.py +423 -0
- package/src/devcouncil/integrations/github.py +35 -35
- package/src/devcouncil/integrations/github_intent.py +142 -0
- package/src/devcouncil/integrations/gitnexus.py +62 -27
- package/src/devcouncil/integrations/graphify.py +34 -34
- package/src/devcouncil/integrations/mcp/server.py +2072 -96
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +62 -0
- package/src/devcouncil/live/__init__.py +2 -0
- package/src/devcouncil/live/cards.py +349 -0
- package/src/devcouncil/live/models.py +63 -0
- package/src/devcouncil/live/repair_prompt.py +83 -0
- package/src/devcouncil/live/reviewer.py +70 -0
- package/src/devcouncil/live/signals.py +135 -0
- package/src/devcouncil/live/summary.py +34 -0
- package/src/devcouncil/live/tasks.py +18 -0
- package/src/devcouncil/live/transcripts.py +141 -0
- package/src/devcouncil/llm/__init__.py +1 -1
- package/src/devcouncil/llm/cache.py +42 -38
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +627 -125
- package/src/devcouncil/llm/router.py +303 -118
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/planning/__init__.py +1 -1
- package/src/devcouncil/planning/arbiter_service.py +57 -57
- package/src/devcouncil/planning/correction_manifest.py +303 -0
- package/src/devcouncil/planning/critique_service.py +71 -66
- package/src/devcouncil/planning/plan_service.py +60 -46
- package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
- package/src/devcouncil/planning/repair_service.py +39 -39
- package/src/devcouncil/planning/spec_service.py +70 -44
- package/src/devcouncil/repo/ci_scaffold.py +157 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +374 -0
- package/src/devcouncil/reporting/github_check.py +32 -32
- package/src/devcouncil/reporting/json_report.py +30 -17
- package/src/devcouncil/reporting/markdown_report.py +83 -46
- package/src/devcouncil/reporting/report_builder.py +14 -14
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +330 -0
- package/src/devcouncil/storage/db.py +147 -66
- package/src/devcouncil/storage/models.py +204 -83
- package/src/devcouncil/storage/native.py +557 -0
- package/src/devcouncil/storage/repositories.py +388 -249
- package/src/devcouncil/telemetry/cost.py +140 -34
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +52 -49
- package/src/devcouncil/ui/__init__.py +1 -0
- package/src/devcouncil/ui/dashboard.py +423 -0
- package/src/devcouncil/utils/__init__.py +1 -1
- package/src/devcouncil/utils/redaction.py +147 -141
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/__init__.py +1 -1
- package/src/devcouncil/verification/acceptance_compiler.py +125 -0
- package/src/devcouncil/verification/ad_hoc_check.py +129 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +55 -55
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +178 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1342 -307
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Diff↔coverage intersection — proof that the *changed lines* were exercised.
|
|
2
|
+
|
|
3
|
+
DevCouncil's headline promise is that a passing test must prove the **new logic
|
|
4
|
+
was exercised**, not merely that *some* suite exited 0. An agent can make a green
|
|
5
|
+
suite pass while the changed code is never imported, never called, or shadowed by
|
|
6
|
+
an unrelated passing test. This module closes that gap: it runs a task's test
|
|
7
|
+
command under coverage instrumentation, then intersects the lines the tests
|
|
8
|
+
actually executed with the lines the diff changed.
|
|
9
|
+
|
|
10
|
+
Two failure shapes are caught:
|
|
11
|
+
|
|
12
|
+
1. **Touched-but-not-exercised** — the changed file *is* in the coverage report,
|
|
13
|
+
but the changed executable lines were never executed (e.g. a passing test that
|
|
14
|
+
exercises a different branch).
|
|
15
|
+
2. **Never-imported** — the changed source file is *absent* from the coverage
|
|
16
|
+
report entirely, meaning the tests never loaded it.
|
|
17
|
+
|
|
18
|
+
False-positive discipline (mirrors :class:`~devcouncil.verification.verifier.Verifier`):
|
|
19
|
+
this analysis only ever produces a *signal* when it has reliable data — a
|
|
20
|
+
parseable diff with real hunks, a detected coverage tool, and changed *executable*
|
|
21
|
+
lines to measure. When any of those is missing it returns
|
|
22
|
+
``DiffCoverageResult(measured=False, ...)`` and the verifier degrades to its prior
|
|
23
|
+
behaviour rather than blocking correct work. Coverage is read from the **target
|
|
24
|
+
repository's** own tooling (coverage.py for Python); DevCouncil never forces its
|
|
25
|
+
own coverage dependency into the project under verification.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Dict, List, Optional, Set
|
|
34
|
+
|
|
35
|
+
# A unified-diff hunk header: ``@@ -<old> +<newStart>[,<newLen>] @@``. We only need
|
|
36
|
+
# the new-file starting line to walk added/context lines into new-file numbers.
|
|
37
|
+
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
38
|
+
|
|
39
|
+
# Path fragments that mark a file as test code. Test files are excluded from the
|
|
40
|
+
# "must be exercised" denominator: a test exercising itself is not the new logic
|
|
41
|
+
# whose behaviour an acceptance criterion is about.
|
|
42
|
+
_TEST_MARKERS = (
|
|
43
|
+
"/tests/",
|
|
44
|
+
"tests/",
|
|
45
|
+
"/test_",
|
|
46
|
+
"test_",
|
|
47
|
+
"_test.",
|
|
48
|
+
".test.",
|
|
49
|
+
".spec.",
|
|
50
|
+
"_spec.",
|
|
51
|
+
"/spec/",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _strip_diff_prefix(path: str) -> str:
|
|
56
|
+
"""Strip a leading ``a/`` or ``b/`` and normalise to forward slashes."""
|
|
57
|
+
path = path.strip().strip('"')
|
|
58
|
+
if path.startswith(("a/", "b/")):
|
|
59
|
+
path = path[2:]
|
|
60
|
+
return path.replace("\\", "/")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_test_path(path: str) -> bool:
|
|
64
|
+
lowered = path.replace("\\", "/").lower()
|
|
65
|
+
return any(marker in lowered for marker in _TEST_MARKERS)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_code_like(line: str) -> bool:
|
|
69
|
+
"""Conservative heuristic: a non-blank line that is not a pure comment.
|
|
70
|
+
|
|
71
|
+
Used only for changed files that are *absent* from the coverage report, where
|
|
72
|
+
no authoritative executable-line set exists. Imports, ``def``/``class``,
|
|
73
|
+
decorators and statements all count; blank lines and ``#`` comments do not.
|
|
74
|
+
Kept deliberately conservative so it never inflates the denominator.
|
|
75
|
+
"""
|
|
76
|
+
stripped = line.strip()
|
|
77
|
+
return bool(stripped) and not stripped.startswith("#")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_changed_lines(diff: str) -> Dict[str, Dict[int, str]]:
|
|
81
|
+
"""Parse a unified diff into ``{file_path: {new_line_number: added_text}}``.
|
|
82
|
+
|
|
83
|
+
Only *added* lines (``+`` in the new file) are recorded, keyed by their line
|
|
84
|
+
number in the post-change file so they can be intersected with coverage data.
|
|
85
|
+
Deletions and context lines advance the counter but are not themselves
|
|
86
|
+
"changed lines" we require coverage for. Paths are normalised relative to the
|
|
87
|
+
repo (``a/``/``b/`` prefixes stripped, forward slashes).
|
|
88
|
+
"""
|
|
89
|
+
changed: Dict[str, Dict[int, str]] = {}
|
|
90
|
+
current_file: Optional[str] = None
|
|
91
|
+
new_line = 0
|
|
92
|
+
in_hunk = False
|
|
93
|
+
|
|
94
|
+
for raw in diff.splitlines():
|
|
95
|
+
if raw.startswith("diff --git") or raw.startswith("--- "):
|
|
96
|
+
in_hunk = False
|
|
97
|
+
continue
|
|
98
|
+
if raw.startswith("+++ "):
|
|
99
|
+
target = raw[4:].strip()
|
|
100
|
+
if target == "/dev/null":
|
|
101
|
+
current_file = None
|
|
102
|
+
else:
|
|
103
|
+
current_file = _strip_diff_prefix(target)
|
|
104
|
+
changed.setdefault(current_file, {})
|
|
105
|
+
in_hunk = False
|
|
106
|
+
continue
|
|
107
|
+
if raw.startswith("@@"):
|
|
108
|
+
match = _HUNK_RE.match(raw)
|
|
109
|
+
if match:
|
|
110
|
+
new_line = int(match.group(1))
|
|
111
|
+
in_hunk = True
|
|
112
|
+
else:
|
|
113
|
+
# A header we can't number (e.g. a combined-merge ``@@@``). Stay out
|
|
114
|
+
# of hunk mode rather than mis-attribute added lines to line 0.
|
|
115
|
+
in_hunk = False
|
|
116
|
+
continue
|
|
117
|
+
if not in_hunk or current_file is None:
|
|
118
|
+
continue
|
|
119
|
+
if raw.startswith("\\"): # ""
|
|
120
|
+
continue
|
|
121
|
+
if raw.startswith("+"):
|
|
122
|
+
changed[current_file][new_line] = raw[1:]
|
|
123
|
+
new_line += 1
|
|
124
|
+
elif raw.startswith("-"):
|
|
125
|
+
continue # old-file only; does not advance the new-file counter
|
|
126
|
+
else:
|
|
127
|
+
new_line += 1 # context line
|
|
128
|
+
|
|
129
|
+
return {path: lines for path, lines in changed.items() if lines}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_coverage_json(data: dict, root: Path) -> "CoverageData":
|
|
133
|
+
"""Extract executed and executable lines per file from ``coverage json`` output.
|
|
134
|
+
|
|
135
|
+
``coverage.py`` reports ``executed_lines`` and ``missing_lines`` per file; their
|
|
136
|
+
union is the set of statements coverage knows are executable. Paths are
|
|
137
|
+
normalised to repo-relative POSIX so they intersect with diff paths.
|
|
138
|
+
"""
|
|
139
|
+
executed: Dict[str, Set[int]] = {}
|
|
140
|
+
executable: Dict[str, Set[int]] = {}
|
|
141
|
+
files = data.get("files", {}) if isinstance(data, dict) else {}
|
|
142
|
+
for raw_path, payload in files.items():
|
|
143
|
+
if not isinstance(payload, dict):
|
|
144
|
+
continue
|
|
145
|
+
rel = _relativize(raw_path, root)
|
|
146
|
+
if rel is None:
|
|
147
|
+
continue
|
|
148
|
+
run = {int(n) for n in payload.get("executed_lines", []) or []}
|
|
149
|
+
miss = {int(n) for n in payload.get("missing_lines", []) or []}
|
|
150
|
+
executed[rel] = run
|
|
151
|
+
executable[rel] = run | miss
|
|
152
|
+
return CoverageData(executed=executed, executable=executable)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _relativize(raw_path: str, root: Path) -> Optional[str]:
|
|
156
|
+
candidate = Path(raw_path)
|
|
157
|
+
try:
|
|
158
|
+
if candidate.is_absolute():
|
|
159
|
+
rel = candidate.resolve().relative_to(root.resolve())
|
|
160
|
+
else:
|
|
161
|
+
rel = candidate
|
|
162
|
+
except ValueError:
|
|
163
|
+
# Outside the repo (site-packages, stdlib) — not a changed-file candidate.
|
|
164
|
+
return None
|
|
165
|
+
return rel.as_posix()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
169
|
+
class CoverageData:
|
|
170
|
+
executed: Dict[str, Set[int]] = field(default_factory=dict)
|
|
171
|
+
executable: Dict[str, Set[int]] = field(default_factory=dict)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class DiffCoverageResult:
|
|
176
|
+
"""Outcome of intersecting changed lines with executed lines.
|
|
177
|
+
|
|
178
|
+
``measured`` is True only when there was a meaningful signal to compute — at
|
|
179
|
+
least one changed *executable* line. When False, callers must NOT treat the
|
|
180
|
+
result as evidence of a problem (false-positive discipline).
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
measured: bool
|
|
184
|
+
tool: str = ""
|
|
185
|
+
reason: str = ""
|
|
186
|
+
changed_executable_lines: int = 0
|
|
187
|
+
covered_changed_lines: int = 0
|
|
188
|
+
uncovered_by_file: Dict[str, List[int]] = field(default_factory=dict)
|
|
189
|
+
absent_files: List[str] = field(default_factory=list)
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def ratio(self) -> float:
|
|
193
|
+
if self.changed_executable_lines == 0:
|
|
194
|
+
return 1.0
|
|
195
|
+
return self.covered_changed_lines / self.changed_executable_lines
|
|
196
|
+
|
|
197
|
+
def summary(self) -> str:
|
|
198
|
+
if not self.measured:
|
|
199
|
+
return f"diff coverage not measured ({self.reason})" if self.reason else "diff coverage not measured"
|
|
200
|
+
pct = round(self.ratio * 100)
|
|
201
|
+
base = (
|
|
202
|
+
f"{self.covered_changed_lines}/{self.changed_executable_lines} changed lines exercised "
|
|
203
|
+
f"({pct}%) via {self.tool or 'coverage'}"
|
|
204
|
+
)
|
|
205
|
+
if self.absent_files:
|
|
206
|
+
base += f"; not imported by tests: {', '.join(self.absent_files)}"
|
|
207
|
+
return base
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def intersect(
|
|
211
|
+
changed: Dict[str, Dict[int, str]],
|
|
212
|
+
coverage: CoverageData,
|
|
213
|
+
*,
|
|
214
|
+
tool: str = "coverage.py",
|
|
215
|
+
) -> DiffCoverageResult:
|
|
216
|
+
"""Intersect changed lines with executed lines to measure diff coverage.
|
|
217
|
+
|
|
218
|
+
``changed`` should already be filtered to measurable source files (e.g. ``.py``
|
|
219
|
+
non-test files). For each file present in the coverage report we use coverage's
|
|
220
|
+
authoritative executable-line set; for changed source files *absent* from the
|
|
221
|
+
report (never imported) we fall back to the conservative ``is_code_like``
|
|
222
|
+
heuristic and count those added lines as executable-but-uncovered.
|
|
223
|
+
"""
|
|
224
|
+
total_executable = 0
|
|
225
|
+
total_covered = 0
|
|
226
|
+
uncovered_by_file: Dict[str, List[int]] = {}
|
|
227
|
+
absent_files: List[str] = []
|
|
228
|
+
|
|
229
|
+
for path, line_map in changed.items():
|
|
230
|
+
changed_nums = set(line_map.keys())
|
|
231
|
+
if path in coverage.executable:
|
|
232
|
+
file_executable = changed_nums & coverage.executable[path]
|
|
233
|
+
file_covered = changed_nums & coverage.executed.get(path, set())
|
|
234
|
+
total_executable += len(file_executable)
|
|
235
|
+
total_covered += len(file_covered)
|
|
236
|
+
missing = sorted(file_executable - file_covered)
|
|
237
|
+
if missing:
|
|
238
|
+
uncovered_by_file[path] = missing
|
|
239
|
+
else:
|
|
240
|
+
# Absent from the coverage report -> the tests never loaded this file.
|
|
241
|
+
code_like = sorted(num for num, text in line_map.items() if is_code_like(text))
|
|
242
|
+
if code_like:
|
|
243
|
+
total_executable += len(code_like)
|
|
244
|
+
uncovered_by_file[path] = code_like
|
|
245
|
+
absent_files.append(path)
|
|
246
|
+
|
|
247
|
+
if total_executable == 0:
|
|
248
|
+
return DiffCoverageResult(
|
|
249
|
+
measured=False,
|
|
250
|
+
tool=tool,
|
|
251
|
+
reason="no changed executable lines to measure",
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return DiffCoverageResult(
|
|
255
|
+
measured=True,
|
|
256
|
+
tool=tool,
|
|
257
|
+
changed_executable_lines=total_executable,
|
|
258
|
+
covered_changed_lines=total_covered,
|
|
259
|
+
uncovered_by_file=uncovered_by_file,
|
|
260
|
+
absent_files=absent_files,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def measurable_python_changes(changed: Dict[str, Dict[int, str]]) -> Dict[str, Dict[int, str]]:
|
|
265
|
+
"""Filter parsed diff lines to Python source files (non-test) coverage.py can measure."""
|
|
266
|
+
return {
|
|
267
|
+
path: lines
|
|
268
|
+
for path, lines in changed.items()
|
|
269
|
+
if path.endswith(".py") and not is_test_path(path)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def coverage_run_argv(
|
|
274
|
+
command_argv: List[str],
|
|
275
|
+
python: str,
|
|
276
|
+
*,
|
|
277
|
+
append: bool,
|
|
278
|
+
data_file: str,
|
|
279
|
+
source: str = ".",
|
|
280
|
+
) -> Optional[List[str]]:
|
|
281
|
+
"""Transform a test command's argv into a ``coverage run`` invocation.
|
|
282
|
+
|
|
283
|
+
Supports the common Python entry points. Returns ``None`` for commands that
|
|
284
|
+
cannot be instrumented (the caller then leaves diff coverage unmeasured rather
|
|
285
|
+
than guessing). ``python -c "<code>"`` is handled separately by the caller
|
|
286
|
+
because it must materialise a temp script first.
|
|
287
|
+
"""
|
|
288
|
+
if not command_argv:
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
prefix = [python, "-m", "coverage", "run", f"--source={source}", f"--data-file={data_file}"]
|
|
292
|
+
if append:
|
|
293
|
+
prefix.append("-a")
|
|
294
|
+
|
|
295
|
+
head = Path(command_argv[0]).name.lower()
|
|
296
|
+
if head.endswith(".exe"): # Windows: python.exe / pytest.exe
|
|
297
|
+
head = head[:-4]
|
|
298
|
+
rest = command_argv[1:]
|
|
299
|
+
|
|
300
|
+
# python -m pytest / python -m unittest -> reuse the same module entry point.
|
|
301
|
+
if head in {"python", "python3", "py"} and len(rest) >= 2 and rest[0] == "-m":
|
|
302
|
+
module = rest[1]
|
|
303
|
+
if module in {"pytest", "unittest"}:
|
|
304
|
+
return [*prefix, "-m", module, *rest[2:]]
|
|
305
|
+
return None
|
|
306
|
+
# bare pytest -> run via the module entry point under coverage.
|
|
307
|
+
if head in {"pytest", "py.test"}:
|
|
308
|
+
return [*prefix, "-m", "pytest", *rest]
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def inline_python_code(command_argv: List[str]) -> Optional[str]:
|
|
313
|
+
"""Return the ``CODE`` of a ``python -c "CODE"`` command, else None.
|
|
314
|
+
|
|
315
|
+
DevCouncil's acceptance compiler and many planner ``expected_tests`` are inline
|
|
316
|
+
assertions (``python -c "import m; assert m.f()==1"``). These are exactly the
|
|
317
|
+
checks whose diff coverage matters, so they are instrumented via a temp script
|
|
318
|
+
(see :func:`coverage_run_script_argv`) rather than left unmeasured.
|
|
319
|
+
"""
|
|
320
|
+
if len(command_argv) < 3:
|
|
321
|
+
return None
|
|
322
|
+
head = Path(command_argv[0]).name.lower()
|
|
323
|
+
if head.endswith(".exe"):
|
|
324
|
+
head = head[:-4]
|
|
325
|
+
if head in {"python", "python3", "py"} and command_argv[1] == "-c":
|
|
326
|
+
return command_argv[2]
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def inline_script_content(code: str, root: Path) -> str:
|
|
331
|
+
"""Wrap inline ``-c`` code as a script that imports like ``python -c`` would.
|
|
332
|
+
|
|
333
|
+
``python -c`` puts the current working directory on ``sys.path``; a plain script
|
|
334
|
+
instead puts the script's own directory there. Since the temp script lives under
|
|
335
|
+
``.devcouncil/tmp`` we re-insert the repo root so ``import <module>`` resolves the
|
|
336
|
+
same way the original inline check did.
|
|
337
|
+
"""
|
|
338
|
+
return f"import sys\nsys.path.insert(0, {str(Path(root))!r})\n{code}\n"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def coverage_run_script_argv(
|
|
342
|
+
script_path: str,
|
|
343
|
+
python: str,
|
|
344
|
+
*,
|
|
345
|
+
append: bool,
|
|
346
|
+
data_file: str,
|
|
347
|
+
source: str = ".",
|
|
348
|
+
) -> List[str]:
|
|
349
|
+
"""A ``coverage run`` invocation for a materialised script (used for inline checks)."""
|
|
350
|
+
prefix = [python, "-m", "coverage", "run", f"--source={source}", f"--data-file={data_file}"]
|
|
351
|
+
if append:
|
|
352
|
+
prefix.append("-a")
|
|
353
|
+
return [*prefix, script_path]
|
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from typing import List
|
|
3
|
-
from pydantic import BaseModel
|
|
4
|
-
from devcouncil.domain.task import Task
|
|
5
|
-
from devcouncil.domain.requirement import Requirement
|
|
6
|
-
from devcouncil.domain.gap import Gap
|
|
7
|
-
from devcouncil.llm.router import ModelRouter
|
|
8
|
-
from devcouncil.utils.redaction import redact_string
|
|
9
|
-
|
|
10
|
-
class ReviewOutput(BaseModel):
|
|
11
|
-
is_satisfactory: bool
|
|
12
|
-
findings: List[Gap]
|
|
13
|
-
|
|
14
|
-
class ImplementationReviewer:
|
|
15
|
-
"""Uses LLM to review code changes against task requirements."""
|
|
16
|
-
|
|
17
|
-
def __init__(self, router: ModelRouter):
|
|
18
|
-
self.router = router
|
|
19
|
-
|
|
20
|
-
async def review_changes(
|
|
21
|
-
self,
|
|
22
|
-
task: Task,
|
|
23
|
-
requirements: List[Requirement],
|
|
24
|
-
diff: str
|
|
25
|
-
) -> ReviewOutput:
|
|
26
|
-
linked_reqs = [r for r in requirements if r.id in task.requirement_ids]
|
|
27
|
-
if not linked_reqs:
|
|
28
|
-
linked_reqs = requirements
|
|
29
|
-
requirements_json = json.dumps([r.model_dump() for r in linked_reqs], indent=2)
|
|
30
|
-
redacted_diff = redact_string(diff)
|
|
31
|
-
prompt = f"""
|
|
32
|
-
You are an expert software reviewer. Review the following code changes against the task requirements.
|
|
33
|
-
Task: {task.title}
|
|
34
|
-
Description: {task.description}
|
|
35
|
-
|
|
36
|
-
Requirements:
|
|
37
|
-
{requirements_json}
|
|
38
|
-
|
|
39
|
-
Code Diff:
|
|
40
|
-
{redacted_diff}
|
|
41
|
-
|
|
42
|
-
Your task is to identify if the implementation is complete, correct, and follows best practices.
|
|
43
|
-
- Identify missing edge cases.
|
|
44
|
-
- Identify architectural drift.
|
|
45
|
-
- Identify security risks not caught by static scans.
|
|
46
|
-
|
|
47
|
-
Return a JSON object with 'is_satisfactory' and a list of 'findings' (as Gap objects).
|
|
48
|
-
"""
|
|
49
|
-
messages = [{"role": "user", "content": prompt}]
|
|
50
|
-
|
|
51
|
-
return await self.router.complete_structured(
|
|
52
|
-
role="implementation_reviewer",
|
|
53
|
-
messages=messages,
|
|
54
|
-
schema=ReviewOutput
|
|
55
|
-
)
|
|
1
|
+
import json
|
|
2
|
+
from typing import List
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from devcouncil.domain.task import Task
|
|
5
|
+
from devcouncil.domain.requirement import Requirement
|
|
6
|
+
from devcouncil.domain.gap import Gap
|
|
7
|
+
from devcouncil.llm.router import ModelRouter
|
|
8
|
+
from devcouncil.utils.redaction import redact_string
|
|
9
|
+
|
|
10
|
+
class ReviewOutput(BaseModel):
|
|
11
|
+
is_satisfactory: bool
|
|
12
|
+
findings: List[Gap]
|
|
13
|
+
|
|
14
|
+
class ImplementationReviewer:
|
|
15
|
+
"""Uses LLM to review code changes against task requirements."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, router: ModelRouter):
|
|
18
|
+
self.router = router
|
|
19
|
+
|
|
20
|
+
async def review_changes(
|
|
21
|
+
self,
|
|
22
|
+
task: Task,
|
|
23
|
+
requirements: List[Requirement],
|
|
24
|
+
diff: str
|
|
25
|
+
) -> ReviewOutput:
|
|
26
|
+
linked_reqs = [r for r in requirements if r.id in task.requirement_ids]
|
|
27
|
+
if not linked_reqs:
|
|
28
|
+
linked_reqs = requirements
|
|
29
|
+
requirements_json = json.dumps([r.model_dump() for r in linked_reqs], indent=2)
|
|
30
|
+
redacted_diff = redact_string(diff)
|
|
31
|
+
prompt = f"""
|
|
32
|
+
You are an expert software reviewer. Review the following code changes against the task requirements.
|
|
33
|
+
Task: {task.title}
|
|
34
|
+
Description: {task.description}
|
|
35
|
+
|
|
36
|
+
Requirements:
|
|
37
|
+
{requirements_json}
|
|
38
|
+
|
|
39
|
+
Code Diff:
|
|
40
|
+
{redacted_diff}
|
|
41
|
+
|
|
42
|
+
Your task is to identify if the implementation is complete, correct, and follows best practices.
|
|
43
|
+
- Identify missing edge cases.
|
|
44
|
+
- Identify architectural drift.
|
|
45
|
+
- Identify security risks not caught by static scans.
|
|
46
|
+
|
|
47
|
+
Return a JSON object with 'is_satisfactory' and a list of 'findings' (as Gap objects).
|
|
48
|
+
"""
|
|
49
|
+
messages = [{"role": "user", "content": prompt}]
|
|
50
|
+
|
|
51
|
+
return await self.router.complete_structured(
|
|
52
|
+
role="implementation_reviewer",
|
|
53
|
+
messages=messages,
|
|
54
|
+
schema=ReviewOutput
|
|
55
|
+
)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Typed, machine-actionable next actions derived from verification gaps.
|
|
2
|
+
|
|
3
|
+
A human-readable "here is what's wrong" report is fine for a person, but an agent
|
|
4
|
+
in a closed loop needs a structured contract it can route on without parsing
|
|
5
|
+
prose. ``build_next_actions`` turns the gaps from a verification run into a list of
|
|
6
|
+
:class:`NextAction` records — ``{category, action, file, line, missing_evidence,
|
|
7
|
+
suggested_command}`` — so a coding agent (over MCP) can self-repair and re-verify
|
|
8
|
+
without a human pasting anything.
|
|
9
|
+
|
|
10
|
+
The mapping is deterministic. Where a gap was created with explicit hints
|
|
11
|
+
(``gap.file``/``gap.line``/``gap.suggested_command``) those are used directly; when
|
|
12
|
+
a gap is reloaded from the database (which does not persist those hint columns) the
|
|
13
|
+
fields are reconstructed best-effort from the gap's evidence and description.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
|
|
22
|
+
from devcouncil.domain.gap import Gap
|
|
23
|
+
|
|
24
|
+
# Machine-routable buckets an agent can branch on. Kept small and stable so the
|
|
25
|
+
# contract is predictable across releases.
|
|
26
|
+
Category = str
|
|
27
|
+
|
|
28
|
+
_CATEGORY_BY_GAP_TYPE = {
|
|
29
|
+
"orphan_diff": "scope",
|
|
30
|
+
"planned_file_not_changed": "scope",
|
|
31
|
+
"dependency_risk": "scope",
|
|
32
|
+
"test_failed": "fix_code",
|
|
33
|
+
"invalid_verification_command": "fix_verification",
|
|
34
|
+
"acceptance_criteria_unproven": "add_test",
|
|
35
|
+
"diff_not_exercised": "add_test",
|
|
36
|
+
"missing_test": "add_test",
|
|
37
|
+
"security_risk": "security",
|
|
38
|
+
"architecture_drift": "review",
|
|
39
|
+
"assumption_violated": "review",
|
|
40
|
+
"migration_gap": "fix_code",
|
|
41
|
+
"requirement_not_planned": "plan",
|
|
42
|
+
"task_not_implemented": "plan",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NextAction(BaseModel):
|
|
47
|
+
"""One concrete, routable step the agent can take to clear a gap."""
|
|
48
|
+
|
|
49
|
+
gap_id: str
|
|
50
|
+
gap_type: str
|
|
51
|
+
category: Category
|
|
52
|
+
severity: str
|
|
53
|
+
blocking: bool
|
|
54
|
+
action: str
|
|
55
|
+
file: Optional[str] = None
|
|
56
|
+
line: Optional[int] = None
|
|
57
|
+
acceptance_criterion_id: Optional[str] = None
|
|
58
|
+
expected_verification_method: Optional[str] = None
|
|
59
|
+
missing_evidence: Optional[str] = None
|
|
60
|
+
suggested_command: Optional[str] = None
|
|
61
|
+
evidence: List[str] = Field(default_factory=list)
|
|
62
|
+
# Paths to the captured stdout/stderr logs for the failing command behind this
|
|
63
|
+
# gap, so the agent can open the full failure output without re-running. Present
|
|
64
|
+
# only on a fresh verify run (the gap store does not persist them).
|
|
65
|
+
stdout_path: Optional[str] = None
|
|
66
|
+
stderr_path: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _looks_like_path(value: str) -> bool:
|
|
70
|
+
value = value.strip()
|
|
71
|
+
if not value or " " in value or "\n" in value:
|
|
72
|
+
return False
|
|
73
|
+
return "/" in value or "." in value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _derive_file(gap: Gap) -> Optional[str]:
|
|
77
|
+
if gap.file:
|
|
78
|
+
return gap.file
|
|
79
|
+
for item in gap.evidence:
|
|
80
|
+
if isinstance(item, str) and _looks_like_path(item):
|
|
81
|
+
return item
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _action_text(gap: Gap, file: Optional[str]) -> str:
|
|
86
|
+
target = file or "the affected file"
|
|
87
|
+
if gap.gap_type == "orphan_diff":
|
|
88
|
+
return f"Revert changes to {target} or add it to the task's planned files."
|
|
89
|
+
if gap.gap_type == "planned_file_not_changed":
|
|
90
|
+
return f"Modify {target} as planned, or remove it from the task's planned files."
|
|
91
|
+
if gap.gap_type == "dependency_risk":
|
|
92
|
+
return f"Justify or revert the unplanned dependency/config change in {target}."
|
|
93
|
+
if gap.gap_type == "test_failed":
|
|
94
|
+
cmd = gap.suggested_command
|
|
95
|
+
return f"Fix the failing check, then re-run: {cmd}" if cmd else "Fix the failing verification check, then re-verify."
|
|
96
|
+
if gap.gap_type == "invalid_verification_command":
|
|
97
|
+
return "Replace the unrunnable verification command with a single runnable command, then re-verify."
|
|
98
|
+
if gap.gap_type == "diff_not_exercised":
|
|
99
|
+
loc = f" ({target}{':' + str(gap.line) if gap.line else ''})" if file else ""
|
|
100
|
+
return f"Add or extend a test that executes the changed lines{loc}, then re-verify."
|
|
101
|
+
if gap.gap_type in {"acceptance_criteria_unproven", "missing_test"}:
|
|
102
|
+
return "Provide a passing verification command that proves this acceptance criterion."
|
|
103
|
+
if gap.gap_type == "security_risk":
|
|
104
|
+
return "Remove the detected secret/finding from the diff and rotate any exposed credential."
|
|
105
|
+
if gap.gap_type == "architecture_drift":
|
|
106
|
+
return "Address the flagged change or resolve the open critique card, then re-verify."
|
|
107
|
+
# Fall back to the gap's own recommended fix for anything unmapped.
|
|
108
|
+
return gap.recommended_fix
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _missing_evidence(gap: Gap) -> Optional[str]:
|
|
112
|
+
"""A concrete description of WHAT evidence is missing — not a restatement of the
|
|
113
|
+
description.
|
|
114
|
+
|
|
115
|
+
For an unproven acceptance criterion we name the criterion and the expected
|
|
116
|
+
verification method (and, where the verifier knew one, the expected check command
|
|
117
|
+
via ``gap.suggested_command``) so the agent can author the right proof rather than
|
|
118
|
+
re-reading prose. Falls back to the gap description for the other add-test gap
|
|
119
|
+
types (diff_not_exercised, missing_test) which already carry concrete locations."""
|
|
120
|
+
if gap.gap_type == "acceptance_criteria_unproven":
|
|
121
|
+
parts: List[str] = []
|
|
122
|
+
ac = gap.acceptance_criterion_id
|
|
123
|
+
method = gap.expected_verification_method
|
|
124
|
+
if ac:
|
|
125
|
+
parts.append(f"No passing evidence for acceptance criterion {ac}")
|
|
126
|
+
else:
|
|
127
|
+
parts.append("No passing acceptance evidence")
|
|
128
|
+
if method:
|
|
129
|
+
parts.append(f"expected verification method: {method}")
|
|
130
|
+
if gap.suggested_command:
|
|
131
|
+
parts.append(f"run/repair check: {gap.suggested_command}")
|
|
132
|
+
elif gap.file:
|
|
133
|
+
loc = f"{gap.file}:{gap.line}" if gap.line else gap.file
|
|
134
|
+
parts.append(f"uncovered: {loc}")
|
|
135
|
+
return "; ".join(parts)
|
|
136
|
+
if gap.gap_type in {"diff_not_exercised", "missing_test"}:
|
|
137
|
+
return gap.description
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def next_action_for(gap: Gap) -> NextAction:
|
|
142
|
+
file = _derive_file(gap)
|
|
143
|
+
return NextAction(
|
|
144
|
+
gap_id=gap.id,
|
|
145
|
+
gap_type=gap.gap_type,
|
|
146
|
+
category=_CATEGORY_BY_GAP_TYPE.get(gap.gap_type, "review"),
|
|
147
|
+
severity=gap.severity,
|
|
148
|
+
blocking=gap.blocking,
|
|
149
|
+
action=_action_text(gap, file),
|
|
150
|
+
file=file,
|
|
151
|
+
line=gap.line,
|
|
152
|
+
acceptance_criterion_id=gap.acceptance_criterion_id,
|
|
153
|
+
expected_verification_method=gap.expected_verification_method,
|
|
154
|
+
missing_evidence=_missing_evidence(gap),
|
|
155
|
+
suggested_command=gap.suggested_command,
|
|
156
|
+
evidence=list(gap.evidence),
|
|
157
|
+
stdout_path=gap.stdout_path,
|
|
158
|
+
stderr_path=gap.stderr_path,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_next_actions(gaps: List[Gap], *, blocking_only: bool = True) -> List[NextAction]:
|
|
163
|
+
"""Build the next-actions contract from verification gaps.
|
|
164
|
+
|
|
165
|
+
By default only *blocking* gaps become next actions — those are what the agent
|
|
166
|
+
must clear to pass verification. Pass ``blocking_only=False`` to include
|
|
167
|
+
advisory signals (non-blocking gaps) as well. Blocking actions are ordered
|
|
168
|
+
first, then by severity.
|
|
169
|
+
"""
|
|
170
|
+
selected = [g for g in gaps if g.blocking] if blocking_only else list(gaps)
|
|
171
|
+
severity_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
172
|
+
selected.sort(key=lambda g: (not g.blocking, severity_rank.get(g.severity, 4)))
|
|
173
|
+
return [next_action_for(gap) for gap in selected]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def split_next_actions(gaps: List[Gap]) -> tuple[List[NextAction], List[NextAction]]:
|
|
177
|
+
"""Return ``(blocking_actions, advisory_actions)``.
|
|
178
|
+
|
|
179
|
+
Blocking actions are what the agent MUST clear to pass the gate. Advisory
|
|
180
|
+
actions are non-blocking signals worth acting on — most importantly the
|
|
181
|
+
diff↔coverage ``diff_not_exercised`` finding ("tests passed but the new code
|
|
182
|
+
was never run") and security/add-test hints, which ``build_next_actions``
|
|
183
|
+
filters out by default. Surfacing them as a distinct array lets an autonomous
|
|
184
|
+
agent improve quality without confusing them with the pass/fail gate.
|
|
185
|
+
"""
|
|
186
|
+
all_actions = build_next_actions(gaps, blocking_only=False)
|
|
187
|
+
blocking = [a for a in all_actions if a.blocking]
|
|
188
|
+
advisory = [a for a in all_actions if not a.blocking]
|
|
189
|
+
return blocking, advisory
|