devcouncil 0.2.0 → 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.
- package/README.md +12 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/app/config.py +181 -7
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +9 -2
- package/src/devcouncil/cli/commands/check.py +12 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +160 -3
- package/src/devcouncil/cli/commands/go.py +96 -16
- package/src/devcouncil/cli/commands/hook.py +172 -0
- package/src/devcouncil/cli/commands/init.py +7 -2
- package/src/devcouncil/cli/commands/integrate.py +492 -34
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +54 -14
- package/src/devcouncil/cli/commands/repair.py +12 -3
- package/src/devcouncil/cli/commands/run.py +128 -7
- package/src/devcouncil/cli/commands/skills.py +180 -1
- package/src/devcouncil/cli/commands/status.py +7 -16
- package/src/devcouncil/cli/commands/verify.py +16 -10
- package/src/devcouncil/cli/commands/watch.py +24 -4
- package/src/devcouncil/cli/main.py +36 -1
- package/src/devcouncil/domain/evidence.py +7 -0
- package/src/devcouncil/execution/checkpoints.py +12 -2
- package/src/devcouncil/execution/fs_watcher.py +27 -2
- package/src/devcouncil/execution/handoff.py +1 -1
- package/src/devcouncil/execution/patch.py +6 -0
- package/src/devcouncil/execution/permissions.py +7 -0
- package/src/devcouncil/execution/policy_engine.py +12 -5
- package/src/devcouncil/execution/prompt_builder.py +126 -10
- package/src/devcouncil/execution/shell_session.py +6 -0
- package/src/devcouncil/execution/task_runner.py +18 -7
- package/src/devcouncil/executors/agent_registry.py +22 -1
- package/src/devcouncil/executors/coding_cli.py +133 -5
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +15 -0
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
- package/src/devcouncil/gating/policy.py +38 -7
- package/src/devcouncil/indexing/ast_matcher.py +16 -6
- package/src/devcouncil/indexing/repo_mapper.py +30 -8
- package/src/devcouncil/indexing/semantic_index.py +42 -26
- package/src/devcouncil/integrations/actions.py +24 -4
- package/src/devcouncil/integrations/check.py +7 -4
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +8 -1
- package/src/devcouncil/integrations/gitnexus.py +10 -2
- package/src/devcouncil/integrations/mcp/server.py +404 -15
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +20 -6
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +38 -5
- package/src/devcouncil/llm/cache.py +14 -6
- package/src/devcouncil/llm/provider.py +179 -92
- package/src/devcouncil/llm/router.py +122 -23
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +47 -4
- package/src/devcouncil/planning/critique_service.py +9 -2
- package/src/devcouncil/planning/plan_service.py +69 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +10 -2
- package/src/devcouncil/repo/ci_scaffold.py +13 -5
- package/src/devcouncil/repo/sca.py +11 -1
- package/src/devcouncil/reporting/json_report.py +11 -0
- package/src/devcouncil/reporting/markdown_report.py +14 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/registry.py +111 -33
- package/src/devcouncil/storage/db.py +58 -2
- package/src/devcouncil/storage/models.py +4 -0
- package/src/devcouncil/storage/native.py +20 -18
- package/src/devcouncil/storage/repositories.py +35 -18
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/tracker.py +12 -1
- package/src/devcouncil/ui/dashboard.py +69 -5
- package/src/devcouncil/verification/acceptance_compiler.py +147 -19
- package/src/devcouncil/verification/ad_hoc_check.py +6 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/sandbox.py +7 -4
- package/src/devcouncil/verification/verifier.py +905 -517
- package/uv.lock +1 -1
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import hashlib
|
|
3
3
|
import json
|
|
4
|
+
import logging
|
|
4
5
|
import os
|
|
5
6
|
import subprocess
|
|
6
7
|
import sys
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from mcp.server import Server
|
|
10
|
+
from typing import Any, NamedTuple
|
|
9
11
|
from mcp.server.stdio import stdio_server
|
|
10
|
-
from mcp.types import
|
|
12
|
+
from mcp.types import (
|
|
13
|
+
Tool,
|
|
14
|
+
TextContent,
|
|
15
|
+
Resource,
|
|
16
|
+
Prompt,
|
|
17
|
+
PromptArgument,
|
|
18
|
+
PromptMessage,
|
|
19
|
+
GetPromptResult,
|
|
20
|
+
)
|
|
11
21
|
from pydantic import AnyUrl
|
|
12
22
|
from devcouncil.storage.db import get_db
|
|
13
23
|
from devcouncil.storage.repositories import (
|
|
@@ -41,6 +51,8 @@ from devcouncil.live.repair_prompt import build_bulk_live_repair_prompt, build_l
|
|
|
41
51
|
from devcouncil.integrations.check import integration_status_summary
|
|
42
52
|
from devcouncil.live.summary import live_review_summary
|
|
43
53
|
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
44
56
|
app = Server("devcouncil")
|
|
45
57
|
_DB_REQUIRED_TOOLS = {
|
|
46
58
|
"devcouncil_status",
|
|
@@ -123,7 +135,7 @@ def _read_log_file(path: str | None) -> str:
|
|
|
123
135
|
return ""
|
|
124
136
|
|
|
125
137
|
|
|
126
|
-
def _git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, object]:
|
|
138
|
+
async def _git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, object]:
|
|
127
139
|
"""Compute a (optionally path-scoped, optionally staged) git diff.
|
|
128
140
|
|
|
129
141
|
Returns {ok, files:[{path,status,additions,deletions}], unified_diff (truncated),
|
|
@@ -147,9 +159,12 @@ def _git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, object]:
|
|
|
147
159
|
)
|
|
148
160
|
|
|
149
161
|
try:
|
|
150
|
-
|
|
151
|
-
numstat_proc =
|
|
152
|
-
|
|
162
|
+
loop = asyncio.get_event_loop()
|
|
163
|
+
diff_proc, numstat_proc, namestatus_proc = await asyncio.gather(
|
|
164
|
+
loop.run_in_executor(None, _run, diff_args),
|
|
165
|
+
loop.run_in_executor(None, _run, numstat_args),
|
|
166
|
+
loop.run_in_executor(None, _run, namestatus_args),
|
|
167
|
+
)
|
|
153
168
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
154
169
|
return {"ok": False, "files": [], "unified_diff": "", "truncated": False, "error": str(exc)}
|
|
155
170
|
|
|
@@ -246,11 +261,77 @@ def _lease_ttl_seconds(root: Path) -> int:
|
|
|
246
261
|
return 1800
|
|
247
262
|
|
|
248
263
|
|
|
264
|
+
# Per-resolved-root caches for stateless, construction-heavy objects that several
|
|
265
|
+
# MCP handlers would otherwise rebuild on every request. Keyed by str(root.resolve())
|
|
266
|
+
# so distinct project roots (and distinct test temp dirs) never collide. _reset_caches()
|
|
267
|
+
# clears them for tests that need a clean slate within a single process.
|
|
268
|
+
_ROUTER_CACHE: dict[str, tuple[Any, Any]] = {}
|
|
269
|
+
_AST_MATCHER_CACHE: dict[str, AstMatcher] = {}
|
|
270
|
+
_LSP_INSPECTOR_CACHE: dict[str, LspInspector] = {}
|
|
271
|
+
_GRAPH_ADAPTER_CACHE: dict[str, CodeReviewGraphAdapter] = {}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _reset_caches() -> None:
|
|
275
|
+
"""Drop all per-root MCP caches (router/ast/lsp/graph). For test isolation."""
|
|
276
|
+
_ROUTER_CACHE.clear()
|
|
277
|
+
_AST_MATCHER_CACHE.clear()
|
|
278
|
+
_LSP_INSPECTOR_CACHE.clear()
|
|
279
|
+
_GRAPH_ADAPTER_CACHE.clear()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _get_ast_matcher(root: Path) -> AstMatcher:
|
|
283
|
+
key = str(root.resolve())
|
|
284
|
+
matcher = _AST_MATCHER_CACHE.get(key)
|
|
285
|
+
if matcher is None:
|
|
286
|
+
matcher = AstMatcher(root)
|
|
287
|
+
_AST_MATCHER_CACHE[key] = matcher
|
|
288
|
+
return matcher
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _get_lsp_inspector(root: Path) -> LspInspector:
|
|
292
|
+
key = str(root.resolve())
|
|
293
|
+
inspector = _LSP_INSPECTOR_CACHE.get(key)
|
|
294
|
+
if inspector is None:
|
|
295
|
+
inspector = LspInspector(root)
|
|
296
|
+
_LSP_INSPECTOR_CACHE[key] = inspector
|
|
297
|
+
return inspector
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _get_graph_adapter(root: Path) -> CodeReviewGraphAdapter:
|
|
301
|
+
key = str(root.resolve())
|
|
302
|
+
adapter = _GRAPH_ADAPTER_CACHE.get(key)
|
|
303
|
+
if adapter is None:
|
|
304
|
+
adapter = CodeReviewGraphAdapter(root)
|
|
305
|
+
_GRAPH_ADAPTER_CACHE[key] = adapter
|
|
306
|
+
return adapter
|
|
307
|
+
|
|
308
|
+
|
|
249
309
|
def _load_router(root: Path):
|
|
250
310
|
"""Build a ModelRouter from project config, or return None when no provider key
|
|
251
311
|
is configured. When present, the verifier runs DevCouncil's strong compiled
|
|
252
312
|
per-criterion acceptance checks; when None, it falls back to coarse mode (which
|
|
253
|
-
the verify response now reports explicitly so the agent is never misled).
|
|
313
|
+
the verify response now reports explicitly so the agent is never misled).
|
|
314
|
+
|
|
315
|
+
Cached per resolved project root: a verify_task storm would otherwise rebuild the
|
|
316
|
+
config+provider+router on every call. The cache is invalidated when config.yaml's
|
|
317
|
+
stat signature changes, so a long-running server picks up a rewritten config (new
|
|
318
|
+
provider key / model) instead of serving a stale router. Use _reset_caches() to clear
|
|
319
|
+
in tests."""
|
|
320
|
+
key = str(root.resolve())
|
|
321
|
+
try:
|
|
322
|
+
cfg_stat = (root / ".devcouncil" / "config.yaml").stat()
|
|
323
|
+
signature: object = (cfg_stat.st_mtime_ns, cfg_stat.st_size, cfg_stat.st_ino)
|
|
324
|
+
except OSError:
|
|
325
|
+
signature = None
|
|
326
|
+
cached = _ROUTER_CACHE.get(key)
|
|
327
|
+
if cached is not None and cached[0] == signature:
|
|
328
|
+
return cached[1]
|
|
329
|
+
router = _build_router(root)
|
|
330
|
+
_ROUTER_CACHE[key] = (signature, router)
|
|
331
|
+
return router
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _build_router(root: Path):
|
|
254
335
|
try:
|
|
255
336
|
from devcouncil.app.config import load_config, get_api_key
|
|
256
337
|
from devcouncil.llm.provider import create_provider, validate_model_provider
|
|
@@ -259,7 +340,7 @@ def _load_router(root: Path):
|
|
|
259
340
|
config = load_config(root)
|
|
260
341
|
validate_model_provider(config.models.provider)
|
|
261
342
|
api_key = get_api_key(config.models.provider, root)
|
|
262
|
-
provider = create_provider(config.models.provider, api_key, project_root=root)
|
|
343
|
+
provider = create_provider(config.models.provider, api_key, project_root=root, provider_prefs=config.provider)
|
|
263
344
|
role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
|
|
264
345
|
return ModelRouter(provider, role_config, project_root=root)
|
|
265
346
|
except Exception:
|
|
@@ -349,6 +430,51 @@ def _project_root() -> Path:
|
|
|
349
430
|
return Path(configured).expanduser().resolve() if configured else Path(".")
|
|
350
431
|
|
|
351
432
|
|
|
433
|
+
def _knowledge_source_uri(kind: str, name: str) -> str:
|
|
434
|
+
"""Stable, parseable resource URI for one ingested knowledge source.
|
|
435
|
+
|
|
436
|
+
The name is percent-encoded so OKF/design source names with spaces or slashes
|
|
437
|
+
still yield a valid AnyUrl and round-trip cleanly through read_resource."""
|
|
438
|
+
from urllib.parse import quote
|
|
439
|
+
|
|
440
|
+
return f"devcouncil://knowledge/{kind}/{quote(name, safe='')}"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _discover_knowledge_sources(root: Path) -> list:
|
|
444
|
+
"""Best-effort enumeration of ingested OKF/design knowledge for the project.
|
|
445
|
+
|
|
446
|
+
A broken or absent knowledge layer must never break resource listing, so any
|
|
447
|
+
failure degrades to an empty list (mirrors the other optional handlers here).
|
|
448
|
+
|
|
449
|
+
Honors the project's ``knowledge`` config (enabled / directory / design_always) so MCP
|
|
450
|
+
exposes exactly what the planning and task prompts do — otherwise a project that
|
|
451
|
+
disabled or relocated its knowledge would still leak it through MCP resources."""
|
|
452
|
+
try:
|
|
453
|
+
from devcouncil.knowledge.sources import discover_knowledge_sources
|
|
454
|
+
|
|
455
|
+
directory, design_always = _knowledge_settings(root)
|
|
456
|
+
if directory is None: # explicitly disabled in config
|
|
457
|
+
return []
|
|
458
|
+
return discover_knowledge_sources(root, directory=directory, design_always=design_always)
|
|
459
|
+
except Exception:
|
|
460
|
+
return []
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _knowledge_settings(root: Path) -> tuple[str | None, bool]:
|
|
464
|
+
"""Resolve (directory, design_always) for knowledge exposure from project config.
|
|
465
|
+
|
|
466
|
+
Returns ``(None, _)`` when the project explicitly disables knowledge so callers can
|
|
467
|
+
suppress it. Falls back to defaults when no/invalid config is present (the MCP server
|
|
468
|
+
must keep working for projects without a full ``.devcouncil/config.yaml``)."""
|
|
469
|
+
try:
|
|
470
|
+
from devcouncil.app.config import load_config
|
|
471
|
+
|
|
472
|
+
cfg = load_config(root).knowledge
|
|
473
|
+
return (None if not cfg.enabled else cfg.directory), cfg.design_always
|
|
474
|
+
except Exception:
|
|
475
|
+
return ".devcouncil/knowledge", True
|
|
476
|
+
|
|
477
|
+
|
|
352
478
|
def _is_secret_path(root: Path, rel_or_abs: str) -> bool:
|
|
353
479
|
"""True when a path matches a protected secret/credential glob.
|
|
354
480
|
|
|
@@ -949,6 +1075,24 @@ async def list_tools() -> list[Tool]:
|
|
|
949
1075
|
},
|
|
950
1076
|
},
|
|
951
1077
|
),
|
|
1078
|
+
Tool(
|
|
1079
|
+
name="devcouncil_select_knowledge",
|
|
1080
|
+
description=(
|
|
1081
|
+
"Select the ingested project knowledge (OKF documents and the design "
|
|
1082
|
+
"system) that applies to a goal and return it as a ready-to-inject "
|
|
1083
|
+
"markdown preamble, so a coding agent can ask 'what project knowledge "
|
|
1084
|
+
"applies to <goal>?'. Always-on design knowledge is included; OKF "
|
|
1085
|
+
"documents are matched on goal keywords. Returns the matched sources "
|
|
1086
|
+
"and the rendered preamble."
|
|
1087
|
+
),
|
|
1088
|
+
inputSchema={
|
|
1089
|
+
"type": "object",
|
|
1090
|
+
"properties": {
|
|
1091
|
+
"goal": {"type": "string", "description": "The task or goal to find applicable knowledge for."},
|
|
1092
|
+
},
|
|
1093
|
+
"required": ["goal"],
|
|
1094
|
+
},
|
|
1095
|
+
),
|
|
952
1096
|
]
|
|
953
1097
|
|
|
954
1098
|
@app.list_resources()
|
|
@@ -978,6 +1122,23 @@ async def list_resources() -> list[Resource]:
|
|
|
978
1122
|
description=f"Scope, status, and gaps for {task.id}.",
|
|
979
1123
|
mimeType="application/json",
|
|
980
1124
|
))
|
|
1125
|
+
# Project knowledge (ingested OKF + design.md) — surfaced only when something has
|
|
1126
|
+
# actually been ingested, so hosts without a knowledge layer see no empty entries.
|
|
1127
|
+
knowledge_sources = _discover_knowledge_sources(root)
|
|
1128
|
+
if knowledge_sources:
|
|
1129
|
+
resources.append(Resource(
|
|
1130
|
+
uri=AnyUrl("devcouncil://knowledge"),
|
|
1131
|
+
name="Project knowledge",
|
|
1132
|
+
description="Index of ingested OKF and design knowledge for this project.",
|
|
1133
|
+
mimeType="text/markdown",
|
|
1134
|
+
))
|
|
1135
|
+
for source in knowledge_sources:
|
|
1136
|
+
resources.append(Resource(
|
|
1137
|
+
uri=AnyUrl(_knowledge_source_uri(source.kind, source.name)),
|
|
1138
|
+
name=f"Knowledge ({source.kind}): {source.description or source.name}",
|
|
1139
|
+
description=source.description or source.name,
|
|
1140
|
+
mimeType="text/markdown",
|
|
1141
|
+
))
|
|
981
1142
|
return resources
|
|
982
1143
|
|
|
983
1144
|
|
|
@@ -1015,18 +1176,204 @@ async def read_resource(uri: AnyUrl) -> str:
|
|
|
1015
1176
|
task = TaskRepository(session).get_by_id(task_id)
|
|
1016
1177
|
if not task:
|
|
1017
1178
|
return json.dumps({"ok": False, "error": f"Task {task_id} not found."})
|
|
1018
|
-
gaps = [g.model_dump() for g in GapRepository(session).
|
|
1179
|
+
gaps = [g.model_dump() for g in GapRepository(session).get_for_task(task_id)]
|
|
1019
1180
|
return json.dumps({"task": task.model_dump(), "gaps": gaps}, indent=2)
|
|
1020
1181
|
|
|
1182
|
+
if key == "devcouncil://knowledge":
|
|
1183
|
+
# Markdown index linking each ingested source to its per-source resource URI.
|
|
1184
|
+
sources = _discover_knowledge_sources(root)
|
|
1185
|
+
if not sources:
|
|
1186
|
+
return "# Project knowledge\n\nNo OKF or design knowledge has been ingested for this project."
|
|
1187
|
+
lines = ["# Project knowledge", "", "Ingested OKF and design knowledge for this project.", ""]
|
|
1188
|
+
for kind in ("design", "okf"):
|
|
1189
|
+
kind_sources = [s for s in sources if s.kind == kind]
|
|
1190
|
+
if not kind_sources:
|
|
1191
|
+
continue
|
|
1192
|
+
lines.append(f"## {kind.upper() if kind == 'okf' else kind.capitalize()}")
|
|
1193
|
+
lines.append("")
|
|
1194
|
+
for source in kind_sources:
|
|
1195
|
+
link = _knowledge_source_uri(source.kind, source.name)
|
|
1196
|
+
desc = source.description or source.name
|
|
1197
|
+
lines.append(f"- [{desc}]({link})")
|
|
1198
|
+
lines.append("")
|
|
1199
|
+
return "\n".join(lines).strip()
|
|
1200
|
+
if key.startswith("devcouncil://knowledge/"):
|
|
1201
|
+
# Match the requested URI back to a discovered source and render its markdown.
|
|
1202
|
+
for source in _discover_knowledge_sources(root):
|
|
1203
|
+
if _knowledge_source_uri(source.kind, source.name) == key:
|
|
1204
|
+
return source.render() or source.body
|
|
1205
|
+
return f"Knowledge source not found: {key}"
|
|
1206
|
+
|
|
1021
1207
|
raise ValueError(f"Unknown resource: {uri}")
|
|
1022
1208
|
|
|
1023
1209
|
|
|
1210
|
+
# --- MCP prompts ---------------------------------------------------------------
|
|
1211
|
+
# Exposed prompts surface in MCP hosts (Claude Code, Codex, ...) as slash commands
|
|
1212
|
+
# (e.g. /mcp__devcouncil__implement_next_task). Each renders an actionable, DevCouncil-
|
|
1213
|
+
# aware instruction block — injecting a live status snapshot when the project is
|
|
1214
|
+
# initialized, and degrading to pure guidance when it is not. They steer a coding agent
|
|
1215
|
+
# through the lease -> read -> edit -> verify -> release loop using the devcouncil_* tools.
|
|
1216
|
+
|
|
1217
|
+
class _PromptSpec(NamedTuple):
|
|
1218
|
+
name: str
|
|
1219
|
+
description: str
|
|
1220
|
+
arguments: list[PromptArgument]
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
_PROMPT_SPECS: list[_PromptSpec] = [
|
|
1224
|
+
_PromptSpec(
|
|
1225
|
+
name="devcouncil_implement_next_task",
|
|
1226
|
+
description="Pick up the next unblocked DevCouncil task and implement it through the policy-gated MCP loop.",
|
|
1227
|
+
arguments=[
|
|
1228
|
+
PromptArgument(name="client_id", description="Optional stable client id used for the task lease.", required=False),
|
|
1229
|
+
],
|
|
1230
|
+
),
|
|
1231
|
+
_PromptSpec(
|
|
1232
|
+
name="devcouncil_repair_task",
|
|
1233
|
+
description="Repair the blocking verification gaps for a task (defaults to the active running task).",
|
|
1234
|
+
arguments=[
|
|
1235
|
+
PromptArgument(name="task_id", description="Task id, e.g. TASK-001. Defaults to the active task.", required=False),
|
|
1236
|
+
],
|
|
1237
|
+
),
|
|
1238
|
+
_PromptSpec(
|
|
1239
|
+
name="devcouncil_verify_task",
|
|
1240
|
+
description="Run DevCouncil verification for a task and report blocking gaps.",
|
|
1241
|
+
arguments=[
|
|
1242
|
+
PromptArgument(name="task_id", description="Task id, e.g. TASK-001. Defaults to the active task.", required=False),
|
|
1243
|
+
],
|
|
1244
|
+
),
|
|
1245
|
+
_PromptSpec(
|
|
1246
|
+
name="devcouncil_review_live",
|
|
1247
|
+
description="Review pending live-review critique cards and resolve the blocking ones.",
|
|
1248
|
+
arguments=[
|
|
1249
|
+
PromptArgument(name="task_id", description="Optional task scope for the live-review cards.", required=False),
|
|
1250
|
+
],
|
|
1251
|
+
),
|
|
1252
|
+
_PromptSpec(
|
|
1253
|
+
name="devcouncil_project_status",
|
|
1254
|
+
description="Summarize the current DevCouncil project phase, tasks, and blocking gaps.",
|
|
1255
|
+
arguments=[],
|
|
1256
|
+
),
|
|
1257
|
+
_PromptSpec(
|
|
1258
|
+
name="devcouncil_apply_knowledge",
|
|
1259
|
+
description="Select the ingested project knowledge (OKF + design) that applies to a goal and inject it.",
|
|
1260
|
+
arguments=[
|
|
1261
|
+
PromptArgument(name="goal", description="The task or goal to find applicable project knowledge for.", required=True),
|
|
1262
|
+
],
|
|
1263
|
+
),
|
|
1264
|
+
]
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _status_snapshot(root: Path) -> str:
|
|
1268
|
+
"""A short live status block for prompt bodies, or an init hint when uninitialized."""
|
|
1269
|
+
db = get_db(root)
|
|
1270
|
+
if not db:
|
|
1271
|
+
return "DevCouncil is not initialized here yet — run `dev init` first."
|
|
1272
|
+
try:
|
|
1273
|
+
with db.get_session() as session:
|
|
1274
|
+
graph = ArtifactGraphRepository(session).load_graph()
|
|
1275
|
+
summary = graph.coverage_summary()
|
|
1276
|
+
state = StateRepository(session).get_state()
|
|
1277
|
+
phase = compute_phase(graph, state.current_phase if state else None)
|
|
1278
|
+
return (
|
|
1279
|
+
f"Phase: {phase} | "
|
|
1280
|
+
f"tasks: {summary['total_tasks']} | "
|
|
1281
|
+
f"gaps: {summary['total_gaps']} ({summary['blocking_gaps']} blocking)"
|
|
1282
|
+
)
|
|
1283
|
+
except Exception:
|
|
1284
|
+
return "DevCouncil status unavailable."
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def _render_prompt_text(name: str, arguments: dict, root: Path) -> str:
|
|
1288
|
+
snapshot = _status_snapshot(root)
|
|
1289
|
+
if name == "devcouncil_implement_next_task":
|
|
1290
|
+
client_id = arguments.get("client_id") or "claude-code"
|
|
1291
|
+
return (
|
|
1292
|
+
"You are implementing the next DevCouncil task under policy enforcement.\n\n"
|
|
1293
|
+
f"Project status: {snapshot}\n\n"
|
|
1294
|
+
"Do exactly this:\n"
|
|
1295
|
+
"1. Call `devcouncil_next_task` to get the highest-priority unblocked task.\n"
|
|
1296
|
+
f"2. Call `devcouncil_checkout_task` with that task_id and client_id='{client_id}' to acquire a lease.\n"
|
|
1297
|
+
"3. Read the task scope with `devcouncil_get_task` and `devcouncil_get_prompt`; inspect files with `devcouncil_read_file` and `devcouncil_get_diff`.\n"
|
|
1298
|
+
"4. Make changes ONLY through `devcouncil_write_file` / `devcouncil_apply_patch` (the policy gate rejects out-of-scope or protected paths) and run tests with `devcouncil_run_command`.\n"
|
|
1299
|
+
"5. Call `devcouncil_verify_task`; if it reports blocking gaps, fix them and re-verify.\n"
|
|
1300
|
+
"6. When verified, call `devcouncil_release_task` with the lease token.\n\n"
|
|
1301
|
+
"Never edit files outside the task scope. If a write is rejected, call `devcouncil_update_task_scope` only when the change is legitimately in-scope."
|
|
1302
|
+
)
|
|
1303
|
+
if name == "devcouncil_repair_task":
|
|
1304
|
+
task_id = arguments.get("task_id") or "(the active task)"
|
|
1305
|
+
return (
|
|
1306
|
+
f"Repair the blocking verification gaps for {task_id}.\n\n"
|
|
1307
|
+
f"Project status: {snapshot}\n\n"
|
|
1308
|
+
"1. Call `devcouncil_get_gaps` (blocking_only=true) and `devcouncil_get_next_actions` for the task.\n"
|
|
1309
|
+
"2. Inspect the relevant files and evidence with `devcouncil_read_file` and `devcouncil_get_evidence`.\n"
|
|
1310
|
+
"3. Apply minimal fixes via `devcouncil_apply_patch` / `devcouncil_write_file`.\n"
|
|
1311
|
+
"4. Re-run `devcouncil_verify_task` until no blocking gaps remain, then `devcouncil_release_task`."
|
|
1312
|
+
)
|
|
1313
|
+
if name == "devcouncil_verify_task":
|
|
1314
|
+
task_id = arguments.get("task_id") or "(the active task)"
|
|
1315
|
+
return (
|
|
1316
|
+
f"Run DevCouncil verification for {task_id} and report the result.\n\n"
|
|
1317
|
+
f"Project status: {snapshot}\n\n"
|
|
1318
|
+
"Call `devcouncil_verify_task` (you must hold the task lease via `devcouncil_checkout_task`). "
|
|
1319
|
+
"Summarize the blocking gaps and proposed next actions; do not mark work complete while blocking gaps remain."
|
|
1320
|
+
)
|
|
1321
|
+
if name == "devcouncil_review_live":
|
|
1322
|
+
scope = arguments.get("task_id")
|
|
1323
|
+
scope_line = f" scoped to {scope}" if scope else ""
|
|
1324
|
+
return (
|
|
1325
|
+
f"Review the pending live-review critique cards{scope_line}.\n\n"
|
|
1326
|
+
"1. Call `devcouncil_live_review` for the blocker count and `devcouncil_live_cards` (status='open') for the cards.\n"
|
|
1327
|
+
"2. For each blocking card, call `devcouncil_live_repair_prompt` (or `devcouncil_live_repair_all`) to get a ready-to-apply repair.\n"
|
|
1328
|
+
"3. Apply the fixes through the policy-gated write tools and re-verify."
|
|
1329
|
+
)
|
|
1330
|
+
if name == "devcouncil_project_status":
|
|
1331
|
+
return (
|
|
1332
|
+
"Summarize the DevCouncil project state for the user.\n\n"
|
|
1333
|
+
f"Live snapshot: {snapshot}\n\n"
|
|
1334
|
+
"Call `devcouncil_status` and `devcouncil_report` for the full coverage report, then give a concise "
|
|
1335
|
+
"phase / tasks / blocking-gaps summary and recommend the next action."
|
|
1336
|
+
)
|
|
1337
|
+
if name == "devcouncil_apply_knowledge":
|
|
1338
|
+
goal = arguments.get("goal") or ""
|
|
1339
|
+
return (
|
|
1340
|
+
f"Find and apply the project knowledge that applies to this goal: {goal!r}.\n\n"
|
|
1341
|
+
"Call `devcouncil_select_knowledge` with the goal, then treat the returned preamble as authoritative "
|
|
1342
|
+
"project context (design system + OKF docs) for any code you write toward this goal."
|
|
1343
|
+
)
|
|
1344
|
+
return f"Unknown DevCouncil prompt: {name}"
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
@app.list_prompts()
|
|
1348
|
+
async def list_prompts() -> list[Prompt]:
|
|
1349
|
+
return [
|
|
1350
|
+
Prompt(name=spec.name, description=spec.description, arguments=list(spec.arguments))
|
|
1351
|
+
for spec in _PROMPT_SPECS
|
|
1352
|
+
]
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
@app.get_prompt()
|
|
1356
|
+
async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult:
|
|
1357
|
+
spec = next((spec for spec in _PROMPT_SPECS if spec.name == name), None)
|
|
1358
|
+
if spec is None:
|
|
1359
|
+
raise ValueError(f"Unknown prompt: {name}")
|
|
1360
|
+
args = _normalize_arguments(arguments)
|
|
1361
|
+
root = _project_root()
|
|
1362
|
+
text = _render_prompt_text(name, args, root)
|
|
1363
|
+
return GetPromptResult(
|
|
1364
|
+
description=spec.description,
|
|
1365
|
+
messages=[PromptMessage(role="user", content=TextContent(type="text", text=text))],
|
|
1366
|
+
)
|
|
1367
|
+
|
|
1368
|
+
|
|
1024
1369
|
@app.call_tool()
|
|
1025
1370
|
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
1026
1371
|
arguments = _normalize_arguments(arguments)
|
|
1372
|
+
logger.info("MCP call_tool: %s args=%s", name, sorted(arguments) if isinstance(arguments, dict) else arguments)
|
|
1027
1373
|
root = _project_root()
|
|
1028
1374
|
db = get_db(root)
|
|
1029
1375
|
if name in _DB_REQUIRED_TOOLS and not db:
|
|
1376
|
+
logger.warning("MCP tool %s rejected: project not initialized at %s", name, root)
|
|
1030
1377
|
return _error_text("DevCouncil not initialized in this directory.", code="not_initialized")
|
|
1031
1378
|
|
|
1032
1379
|
if name == "devcouncil_integration_status":
|
|
@@ -1162,9 +1509,10 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
1162
1509
|
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
1163
1510
|
if arg_error:
|
|
1164
1511
|
return arg_error
|
|
1512
|
+
assert task_id is not None # _required_string_argument returns a value when arg_error is None
|
|
1165
1513
|
blocking_only = bool(arguments.get("blocking_only", False))
|
|
1166
1514
|
with db.get_session() as session:
|
|
1167
|
-
gaps =
|
|
1515
|
+
gaps = GapRepository(session).get_for_task(task_id)
|
|
1168
1516
|
if blocking_only:
|
|
1169
1517
|
gaps = [g for g in gaps if g.blocking]
|
|
1170
1518
|
return _json_text({
|
|
@@ -1181,7 +1529,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
1181
1529
|
return arg_error
|
|
1182
1530
|
assert task_id is not None
|
|
1183
1531
|
with db.get_session() as session:
|
|
1184
|
-
gaps =
|
|
1532
|
+
gaps = GapRepository(session).get_for_task(task_id)
|
|
1185
1533
|
task = TaskRepository(session).get_by_id(task_id)
|
|
1186
1534
|
blocking_actions, advisory_actions = split_next_actions(gaps)
|
|
1187
1535
|
has_blocking = any(g.blocking for g in gaps)
|
|
@@ -1294,11 +1642,11 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
1294
1642
|
files = arguments.get("files", [])
|
|
1295
1643
|
if not isinstance(files, list):
|
|
1296
1644
|
files = []
|
|
1297
|
-
context =
|
|
1645
|
+
context = _get_graph_adapter(root).get_context([file for file in files if isinstance(file, str)])
|
|
1298
1646
|
return [TextContent(type="text", text=context.model_dump_json(indent=2))]
|
|
1299
1647
|
|
|
1300
1648
|
elif name == "devcouncil_lsp_status":
|
|
1301
|
-
return [TextContent(type="text", text=
|
|
1649
|
+
return [TextContent(type="text", text=_get_lsp_inspector(root).summary_json())]
|
|
1302
1650
|
|
|
1303
1651
|
elif name == "devcouncil_ast_match":
|
|
1304
1652
|
query = _optional_string_argument(arguments, "query")
|
|
@@ -1308,7 +1656,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
1308
1656
|
if value == "":
|
|
1309
1657
|
return _error_text(f"{arg_name} must be a string", code="invalid_arguments", argument=arg_name)
|
|
1310
1658
|
limit = _int_argument(arguments, "limit", 100, minimum=1, maximum=500)
|
|
1311
|
-
matches =
|
|
1659
|
+
matches = _get_ast_matcher(root).match(
|
|
1312
1660
|
query=query or "",
|
|
1313
1661
|
language=language,
|
|
1314
1662
|
kind=kind,
|
|
@@ -1403,7 +1751,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
1403
1751
|
# The task is now leased and running-ready: surface the inner-loop tools.
|
|
1404
1752
|
"allowed_next_tools": _allowed_next_tools(
|
|
1405
1753
|
"running",
|
|
1406
|
-
|
|
1754
|
+
bool(GapRepository(session).get_blocking_for_task(task_id)),
|
|
1407
1755
|
),
|
|
1408
1756
|
})
|
|
1409
1757
|
|
|
@@ -1903,7 +2251,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
1903
2251
|
p = planned.path.replace("\\", "/")
|
|
1904
2252
|
if p not in scope_paths:
|
|
1905
2253
|
scope_paths.append(p)
|
|
1906
|
-
return _json_text(_git_diff(root, scope_paths, staged_value))
|
|
2254
|
+
return _json_text(await _git_diff(root, scope_paths, staged_value))
|
|
1907
2255
|
|
|
1908
2256
|
elif name == "devcouncil_get_evidence":
|
|
1909
2257
|
assert db is not None
|
|
@@ -2111,6 +2459,47 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
2111
2459
|
"transcript_truncated": truncated,
|
|
2112
2460
|
})
|
|
2113
2461
|
|
|
2462
|
+
elif name == "devcouncil_select_knowledge":
|
|
2463
|
+
# No DB needed: knowledge lives on disk. Best-effort — a knowledge failure
|
|
2464
|
+
# degrades to an empty preamble rather than crashing the server.
|
|
2465
|
+
goal, arg_error = _required_string_argument(arguments, "goal")
|
|
2466
|
+
if arg_error:
|
|
2467
|
+
return arg_error
|
|
2468
|
+
assert goal is not None
|
|
2469
|
+
try:
|
|
2470
|
+
from devcouncil.knowledge.sources import (
|
|
2471
|
+
render_knowledge_preamble,
|
|
2472
|
+
select_knowledge_sources,
|
|
2473
|
+
)
|
|
2474
|
+
|
|
2475
|
+
# Honor the project's knowledge config so MCP selection matches the prompts.
|
|
2476
|
+
directory, design_always = _knowledge_settings(root)
|
|
2477
|
+
if directory is None: # explicitly disabled
|
|
2478
|
+
sources = []
|
|
2479
|
+
else:
|
|
2480
|
+
sources = select_knowledge_sources(
|
|
2481
|
+
goal, root, directory=directory, design_always=design_always
|
|
2482
|
+
)
|
|
2483
|
+
preamble = render_knowledge_preamble(sources)
|
|
2484
|
+
return _json_text({
|
|
2485
|
+
"ok": True,
|
|
2486
|
+
"goal": goal,
|
|
2487
|
+
"sources": [
|
|
2488
|
+
{"name": s.name, "kind": s.kind, "description": s.description}
|
|
2489
|
+
for s in sources
|
|
2490
|
+
],
|
|
2491
|
+
"preamble": preamble,
|
|
2492
|
+
})
|
|
2493
|
+
except Exception as exc:
|
|
2494
|
+
return _json_text({
|
|
2495
|
+
"ok": True,
|
|
2496
|
+
"goal": goal,
|
|
2497
|
+
"sources": [],
|
|
2498
|
+
"preamble": "",
|
|
2499
|
+
"note": f"knowledge unavailable: {exc}",
|
|
2500
|
+
})
|
|
2501
|
+
|
|
2502
|
+
logger.warning("MCP unknown tool requested: %s", name)
|
|
2114
2503
|
return _error_text(f"Unknown tool: {name}", code="unknown_tool", tool=name)
|
|
2115
2504
|
|
|
2116
2505
|
async def run():
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
import httpx
|
|
4
5
|
from urllib.parse import quote
|
|
5
6
|
|
|
6
7
|
from devcouncil.artifacts.graph import ArtifactGraph
|
|
7
8
|
from devcouncil.reporting.report_builder import ReportBuilder
|
|
8
9
|
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
9
12
|
|
|
10
13
|
class PullRequestCommentError(RuntimeError):
|
|
11
14
|
pass
|
|
@@ -34,10 +37,13 @@ class GitHubPRCommenter:
|
|
|
34
37
|
"Accept": "application/vnd.github+json",
|
|
35
38
|
"Content-Type": "application/json",
|
|
36
39
|
}
|
|
40
|
+
logger.info("Posting GitHub PR comment: repo=%s pr=%s", self.repository, self.pull_number)
|
|
37
41
|
async with httpx.AsyncClient() as client:
|
|
38
42
|
response = await client.post(url, headers=headers, json={"body": body})
|
|
39
43
|
if response.status_code >= 400:
|
|
44
|
+
logger.error("GitHub comment failed: HTTP %s for %s#%s", response.status_code, self.repository, self.pull_number)
|
|
40
45
|
raise PullRequestCommentError(f"GitHub comment failed with HTTP {response.status_code}: {response.text}")
|
|
46
|
+
logger.info("GitHub PR comment posted to %s#%s", self.repository, self.pull_number)
|
|
41
47
|
return response.json() if response.content else {}
|
|
42
48
|
|
|
43
49
|
|
|
@@ -55,8 +61,11 @@ class GitLabMRCommenter:
|
|
|
55
61
|
"PRIVATE-TOKEN": self.token,
|
|
56
62
|
"Content-Type": "application/json",
|
|
57
63
|
}
|
|
64
|
+
logger.info("Posting GitLab MR note: project=%s mr=%s", self.project_id, self.merge_request_iid)
|
|
58
65
|
async with httpx.AsyncClient() as client:
|
|
59
66
|
response = await client.post(url, headers=headers, json={"body": body})
|
|
60
67
|
if response.status_code >= 400:
|
|
68
|
+
logger.error("GitLab comment failed: HTTP %s for project=%s mr=%s", response.status_code, self.project_id, self.merge_request_iid)
|
|
61
69
|
raise PullRequestCommentError(f"GitLab comment failed with HTTP {response.status_code}: {response.text}")
|
|
70
|
+
logger.info("GitLab MR note posted to project=%s mr=%s", self.project_id, self.merge_request_iid)
|
|
62
71
|
return response.json() if response.content else {}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Knowledge formats: Open Knowledge Format (OKF) and design.md support.
|
|
2
|
+
|
|
3
|
+
DevCouncil treats durable, file-based knowledge the same way it treats its own
|
|
4
|
+
artifacts. This package adds two interoperable, vendor-neutral markdown formats:
|
|
5
|
+
|
|
6
|
+
* :mod:`devcouncil.knowledge.okf` — the Open Knowledge Format (Google Cloud, v0.1):
|
|
7
|
+
markdown + YAML frontmatter arranged into a cross-linked knowledge graph. Used both
|
|
8
|
+
to *export* DevCouncil's artifact graph as a portable bundle and to *ingest* external
|
|
9
|
+
org knowledge as planning context.
|
|
10
|
+
* :mod:`devcouncil.knowledge.design` — the design.md spec (google-labs-code, alpha):
|
|
11
|
+
machine-readable design tokens plus human-readable rationale, with lint/export tooling.
|
|
12
|
+
|
|
13
|
+
Both ride on :mod:`devcouncil.knowledge.frontmatter` (a single markdown+YAML frontmatter
|
|
14
|
+
implementation shared with the skills library) and are surfaced as selectable
|
|
15
|
+
:class:`devcouncil.knowledge.sources.KnowledgeSource` objects injected into prompts.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from devcouncil.knowledge.frontmatter import (
|
|
19
|
+
build_frontmatter_markdown,
|
|
20
|
+
split_frontmatter,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = ["build_frontmatter_markdown", "split_frontmatter"]
|