devcouncil 0.2.0 → 0.3.1

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 (98) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/devcouncil/app/config.py +181 -7
  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 +9 -2
  8. package/src/devcouncil/cli/commands/check.py +12 -1
  9. package/src/devcouncil/cli/commands/design.py +186 -0
  10. package/src/devcouncil/cli/commands/doctor.py +160 -3
  11. package/src/devcouncil/cli/commands/go.py +96 -16
  12. package/src/devcouncil/cli/commands/hook.py +172 -0
  13. package/src/devcouncil/cli/commands/init.py +7 -2
  14. package/src/devcouncil/cli/commands/integrate.py +492 -34
  15. package/src/devcouncil/cli/commands/logs.py +106 -0
  16. package/src/devcouncil/cli/commands/okf.py +245 -0
  17. package/src/devcouncil/cli/commands/plan.py +54 -14
  18. package/src/devcouncil/cli/commands/repair.py +12 -3
  19. package/src/devcouncil/cli/commands/run.py +128 -7
  20. package/src/devcouncil/cli/commands/skills.py +180 -1
  21. package/src/devcouncil/cli/commands/status.py +7 -16
  22. package/src/devcouncil/cli/commands/verify.py +16 -10
  23. package/src/devcouncil/cli/commands/watch.py +24 -4
  24. package/src/devcouncil/cli/main.py +36 -1
  25. package/src/devcouncil/domain/evidence.py +7 -0
  26. package/src/devcouncil/execution/checkpoints.py +12 -2
  27. package/src/devcouncil/execution/fs_watcher.py +27 -2
  28. package/src/devcouncil/execution/handoff.py +1 -1
  29. package/src/devcouncil/execution/patch.py +6 -0
  30. package/src/devcouncil/execution/permissions.py +7 -0
  31. package/src/devcouncil/execution/policy_engine.py +12 -5
  32. package/src/devcouncil/execution/prompt_builder.py +126 -10
  33. package/src/devcouncil/execution/shell_session.py +6 -0
  34. package/src/devcouncil/execution/task_runner.py +18 -7
  35. package/src/devcouncil/executors/agent_registry.py +22 -1
  36. package/src/devcouncil/executors/coding_cli.py +133 -5
  37. package/src/devcouncil/executors/mini_swe.py +6 -0
  38. package/src/devcouncil/executors/native/agent.py +15 -0
  39. package/src/devcouncil/executors/openhands.py +6 -0
  40. package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
  41. package/src/devcouncil/gating/policy.py +38 -7
  42. package/src/devcouncil/indexing/ast_matcher.py +16 -6
  43. package/src/devcouncil/indexing/repo_mapper.py +30 -8
  44. package/src/devcouncil/indexing/semantic_index.py +42 -26
  45. package/src/devcouncil/integrations/actions.py +24 -4
  46. package/src/devcouncil/integrations/check.py +7 -4
  47. package/src/devcouncil/integrations/claude_assets.py +444 -0
  48. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  49. package/src/devcouncil/integrations/github_intent.py +8 -1
  50. package/src/devcouncil/integrations/gitnexus.py +10 -2
  51. package/src/devcouncil/integrations/mcp/server.py +404 -15
  52. package/src/devcouncil/integrations/pr_comments.py +9 -0
  53. package/src/devcouncil/knowledge/__init__.py +23 -0
  54. package/src/devcouncil/knowledge/design.py +374 -0
  55. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  56. package/src/devcouncil/knowledge/fetch.py +223 -0
  57. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  58. package/src/devcouncil/knowledge/okf.py +202 -0
  59. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  60. package/src/devcouncil/knowledge/sources.py +239 -0
  61. package/src/devcouncil/live/cards.py +20 -6
  62. package/src/devcouncil/live/repair_prompt.py +29 -6
  63. package/src/devcouncil/live/reviewer.py +72 -13
  64. package/src/devcouncil/live/summary.py +18 -8
  65. package/src/devcouncil/live/transcripts.py +38 -5
  66. package/src/devcouncil/llm/cache.py +14 -6
  67. package/src/devcouncil/llm/provider.py +179 -92
  68. package/src/devcouncil/llm/router.py +122 -23
  69. package/src/devcouncil/optimization/skillopt.py +673 -0
  70. package/src/devcouncil/planning/arbiter_service.py +10 -2
  71. package/src/devcouncil/planning/correction_manifest.py +47 -4
  72. package/src/devcouncil/planning/critique_service.py +9 -2
  73. package/src/devcouncil/planning/plan_service.py +69 -3
  74. package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
  75. package/src/devcouncil/planning/repair_service.py +8 -2
  76. package/src/devcouncil/planning/spec_service.py +10 -2
  77. package/src/devcouncil/repo/ci_scaffold.py +13 -5
  78. package/src/devcouncil/repo/sca.py +11 -1
  79. package/src/devcouncil/reporting/json_report.py +11 -0
  80. package/src/devcouncil/reporting/markdown_report.py +14 -1
  81. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  82. package/src/devcouncil/reporting/okf_html.py +323 -0
  83. package/src/devcouncil/reporting/report_builder.py +18 -1
  84. package/src/devcouncil/skills/registry.py +111 -33
  85. package/src/devcouncil/storage/db.py +58 -2
  86. package/src/devcouncil/storage/models.py +4 -0
  87. package/src/devcouncil/storage/native.py +20 -18
  88. package/src/devcouncil/storage/repositories.py +35 -18
  89. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  90. package/src/devcouncil/telemetry/stages.py +141 -0
  91. package/src/devcouncil/telemetry/tracker.py +12 -1
  92. package/src/devcouncil/ui/dashboard.py +69 -5
  93. package/src/devcouncil/verification/acceptance_compiler.py +147 -19
  94. package/src/devcouncil/verification/ad_hoc_check.py +6 -0
  95. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  96. package/src/devcouncil/verification/sandbox.py +7 -4
  97. package/src/devcouncil/verification/verifier.py +905 -517
  98. 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 Tool, TextContent, Resource
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
- diff_proc = _run(diff_args)
151
- numstat_proc = _run(numstat_args)
152
- namestatus_proc = _run(namestatus_args)
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).get_all() if g.task_id == task_id]
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 = [g for g in GapRepository(session).get_all() if g.task_id == task_id]
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 = [g for g in GapRepository(session).get_all() if g.task_id == task_id]
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 = CodeReviewGraphAdapter(root).get_context([file for file in files if isinstance(file, str)])
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=LspInspector(root).summary_json())]
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 = AstMatcher(root).match(
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
- any(g.blocking for g in GapRepository(session).get_all() if g.task_id == task_id),
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"]