devcouncil 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- 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 +6 -3
- package/src/devcouncil/cli/commands/check.py +220 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +195 -54
- 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 +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- 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 +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- 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 +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -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/__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 +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -1,42 +1,93 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import hashlib
|
|
2
3
|
import json
|
|
4
|
+
import logging
|
|
3
5
|
import os
|
|
4
6
|
import subprocess
|
|
5
7
|
import sys
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
from mcp.server import Server
|
|
10
|
+
from typing import Any, NamedTuple
|
|
8
11
|
from mcp.server.stdio import stdio_server
|
|
9
|
-
from mcp.types import
|
|
12
|
+
from mcp.types import (
|
|
13
|
+
Tool,
|
|
14
|
+
TextContent,
|
|
15
|
+
Resource,
|
|
16
|
+
Prompt,
|
|
17
|
+
PromptArgument,
|
|
18
|
+
PromptMessage,
|
|
19
|
+
GetPromptResult,
|
|
20
|
+
)
|
|
21
|
+
from pydantic import AnyUrl
|
|
10
22
|
from devcouncil.storage.db import get_db
|
|
11
|
-
from devcouncil.storage.repositories import
|
|
23
|
+
from devcouncil.storage.repositories import (
|
|
24
|
+
TaskRepository,
|
|
25
|
+
ArtifactGraphRepository,
|
|
26
|
+
StateRepository,
|
|
27
|
+
RequirementRepository,
|
|
28
|
+
EvidenceRepository,
|
|
29
|
+
GapRepository,
|
|
30
|
+
)
|
|
31
|
+
from devcouncil.storage.native import (
|
|
32
|
+
TaskLeaseRepository,
|
|
33
|
+
ShellCommandRepository,
|
|
34
|
+
FileChangeRepository,
|
|
35
|
+
VerificationRunRepository,
|
|
36
|
+
CorrectionManifestRepository,
|
|
37
|
+
)
|
|
38
|
+
from devcouncil.domain.evidence import CommandResult, DiffEvidence, DiffCoverageEvidence, TestEvidence
|
|
39
|
+
from devcouncil.verification.next_actions import split_next_actions
|
|
12
40
|
from devcouncil.reporting.report_builder import ReportBuilder
|
|
13
41
|
from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
|
|
14
42
|
from devcouncil.execution.hook_policy import HookPolicy
|
|
15
43
|
from devcouncil.execution.prompt_builder import PromptBuilder
|
|
44
|
+
from devcouncil.utils.subprocess_env import clean_subprocess_env
|
|
16
45
|
from devcouncil.telemetry.traces import read_trace_events
|
|
17
46
|
from devcouncil.indexing.ast_matcher import AstMatcher
|
|
18
47
|
from devcouncil.indexing.lsp import LspInspector
|
|
19
48
|
from devcouncil.app.project_status import compute_phase
|
|
20
49
|
from devcouncil.live.cards import filter_cards, get_card, load_cards
|
|
21
50
|
from devcouncil.live.repair_prompt import build_bulk_live_repair_prompt, build_live_repair_prompt
|
|
51
|
+
from devcouncil.integrations.check import integration_status_summary
|
|
22
52
|
from devcouncil.live.summary import live_review_summary
|
|
23
53
|
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
24
56
|
app = Server("devcouncil")
|
|
25
57
|
_DB_REQUIRED_TOOLS = {
|
|
26
58
|
"devcouncil_status",
|
|
27
59
|
"devcouncil_report",
|
|
28
60
|
"devcouncil_get_task",
|
|
29
61
|
"devcouncil_list_tasks",
|
|
62
|
+
"devcouncil_get_gaps",
|
|
63
|
+
"devcouncil_get_next_actions",
|
|
64
|
+
"devcouncil_get_task_provenance",
|
|
65
|
+
"devcouncil_list_leases",
|
|
66
|
+
"devcouncil_renew_lease",
|
|
30
67
|
"devcouncil_get_prompt",
|
|
31
68
|
"devcouncil_tail_trace",
|
|
32
69
|
"devcouncil_policy_check_write",
|
|
33
70
|
"devcouncil_graph_context",
|
|
34
71
|
"devcouncil_prepare_execution",
|
|
72
|
+
"devcouncil_checkout_task",
|
|
73
|
+
"devcouncil_release_task",
|
|
74
|
+
"devcouncil_update_task_scope",
|
|
75
|
+
"devcouncil_append_evidence",
|
|
76
|
+
"devcouncil_record_command",
|
|
77
|
+
"devcouncil_write_file",
|
|
78
|
+
"devcouncil_apply_patch",
|
|
79
|
+
"devcouncil_verify_task",
|
|
80
|
+
"devcouncil_handoff_agent",
|
|
81
|
+
"devcouncil_get_evidence",
|
|
82
|
+
"devcouncil_run_command",
|
|
83
|
+
"devcouncil_next_task",
|
|
35
84
|
}
|
|
36
85
|
_CLI_ALLOWED_ROOTS = {"status", "tasks", "report", "map", "prompt", "show", "trace", "lsp", "ast", "verify"}
|
|
37
86
|
_CLI_FORBIDDEN_FLAGS = {"--project-root", "--github", "--github-pr-comment", "--gitlab-pr-comment"}
|
|
38
87
|
_CLI_TIMEOUT_SECONDS = 120
|
|
39
88
|
_CLI_OUTPUT_LIMIT = 20_000
|
|
89
|
+
# Allowed values for devcouncil_record_command.status (validated, not free-text).
|
|
90
|
+
_RECORD_COMMAND_STATUSES = {"started", "finished", "failed", "blocked"}
|
|
40
91
|
|
|
41
92
|
|
|
42
93
|
def _forbidden_cli_flags(args: list[str]) -> list[str]:
|
|
@@ -63,6 +114,239 @@ def _json_text(payload: dict[str, object]) -> list[TextContent]:
|
|
|
63
114
|
return [TextContent(type="text", text=json.dumps(payload, indent=2))]
|
|
64
115
|
|
|
65
116
|
|
|
117
|
+
def _is_git_repo(root: Path) -> bool:
|
|
118
|
+
try:
|
|
119
|
+
result = subprocess.run(
|
|
120
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
121
|
+
cwd=root, capture_output=True, text=True,
|
|
122
|
+
)
|
|
123
|
+
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
124
|
+
except Exception:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _read_log_file(path: str | None) -> str:
|
|
129
|
+
"""Best-effort read of a persisted stdout/stderr log; tolerate a missing file."""
|
|
130
|
+
if not path:
|
|
131
|
+
return ""
|
|
132
|
+
try:
|
|
133
|
+
return Path(path).read_text(encoding="utf-8", errors="replace")
|
|
134
|
+
except OSError:
|
|
135
|
+
return ""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def _git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, object]:
|
|
139
|
+
"""Compute a (optionally path-scoped, optionally staged) git diff.
|
|
140
|
+
|
|
141
|
+
Returns {ok, files:[{path,status,additions,deletions}], unified_diff (truncated),
|
|
142
|
+
truncated}. Never raises — a git failure is reported as an empty diff with the
|
|
143
|
+
stderr in an error field so the agent can act on it."""
|
|
144
|
+
diff_args = ["git", "diff"]
|
|
145
|
+
numstat_args = ["git", "diff", "--numstat"]
|
|
146
|
+
namestatus_args = ["git", "diff", "--name-status"]
|
|
147
|
+
if staged:
|
|
148
|
+
for args in (diff_args, numstat_args, namestatus_args):
|
|
149
|
+
args.append("--cached")
|
|
150
|
+
if paths:
|
|
151
|
+
for args in (diff_args, numstat_args, namestatus_args):
|
|
152
|
+
args.append("--")
|
|
153
|
+
args.extend(paths)
|
|
154
|
+
|
|
155
|
+
def _run(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
156
|
+
return subprocess.run(
|
|
157
|
+
args, cwd=root, capture_output=True, text=True,
|
|
158
|
+
encoding="utf-8", errors="replace", timeout=_CLI_TIMEOUT_SECONDS,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
try:
|
|
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
|
+
)
|
|
168
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
169
|
+
return {"ok": False, "files": [], "unified_diff": "", "truncated": False, "error": str(exc)}
|
|
170
|
+
|
|
171
|
+
status_by_path: dict[str, str] = {}
|
|
172
|
+
for line in namestatus_proc.stdout.splitlines():
|
|
173
|
+
parts = line.split("\t")
|
|
174
|
+
if len(parts) >= 2:
|
|
175
|
+
status_by_path[parts[-1].replace("\\", "/")] = parts[0]
|
|
176
|
+
|
|
177
|
+
files: list[dict[str, object]] = []
|
|
178
|
+
for line in numstat_proc.stdout.splitlines():
|
|
179
|
+
parts = line.split("\t")
|
|
180
|
+
if len(parts) < 3:
|
|
181
|
+
continue
|
|
182
|
+
added_str, deleted_str, file_path = parts[0], parts[1], parts[-1]
|
|
183
|
+
file_path = file_path.replace("\\", "/")
|
|
184
|
+
files.append({
|
|
185
|
+
"path": file_path,
|
|
186
|
+
"status": status_by_path.get(file_path, "M"),
|
|
187
|
+
"additions": int(added_str) if added_str.isdigit() else 0,
|
|
188
|
+
"deletions": int(deleted_str) if deleted_str.isdigit() else 0,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
unified_diff, truncated = _truncate_text(diff_proc.stdout)
|
|
192
|
+
return {"ok": True, "files": files, "unified_diff": unified_diff, "truncated": truncated, "staged": staged}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _within_root(root: Path, rel_or_abs: str) -> Path | None:
|
|
196
|
+
"""Resolve a path against the project root and confirm it stays inside it.
|
|
197
|
+
|
|
198
|
+
Returns the absolute resolved path, or None when the path escapes the project
|
|
199
|
+
(a containment violation — a write tool must refuse it)."""
|
|
200
|
+
raw = rel_or_abs.strip().strip('"').replace("\\", "/")
|
|
201
|
+
try:
|
|
202
|
+
candidate = Path(raw)
|
|
203
|
+
resolved = candidate.resolve() if candidate.is_absolute() else (root / raw).resolve()
|
|
204
|
+
resolved.relative_to(root.resolve())
|
|
205
|
+
return resolved
|
|
206
|
+
except (OSError, ValueError):
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _diff_target_paths(unified_diff: str) -> list[str]:
|
|
211
|
+
"""Extract EVERY repo-relative file a unified diff touches — both sides.
|
|
212
|
+
|
|
213
|
+
Captures pre- and post-image paths from ``---``/``+++`` hunk headers AND the
|
|
214
|
+
``rename from/to`` / ``copy from/to`` lines (a pure rename has no hunk headers, so
|
|
215
|
+
its source would otherwise escape the policy check — letting a protected file be
|
|
216
|
+
moved out of scope). Handles paths with spaces and git's C-quoting. Every target is
|
|
217
|
+
then policy-checked before the patch is applied."""
|
|
218
|
+
targets: list[str] = []
|
|
219
|
+
seen: set[str] = set()
|
|
220
|
+
|
|
221
|
+
def _clean(token: str) -> str | None:
|
|
222
|
+
token = token.strip()
|
|
223
|
+
if len(token) >= 2 and token.startswith('"') and token.endswith('"'):
|
|
224
|
+
try:
|
|
225
|
+
token = token[1:-1].encode("utf-8").decode("unicode_escape")
|
|
226
|
+
except Exception:
|
|
227
|
+
token = token[1:-1]
|
|
228
|
+
if not token or token == "/dev/null":
|
|
229
|
+
return None
|
|
230
|
+
if token[:2] in ("a/", "b/"):
|
|
231
|
+
token = token[2:]
|
|
232
|
+
return token or None
|
|
233
|
+
|
|
234
|
+
def _add(path: str | None) -> None:
|
|
235
|
+
if path and path not in seen:
|
|
236
|
+
seen.add(path)
|
|
237
|
+
targets.append(path)
|
|
238
|
+
|
|
239
|
+
for line in unified_diff.splitlines():
|
|
240
|
+
if line.startswith("--- ") or line.startswith("+++ "):
|
|
241
|
+
_add(_clean(line[4:]))
|
|
242
|
+
elif line.startswith(("rename from ", "rename to ", "copy from ", "copy to ")):
|
|
243
|
+
_add(_clean(line.split(" ", 2)[2]))
|
|
244
|
+
elif line.startswith("diff --git "):
|
|
245
|
+
# Fallback for metadata-only changes (e.g. mode change) that have no hunk or
|
|
246
|
+
# rename lines; best-effort split handles the common no-space case.
|
|
247
|
+
parts = line[len("diff --git "):].split()
|
|
248
|
+
if len(parts) == 2:
|
|
249
|
+
_add(_clean(parts[0]))
|
|
250
|
+
_add(_clean(parts[1]))
|
|
251
|
+
return targets
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _lease_ttl_seconds(root: Path) -> int:
|
|
255
|
+
"""Default MCP lease TTL from config (so a crashed agent's lease auto-expires)."""
|
|
256
|
+
try:
|
|
257
|
+
from devcouncil.app.config import load_config
|
|
258
|
+
|
|
259
|
+
return max(0, int(load_config(root).execution.lease_ttl_seconds))
|
|
260
|
+
except Exception:
|
|
261
|
+
return 1800
|
|
262
|
+
|
|
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
|
+
|
|
309
|
+
def _load_router(root: Path):
|
|
310
|
+
"""Build a ModelRouter from project config, or return None when no provider key
|
|
311
|
+
is configured. When present, the verifier runs DevCouncil's strong compiled
|
|
312
|
+
per-criterion acceptance checks; when None, it falls back to coarse mode (which
|
|
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):
|
|
335
|
+
try:
|
|
336
|
+
from devcouncil.app.config import load_config, get_api_key
|
|
337
|
+
from devcouncil.llm.provider import create_provider, validate_model_provider
|
|
338
|
+
from devcouncil.llm.router import ModelRouter
|
|
339
|
+
|
|
340
|
+
config = load_config(root)
|
|
341
|
+
validate_model_provider(config.models.provider)
|
|
342
|
+
api_key = get_api_key(config.models.provider, root)
|
|
343
|
+
provider = create_provider(config.models.provider, api_key, project_root=root, provider_prefs=config.provider)
|
|
344
|
+
role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
|
|
345
|
+
return ModelRouter(provider, role_config, project_root=root)
|
|
346
|
+
except Exception:
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
|
|
66
350
|
def _error_text(message: str, *, code: str = "error", **details: object) -> list[TextContent]:
|
|
67
351
|
return _json_text({"ok": False, "error": message, "code": code, **details})
|
|
68
352
|
|
|
@@ -85,6 +369,15 @@ def _optional_string_argument(arguments: dict, name: str) -> str | None:
|
|
|
85
369
|
return value if isinstance(value, str) else ""
|
|
86
370
|
|
|
87
371
|
|
|
372
|
+
def _optional_string_list_argument(arguments: dict, name: str) -> tuple[list[str], list[TextContent] | None]:
|
|
373
|
+
value = arguments.get(name)
|
|
374
|
+
if value is None:
|
|
375
|
+
return [], None
|
|
376
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
377
|
+
return [], _error_text(f"{name} must be a string array", code="invalid_arguments", argument=name)
|
|
378
|
+
return value, None
|
|
379
|
+
|
|
380
|
+
|
|
88
381
|
def _required_string_argument(arguments: dict, name: str) -> tuple[str | None, list[TextContent] | None]:
|
|
89
382
|
value = arguments.get(name)
|
|
90
383
|
if value is None or value == "":
|
|
@@ -137,6 +430,103 @@ def _project_root() -> Path:
|
|
|
137
430
|
return Path(configured).expanduser().resolve() if configured else Path(".")
|
|
138
431
|
|
|
139
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
|
+
|
|
478
|
+
def _is_secret_path(root: Path, rel_or_abs: str) -> bool:
|
|
479
|
+
"""True when a path matches a protected secret/credential glob.
|
|
480
|
+
|
|
481
|
+
Reuses the shared SECRET_PATH_PATTERNS (the single source of truth in the policy
|
|
482
|
+
engine) so read tools refuse exactly the same files the write gate refuses — an
|
|
483
|
+
MCP agent must never be able to exfiltrate a credential through a read tool."""
|
|
484
|
+
from devcouncil.execution.policy_engine import SECRET_PATH_PATTERNS
|
|
485
|
+
|
|
486
|
+
normalized = rel_or_abs.strip().strip('"').replace("\\", "/")
|
|
487
|
+
try:
|
|
488
|
+
candidate = Path(normalized)
|
|
489
|
+
if candidate.is_absolute():
|
|
490
|
+
resolved = candidate.resolve()
|
|
491
|
+
try:
|
|
492
|
+
normalized = resolved.relative_to(root.resolve()).as_posix()
|
|
493
|
+
except ValueError:
|
|
494
|
+
normalized = resolved.as_posix()
|
|
495
|
+
except OSError:
|
|
496
|
+
pass
|
|
497
|
+
import fnmatch as _fnmatch
|
|
498
|
+
|
|
499
|
+
return any(_fnmatch.fnmatch(normalized, pattern) for pattern in SECRET_PATH_PATTERNS)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _allowed_next_tools(status: str, has_blocking_gaps: bool) -> list[str]:
|
|
503
|
+
"""Compute the self-describing next-tool contract from task state.
|
|
504
|
+
|
|
505
|
+
Replaces a hardcoded list so an agent is steered by what the task actually
|
|
506
|
+
needs: a verified task only needs releasing; a blocked/running task gets the
|
|
507
|
+
full read->edit->test loop; a planned task should check out first."""
|
|
508
|
+
if status == "verified":
|
|
509
|
+
return ["devcouncil_release_task"]
|
|
510
|
+
if status == "done":
|
|
511
|
+
return []
|
|
512
|
+
if status in {"running", "blocked"} or has_blocking_gaps:
|
|
513
|
+
return [
|
|
514
|
+
"devcouncil_read_file",
|
|
515
|
+
"devcouncil_get_evidence",
|
|
516
|
+
"devcouncil_get_diff",
|
|
517
|
+
"devcouncil_run_command",
|
|
518
|
+
"devcouncil_apply_patch",
|
|
519
|
+
"devcouncil_write_file",
|
|
520
|
+
"devcouncil_verify_task",
|
|
521
|
+
]
|
|
522
|
+
# planned / ready and not yet leased: bootstrap by checking out.
|
|
523
|
+
return [
|
|
524
|
+
"devcouncil_checkout_task",
|
|
525
|
+
"devcouncil_read_file",
|
|
526
|
+
"devcouncil_get_diff",
|
|
527
|
+
]
|
|
528
|
+
|
|
529
|
+
|
|
140
530
|
@app.list_tools()
|
|
141
531
|
async def list_tools() -> list[Tool]:
|
|
142
532
|
return [
|
|
@@ -148,6 +538,11 @@ async def list_tools() -> list[Tool]:
|
|
|
148
538
|
"properties": {}
|
|
149
539
|
}
|
|
150
540
|
),
|
|
541
|
+
Tool(
|
|
542
|
+
name="devcouncil_integration_status",
|
|
543
|
+
description="Get read-only coding CLI integration status, capability rows, detected clients, and recommended executor.",
|
|
544
|
+
inputSchema={"type": "object", "properties": {}},
|
|
545
|
+
),
|
|
151
546
|
Tool(
|
|
152
547
|
name="devcouncil_report",
|
|
153
548
|
description="Get the full coverage report and a list of all requirements and blocking gaps.",
|
|
@@ -170,6 +565,50 @@ async def list_tools() -> list[Tool]:
|
|
|
170
565
|
"required": ["task_id"]
|
|
171
566
|
}
|
|
172
567
|
),
|
|
568
|
+
Tool(
|
|
569
|
+
name="devcouncil_get_gaps",
|
|
570
|
+
description=(
|
|
571
|
+
"Read the persisted verification gaps for a task WITHOUT re-running "
|
|
572
|
+
"verification. Cheap and idempotent — use it to resume after a "
|
|
573
|
+
"reconnect or to inspect outstanding work before deciding to repair."
|
|
574
|
+
),
|
|
575
|
+
inputSchema={
|
|
576
|
+
"type": "object",
|
|
577
|
+
"properties": {
|
|
578
|
+
"task_id": {"type": "string"},
|
|
579
|
+
"blocking_only": {"type": "boolean", "default": False},
|
|
580
|
+
},
|
|
581
|
+
"required": ["task_id"],
|
|
582
|
+
},
|
|
583
|
+
),
|
|
584
|
+
Tool(
|
|
585
|
+
name="devcouncil_get_next_actions",
|
|
586
|
+
description=(
|
|
587
|
+
"Get the typed, machine-routable next-actions contract for a task from "
|
|
588
|
+
"its persisted gaps, WITHOUT re-verifying. Returns blocking next_actions "
|
|
589
|
+
"plus advisory_actions and the tools allowed next."
|
|
590
|
+
),
|
|
591
|
+
inputSchema={
|
|
592
|
+
"type": "object",
|
|
593
|
+
"properties": {"task_id": {"type": "string"}},
|
|
594
|
+
"required": ["task_id"],
|
|
595
|
+
},
|
|
596
|
+
),
|
|
597
|
+
Tool(
|
|
598
|
+
name="devcouncil_get_task_provenance",
|
|
599
|
+
description=(
|
|
600
|
+
"Inspect the recorded audit trail for a task: gated file changes "
|
|
601
|
+
"(write_file/apply_patch and hook events), verification runs, diff-coverage "
|
|
602
|
+
"evidence (was the changed code actually exercised), and the latest "
|
|
603
|
+
"correction manifest. Read-only — lets a developer or agent trust what "
|
|
604
|
+
"actually happened on disk."
|
|
605
|
+
),
|
|
606
|
+
inputSchema={
|
|
607
|
+
"type": "object",
|
|
608
|
+
"properties": {"task_id": {"type": "string"}},
|
|
609
|
+
"required": ["task_id"],
|
|
610
|
+
},
|
|
611
|
+
),
|
|
173
612
|
Tool(
|
|
174
613
|
name="devcouncil_live_review",
|
|
175
614
|
description="Get live coding-agent review status, pending signals, critique-card counts, and blockers.",
|
|
@@ -245,8 +684,15 @@ async def list_tools() -> list[Tool]:
|
|
|
245
684
|
),
|
|
246
685
|
Tool(
|
|
247
686
|
name="devcouncil_list_tasks",
|
|
248
|
-
description="List DevCouncil tasks with status and requirement mappings.",
|
|
249
|
-
inputSchema={
|
|
687
|
+
description="List DevCouncil tasks with status and requirement mappings. Supports a status filter and limit/offset paging so large projects don't blow the agent's context.",
|
|
688
|
+
inputSchema={
|
|
689
|
+
"type": "object",
|
|
690
|
+
"properties": {
|
|
691
|
+
"status": {"type": "string", "description": "Optional status filter (e.g. planned, running, blocked, verified, done)."},
|
|
692
|
+
"limit": {"type": "integer", "description": "Max tasks to return (default 100, max 500)."},
|
|
693
|
+
"offset": {"type": "integer", "description": "Number of tasks to skip (default 0)."},
|
|
694
|
+
},
|
|
695
|
+
},
|
|
250
696
|
),
|
|
251
697
|
Tool(
|
|
252
698
|
name="devcouncil_get_prompt",
|
|
@@ -339,16 +785,600 @@ async def list_tools() -> list[Tool]:
|
|
|
339
785
|
"required": ["task_id"],
|
|
340
786
|
},
|
|
341
787
|
),
|
|
788
|
+
Tool(
|
|
789
|
+
name="devcouncil_checkout_task",
|
|
790
|
+
description="Acquire a task lease and return scope for MCP write tools.",
|
|
791
|
+
inputSchema={
|
|
792
|
+
"type": "object",
|
|
793
|
+
"properties": {
|
|
794
|
+
"task_id": {"type": "string"},
|
|
795
|
+
"client_id": {"type": "string"},
|
|
796
|
+
"agent": {"type": "string"},
|
|
797
|
+
"force": {"type": "boolean", "default": False},
|
|
798
|
+
},
|
|
799
|
+
"required": ["task_id", "client_id"],
|
|
800
|
+
},
|
|
801
|
+
),
|
|
802
|
+
Tool(
|
|
803
|
+
name="devcouncil_release_task",
|
|
804
|
+
description="Release a task lease using its token.",
|
|
805
|
+
inputSchema={
|
|
806
|
+
"type": "object",
|
|
807
|
+
"properties": {
|
|
808
|
+
"task_id": {"type": "string"},
|
|
809
|
+
"lease_token": {"type": "string"},
|
|
810
|
+
},
|
|
811
|
+
"required": ["task_id", "lease_token"],
|
|
812
|
+
},
|
|
813
|
+
),
|
|
814
|
+
Tool(
|
|
815
|
+
name="devcouncil_renew_lease",
|
|
816
|
+
description=(
|
|
817
|
+
"Extend a held task lease's TTL so a long-running agent does not lose it "
|
|
818
|
+
"to expiry. Returns the new expires_at."
|
|
819
|
+
),
|
|
820
|
+
inputSchema={
|
|
821
|
+
"type": "object",
|
|
822
|
+
"properties": {
|
|
823
|
+
"task_id": {"type": "string"},
|
|
824
|
+
"lease_token": {"type": "string"},
|
|
825
|
+
"ttl_seconds": {"type": "integer"},
|
|
826
|
+
},
|
|
827
|
+
"required": ["task_id", "lease_token"],
|
|
828
|
+
},
|
|
829
|
+
),
|
|
830
|
+
Tool(
|
|
831
|
+
name="devcouncil_list_leases",
|
|
832
|
+
description=(
|
|
833
|
+
"List task leases for fleet supervision — task_id, owner, agent, "
|
|
834
|
+
"expires_at, and whether each is expired. Defaults to active leases."
|
|
835
|
+
),
|
|
836
|
+
inputSchema={
|
|
837
|
+
"type": "object",
|
|
838
|
+
"properties": {"active_only": {"type": "boolean", "default": True}},
|
|
839
|
+
},
|
|
840
|
+
),
|
|
841
|
+
Tool(
|
|
842
|
+
name="devcouncil_update_task_scope",
|
|
843
|
+
description="Append unique expected tests or allowed commands for a leased task.",
|
|
844
|
+
inputSchema={
|
|
845
|
+
"type": "object",
|
|
846
|
+
"properties": {
|
|
847
|
+
"task_id": {"type": "string"},
|
|
848
|
+
"lease_token": {"type": "string"},
|
|
849
|
+
"expected_tests": {"type": "array", "items": {"type": "string"}},
|
|
850
|
+
"allowed_commands": {"type": "array", "items": {"type": "string"}},
|
|
851
|
+
},
|
|
852
|
+
"required": ["task_id", "lease_token"],
|
|
853
|
+
},
|
|
854
|
+
),
|
|
855
|
+
Tool(
|
|
856
|
+
name="devcouncil_append_evidence",
|
|
857
|
+
description="Append command evidence for a leased task.",
|
|
858
|
+
inputSchema={
|
|
859
|
+
"type": "object",
|
|
860
|
+
"properties": {
|
|
861
|
+
"task_id": {"type": "string"},
|
|
862
|
+
"lease_token": {"type": "string"},
|
|
863
|
+
"command": {"type": "string"},
|
|
864
|
+
"exit_code": {"type": "integer"},
|
|
865
|
+
"summary": {"type": "string"},
|
|
866
|
+
},
|
|
867
|
+
"required": ["task_id", "lease_token", "command", "exit_code", "summary"],
|
|
868
|
+
},
|
|
869
|
+
),
|
|
870
|
+
Tool(
|
|
871
|
+
name="devcouncil_record_command",
|
|
872
|
+
description="Record a shell command event for a leased task.",
|
|
873
|
+
inputSchema={
|
|
874
|
+
"type": "object",
|
|
875
|
+
"properties": {
|
|
876
|
+
"task_id": {"type": "string"},
|
|
877
|
+
"lease_token": {"type": "string"},
|
|
878
|
+
"command": {"type": "string"},
|
|
879
|
+
"status": {"type": "string", "enum": ["started", "finished", "failed", "blocked"]},
|
|
880
|
+
"exit_code": {"type": "integer"},
|
|
881
|
+
"reason": {"type": "string"},
|
|
882
|
+
},
|
|
883
|
+
"required": ["task_id", "lease_token", "command", "status"],
|
|
884
|
+
},
|
|
885
|
+
),
|
|
886
|
+
Tool(
|
|
887
|
+
name="devcouncil_write_file",
|
|
888
|
+
description=(
|
|
889
|
+
"Write a file for a leased task through DevCouncil's policy gate. The write "
|
|
890
|
+
"is checked against the task's scope BEFORE it lands (out-of-scope or "
|
|
891
|
+
"protected paths are rejected), applied atomically, and recorded as a "
|
|
892
|
+
"FileChangeEvent. Returns applied_files and rejected_files."
|
|
893
|
+
),
|
|
894
|
+
inputSchema={
|
|
895
|
+
"type": "object",
|
|
896
|
+
"properties": {
|
|
897
|
+
"task_id": {"type": "string"},
|
|
898
|
+
"lease_token": {"type": "string"},
|
|
899
|
+
"path": {"type": "string"},
|
|
900
|
+
"content": {"type": "string"},
|
|
901
|
+
},
|
|
902
|
+
"required": ["task_id", "lease_token", "path", "content"],
|
|
903
|
+
},
|
|
904
|
+
),
|
|
905
|
+
Tool(
|
|
906
|
+
name="devcouncil_apply_patch",
|
|
907
|
+
description=(
|
|
908
|
+
"Apply a unified diff for a leased task through DevCouncil's policy gate. "
|
|
909
|
+
"EVERY target file is policy-checked first; if any is out of scope the whole "
|
|
910
|
+
"patch is rejected (never partially applied). Applied atomically via git and "
|
|
911
|
+
"each file recorded as a FileChangeEvent. Returns applied_files/rejected_files."
|
|
912
|
+
),
|
|
913
|
+
inputSchema={
|
|
914
|
+
"type": "object",
|
|
915
|
+
"properties": {
|
|
916
|
+
"task_id": {"type": "string"},
|
|
917
|
+
"lease_token": {"type": "string"},
|
|
918
|
+
"unified_diff": {"type": "string"},
|
|
919
|
+
},
|
|
920
|
+
"required": ["task_id", "lease_token", "unified_diff"],
|
|
921
|
+
},
|
|
922
|
+
),
|
|
923
|
+
Tool(
|
|
924
|
+
name="devcouncil_verify_task",
|
|
925
|
+
description="Run verification for a leased task (local sandbox).",
|
|
926
|
+
inputSchema={
|
|
927
|
+
"type": "object",
|
|
928
|
+
"properties": {
|
|
929
|
+
"task_id": {"type": "string"},
|
|
930
|
+
"lease_token": {"type": "string"},
|
|
931
|
+
"sandbox": {"type": "string", "enum": ["local"], "default": "local", "description": "Only 'local' is supported in this build."},
|
|
932
|
+
},
|
|
933
|
+
"required": ["task_id", "lease_token"],
|
|
934
|
+
},
|
|
935
|
+
),
|
|
936
|
+
Tool(
|
|
937
|
+
name="devcouncil_handoff_agent",
|
|
938
|
+
description="Hand off a task between coding CLI agents.",
|
|
939
|
+
inputSchema={
|
|
940
|
+
"type": "object",
|
|
941
|
+
"properties": {
|
|
942
|
+
"task_id": {"type": "string"},
|
|
943
|
+
"lease_token": {"type": "string"},
|
|
944
|
+
"from_agent": {"type": "string"},
|
|
945
|
+
"to_agent": {"type": "string"},
|
|
946
|
+
"instruction": {"type": "string"},
|
|
947
|
+
},
|
|
948
|
+
"required": ["task_id", "lease_token", "from_agent", "to_agent"],
|
|
949
|
+
},
|
|
950
|
+
),
|
|
951
|
+
Tool(
|
|
952
|
+
name="devcouncil_read_file",
|
|
953
|
+
description=(
|
|
954
|
+
"Read a repository file (read-only, no lease required) so an MCP-only "
|
|
955
|
+
"agent can inspect content before constructing a diff or overwriting it. "
|
|
956
|
+
"Containment-checked against the project root and refuses secret/credential "
|
|
957
|
+
"paths. Supports offset/limit or line_range windowing. Returns content "
|
|
958
|
+
"(truncated), sha256, and line_count."
|
|
959
|
+
),
|
|
960
|
+
inputSchema={
|
|
961
|
+
"type": "object",
|
|
962
|
+
"properties": {
|
|
963
|
+
"path": {"type": "string", "description": "Repository-relative or absolute path inside the project."},
|
|
964
|
+
"offset": {"type": "integer", "minimum": 0, "description": "0-based line offset to start from."},
|
|
965
|
+
"limit": {"type": "integer", "minimum": 1, "description": "Max number of lines to return."},
|
|
966
|
+
"line_range": {
|
|
967
|
+
"type": "string",
|
|
968
|
+
"description": "Inclusive 1-based line range like '10-40' (overrides offset/limit).",
|
|
969
|
+
},
|
|
970
|
+
},
|
|
971
|
+
"required": ["path"],
|
|
972
|
+
},
|
|
973
|
+
),
|
|
974
|
+
Tool(
|
|
975
|
+
name="devcouncil_get_diff",
|
|
976
|
+
description=(
|
|
977
|
+
"Return the working-tree diff for the project (requires a git repo). When "
|
|
978
|
+
"task_id is given the diff is scoped to that task's planned/changed files. "
|
|
979
|
+
"Set staged=true to include the staged (git diff --cached) changes. Returns "
|
|
980
|
+
"per-file status with additions/deletions and the truncated unified diff."
|
|
981
|
+
),
|
|
982
|
+
inputSchema={
|
|
983
|
+
"type": "object",
|
|
984
|
+
"properties": {
|
|
985
|
+
"task_id": {"type": "string", "description": "Optional task to scope the diff to its files."},
|
|
986
|
+
"paths": {
|
|
987
|
+
"type": "array",
|
|
988
|
+
"items": {"type": "string"},
|
|
989
|
+
"description": "Optional explicit repo-relative paths to scope the diff to.",
|
|
990
|
+
},
|
|
991
|
+
"staged": {"type": "boolean", "default": False, "description": "Include staged changes."},
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
),
|
|
995
|
+
Tool(
|
|
996
|
+
name="devcouncil_get_evidence",
|
|
997
|
+
description=(
|
|
998
|
+
"Read persisted CommandResult evidence for a task and inline the truncated "
|
|
999
|
+
"stdout/stderr from the stored log files (best-effort; tolerates missing "
|
|
1000
|
+
"files). Pairs with verification to close the diagnose leg of the loop."
|
|
1001
|
+
),
|
|
1002
|
+
inputSchema={
|
|
1003
|
+
"type": "object",
|
|
1004
|
+
"properties": {
|
|
1005
|
+
"task_id": {"type": "string"},
|
|
1006
|
+
"command": {"type": "string", "description": "Optional substring filter on the recorded command."},
|
|
1007
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
|
|
1008
|
+
},
|
|
1009
|
+
"required": ["task_id"],
|
|
1010
|
+
},
|
|
1011
|
+
),
|
|
1012
|
+
Tool(
|
|
1013
|
+
name="devcouncil_run_command",
|
|
1014
|
+
description=(
|
|
1015
|
+
"Run a command for a leased task through DevCouncil's allowlist gate. The "
|
|
1016
|
+
"command must pass the task's allowed_commands policy (same gate as the "
|
|
1017
|
+
"hooks); otherwise it is refused and nothing runs. Executed with a clean "
|
|
1018
|
+
"subprocess env and a timeout, recorded as a ShellCommandEvent. Returns "
|
|
1019
|
+
"exit_code and truncated stdout/stderr."
|
|
1020
|
+
),
|
|
1021
|
+
inputSchema={
|
|
1022
|
+
"type": "object",
|
|
1023
|
+
"properties": {
|
|
1024
|
+
"task_id": {"type": "string"},
|
|
1025
|
+
"lease_token": {"type": "string"},
|
|
1026
|
+
"command": {"type": "string"},
|
|
1027
|
+
},
|
|
1028
|
+
"required": ["task_id", "lease_token", "command"],
|
|
1029
|
+
},
|
|
1030
|
+
),
|
|
1031
|
+
Tool(
|
|
1032
|
+
name="devcouncil_list_agent_runs",
|
|
1033
|
+
description=(
|
|
1034
|
+
"List recorded coding-agent runs (from .devcouncil/runs/*/agent-run.json), "
|
|
1035
|
+
"newest first. Each entry includes run_id, task, agent, profile, status, "
|
|
1036
|
+
"started time, and an orphaned flag for runs still marked running whose "
|
|
1037
|
+
"manifest has gone stale (executor likely crashed). Read-only."
|
|
1038
|
+
),
|
|
1039
|
+
inputSchema={
|
|
1040
|
+
"type": "object",
|
|
1041
|
+
"properties": {
|
|
1042
|
+
"status": {"type": "string", "description": "Optional status filter (e.g. running, finished, failed, timeout)."},
|
|
1043
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 20},
|
|
1044
|
+
},
|
|
1045
|
+
},
|
|
1046
|
+
),
|
|
1047
|
+
Tool(
|
|
1048
|
+
name="devcouncil_get_run",
|
|
1049
|
+
description=(
|
|
1050
|
+
"Get the full manifest for a single coding-agent run plus a redacted "
|
|
1051
|
+
"transcript tail when a transcript/log file exists in the run directory. "
|
|
1052
|
+
"Includes the resolved CLI invocation and an orphaned flag. Read-only."
|
|
1053
|
+
),
|
|
1054
|
+
inputSchema={
|
|
1055
|
+
"type": "object",
|
|
1056
|
+
"properties": {
|
|
1057
|
+
"run_id": {"type": "string", "description": "The run id to inspect."},
|
|
1058
|
+
},
|
|
1059
|
+
"required": ["run_id"],
|
|
1060
|
+
},
|
|
1061
|
+
),
|
|
1062
|
+
Tool(
|
|
1063
|
+
name="devcouncil_next_task",
|
|
1064
|
+
description=(
|
|
1065
|
+
"Return the highest-priority task that is unblocked (its depends_on are "
|
|
1066
|
+
"satisfied) and has no active lease, so an autonomous agent can bootstrap "
|
|
1067
|
+
"deterministically instead of racing list_tasks. Includes a blocking-gap "
|
|
1068
|
+
"summary and a ready_to_checkout flag."
|
|
1069
|
+
),
|
|
1070
|
+
inputSchema={
|
|
1071
|
+
"type": "object",
|
|
1072
|
+
"properties": {
|
|
1073
|
+
"client_id": {"type": "string", "description": "Optional client id (informational)."},
|
|
1074
|
+
"status": {"type": "string", "description": "Optional status filter (default planned/ready)."},
|
|
1075
|
+
},
|
|
1076
|
+
},
|
|
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
|
+
),
|
|
1096
|
+
]
|
|
1097
|
+
|
|
1098
|
+
@app.list_resources()
|
|
1099
|
+
async def list_resources() -> list[Resource]:
|
|
1100
|
+
"""Expose the DevCouncil corpus as browsable/subscribable MCP resources, so a host
|
|
1101
|
+
can read the report, task graph, gaps, and live-review state without a tool call."""
|
|
1102
|
+
root = _project_root()
|
|
1103
|
+
resources: list[Resource] = [
|
|
1104
|
+
Resource(uri=AnyUrl("devcouncil://report"), name="DevCouncil report",
|
|
1105
|
+
description="Coverage report, requirement/task mapping, and blocking gaps.",
|
|
1106
|
+
mimeType="text/markdown"),
|
|
1107
|
+
Resource(uri=AnyUrl("devcouncil://tasks"), name="Tasks",
|
|
1108
|
+
description="All planned tasks with scope and status.", mimeType="application/json"),
|
|
1109
|
+
Resource(uri=AnyUrl("devcouncil://gaps"), name="Gaps",
|
|
1110
|
+
description="All open verification gaps.", mimeType="application/json"),
|
|
1111
|
+
Resource(uri=AnyUrl("devcouncil://cards"), name="Live review",
|
|
1112
|
+
description="Live-review summary: cards, signals, and blockers.",
|
|
1113
|
+
mimeType="application/json"),
|
|
1114
|
+
]
|
|
1115
|
+
db = get_db(root)
|
|
1116
|
+
if db:
|
|
1117
|
+
with db.get_session() as session:
|
|
1118
|
+
for task in TaskRepository(session).get_all():
|
|
1119
|
+
resources.append(Resource(
|
|
1120
|
+
uri=AnyUrl(f"devcouncil://task/{task.id}"),
|
|
1121
|
+
name=f"Task {task.id}: {task.title}",
|
|
1122
|
+
description=f"Scope, status, and gaps for {task.id}.",
|
|
1123
|
+
mimeType="application/json",
|
|
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
|
+
))
|
|
1142
|
+
return resources
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
@app.read_resource()
|
|
1146
|
+
async def read_resource(uri: AnyUrl) -> str:
|
|
1147
|
+
root = _project_root()
|
|
1148
|
+
db = get_db(root)
|
|
1149
|
+
key = str(uri).rstrip("/")
|
|
1150
|
+
|
|
1151
|
+
if key == "devcouncil://report":
|
|
1152
|
+
if not db:
|
|
1153
|
+
return "DevCouncil is not initialized in this directory."
|
|
1154
|
+
with db.get_session() as session:
|
|
1155
|
+
graph = ArtifactGraphRepository(session).load_graph()
|
|
1156
|
+
return ReportBuilder.build_markdown(graph, live_review=live_review_summary(root))
|
|
1157
|
+
if key == "devcouncil://tasks":
|
|
1158
|
+
if not db:
|
|
1159
|
+
return json.dumps({"tasks": []})
|
|
1160
|
+
with db.get_session() as session:
|
|
1161
|
+
tasks = [t.model_dump() for t in TaskRepository(session).get_all()]
|
|
1162
|
+
return json.dumps({"tasks": tasks}, indent=2)
|
|
1163
|
+
if key == "devcouncil://gaps":
|
|
1164
|
+
if not db:
|
|
1165
|
+
return json.dumps({"gaps": []})
|
|
1166
|
+
with db.get_session() as session:
|
|
1167
|
+
gaps = [g.model_dump() for g in GapRepository(session).get_all()]
|
|
1168
|
+
return json.dumps({"gaps": gaps}, indent=2)
|
|
1169
|
+
if key == "devcouncil://cards":
|
|
1170
|
+
return json.dumps(live_review_summary(root), indent=2)
|
|
1171
|
+
if key.startswith("devcouncil://task/"):
|
|
1172
|
+
task_id = key.rsplit("/", 1)[-1]
|
|
1173
|
+
if not db:
|
|
1174
|
+
return json.dumps({"ok": False, "error": "not initialized"})
|
|
1175
|
+
with db.get_session() as session:
|
|
1176
|
+
task = TaskRepository(session).get_by_id(task_id)
|
|
1177
|
+
if not task:
|
|
1178
|
+
return json.dumps({"ok": False, "error": f"Task {task_id} not found."})
|
|
1179
|
+
gaps = [g.model_dump() for g in GapRepository(session).get_for_task(task_id)]
|
|
1180
|
+
return json.dumps({"task": task.model_dump(), "gaps": gaps}, indent=2)
|
|
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
|
+
|
|
1207
|
+
raise ValueError(f"Unknown resource: {uri}")
|
|
1208
|
+
|
|
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
|
|
342
1352
|
]
|
|
343
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
|
+
|
|
344
1369
|
@app.call_tool()
|
|
345
1370
|
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
346
1371
|
arguments = _normalize_arguments(arguments)
|
|
1372
|
+
logger.info("MCP call_tool: %s args=%s", name, sorted(arguments) if isinstance(arguments, dict) else arguments)
|
|
347
1373
|
root = _project_root()
|
|
348
1374
|
db = get_db(root)
|
|
349
1375
|
if name in _DB_REQUIRED_TOOLS and not db:
|
|
1376
|
+
logger.warning("MCP tool %s rejected: project not initialized at %s", name, root)
|
|
350
1377
|
return _error_text("DevCouncil not initialized in this directory.", code="not_initialized")
|
|
351
1378
|
|
|
1379
|
+
if name == "devcouncil_integration_status":
|
|
1380
|
+
return _json_text(integration_status_summary(root))
|
|
1381
|
+
|
|
352
1382
|
if name == "devcouncil_status":
|
|
353
1383
|
assert db is not None
|
|
354
1384
|
with db.get_session() as session:
|
|
@@ -397,15 +1427,15 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
397
1427
|
return _error_text(f"{arg_name} must be a string", code="invalid_arguments", argument=arg_name)
|
|
398
1428
|
|
|
399
1429
|
limit = _int_argument(arguments, "limit", 20, minimum=1, maximum=200)
|
|
400
|
-
filtered,
|
|
1430
|
+
filtered, filter_error, argument = filter_cards(
|
|
401
1431
|
load_cards(root),
|
|
402
1432
|
task_id=task_id,
|
|
403
1433
|
status=status,
|
|
404
1434
|
verdict=verdict,
|
|
405
1435
|
client=client,
|
|
406
1436
|
)
|
|
407
|
-
if
|
|
408
|
-
return _error_text(
|
|
1437
|
+
if filter_error:
|
|
1438
|
+
return _error_text(filter_error, code="invalid_arguments", argument=argument)
|
|
409
1439
|
|
|
410
1440
|
total = len(filtered)
|
|
411
1441
|
return [TextContent(
|
|
@@ -424,9 +1454,10 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
424
1454
|
)]
|
|
425
1455
|
|
|
426
1456
|
elif name == "devcouncil_live_repair_prompt":
|
|
427
|
-
card_id,
|
|
428
|
-
if
|
|
429
|
-
return
|
|
1457
|
+
card_id, arg_error = _required_string_argument(arguments, "card_id")
|
|
1458
|
+
if arg_error:
|
|
1459
|
+
return arg_error
|
|
1460
|
+
assert card_id is not None
|
|
430
1461
|
card = get_card(root, card_id)
|
|
431
1462
|
if not card:
|
|
432
1463
|
return _error_text(f"Critique card {card_id} not found.", code="not_found", card_id=card_id)
|
|
@@ -448,21 +1479,22 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
448
1479
|
for item in summary["blocking_cards"]
|
|
449
1480
|
if isinstance(item.get("id"), str)
|
|
450
1481
|
]
|
|
451
|
-
|
|
1482
|
+
resolved_cards = [card for card in cards if card is not None]
|
|
452
1483
|
return [TextContent(
|
|
453
1484
|
type="text",
|
|
454
1485
|
text=json.dumps({
|
|
455
1486
|
"scope_task_id": summary["scope_task_id"],
|
|
456
|
-
"cards": [card.model_dump() for card in
|
|
457
|
-
"prompt": build_bulk_live_repair_prompt(root,
|
|
1487
|
+
"cards": [card.model_dump() for card in resolved_cards],
|
|
1488
|
+
"prompt": build_bulk_live_repair_prompt(root, resolved_cards),
|
|
458
1489
|
}, indent=2),
|
|
459
1490
|
)]
|
|
460
1491
|
|
|
461
1492
|
elif name == "devcouncil_get_task":
|
|
462
1493
|
assert db is not None
|
|
463
|
-
task_id,
|
|
464
|
-
if
|
|
465
|
-
return
|
|
1494
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
1495
|
+
if arg_error:
|
|
1496
|
+
return arg_error
|
|
1497
|
+
assert task_id is not None
|
|
466
1498
|
|
|
467
1499
|
with db.get_session() as session:
|
|
468
1500
|
task_repo = TaskRepository(session)
|
|
@@ -472,19 +1504,96 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
472
1504
|
|
|
473
1505
|
return [TextContent(type="text", text=task.model_dump_json(indent=2))]
|
|
474
1506
|
|
|
475
|
-
elif name == "
|
|
1507
|
+
elif name == "devcouncil_get_gaps":
|
|
476
1508
|
assert db is not None
|
|
1509
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
1510
|
+
if arg_error:
|
|
1511
|
+
return arg_error
|
|
1512
|
+
assert task_id is not None # _required_string_argument returns a value when arg_error is None
|
|
1513
|
+
blocking_only = bool(arguments.get("blocking_only", False))
|
|
477
1514
|
with db.get_session() as session:
|
|
478
|
-
|
|
479
|
-
|
|
1515
|
+
gaps = GapRepository(session).get_for_task(task_id)
|
|
1516
|
+
if blocking_only:
|
|
1517
|
+
gaps = [g for g in gaps if g.blocking]
|
|
1518
|
+
return _json_text({
|
|
1519
|
+
"ok": True,
|
|
1520
|
+
"task_id": task_id,
|
|
1521
|
+
"gaps": [g.model_dump() for g in gaps],
|
|
1522
|
+
"blocking_count": sum(1 for g in gaps if g.blocking),
|
|
1523
|
+
})
|
|
480
1524
|
|
|
481
|
-
|
|
1525
|
+
elif name == "devcouncil_get_next_actions":
|
|
1526
|
+
assert db is not None
|
|
1527
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
1528
|
+
if arg_error:
|
|
1529
|
+
return arg_error
|
|
1530
|
+
assert task_id is not None
|
|
1531
|
+
with db.get_session() as session:
|
|
1532
|
+
gaps = GapRepository(session).get_for_task(task_id)
|
|
1533
|
+
task = TaskRepository(session).get_by_id(task_id)
|
|
1534
|
+
blocking_actions, advisory_actions = split_next_actions(gaps)
|
|
1535
|
+
has_blocking = any(g.blocking for g in gaps)
|
|
1536
|
+
return _json_text({
|
|
1537
|
+
"ok": True,
|
|
1538
|
+
"task_id": task_id,
|
|
1539
|
+
"next_actions": [a.model_dump() for a in blocking_actions],
|
|
1540
|
+
"advisory_actions": [a.model_dump() for a in advisory_actions],
|
|
1541
|
+
# Self-describing loop: computed from the task's status + blocking gaps so
|
|
1542
|
+
# the agent is steered toward what this task actually needs next.
|
|
1543
|
+
"allowed_next_tools": _allowed_next_tools(task.status if task else "planned", has_blocking),
|
|
1544
|
+
})
|
|
1545
|
+
|
|
1546
|
+
elif name == "devcouncil_get_task_provenance":
|
|
1547
|
+
assert db is not None
|
|
1548
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
1549
|
+
if arg_error:
|
|
1550
|
+
return arg_error
|
|
1551
|
+
assert task_id is not None
|
|
1552
|
+
with db.get_session() as session:
|
|
1553
|
+
file_changes = [r.model_dump() for r in FileChangeRepository(session).list_for_task(task_id)]
|
|
1554
|
+
verification_runs = [r.model_dump() for r in VerificationRunRepository(session).list_for_task(task_id)]
|
|
1555
|
+
coverage = [
|
|
1556
|
+
ev.model_dump()
|
|
1557
|
+
for ev in EvidenceRepository(session).get_all()
|
|
1558
|
+
if isinstance(ev, DiffCoverageEvidence) and ev.task_id == task_id
|
|
1559
|
+
]
|
|
1560
|
+
correction_manifest = CorrectionManifestRepository(session).latest_for_task(task_id)
|
|
1561
|
+
return _json_text({
|
|
1562
|
+
"ok": True,
|
|
1563
|
+
"task_id": task_id,
|
|
1564
|
+
"file_changes": file_changes,
|
|
1565
|
+
"verification_runs": verification_runs,
|
|
1566
|
+
"diff_coverage": coverage,
|
|
1567
|
+
"latest_correction_manifest": correction_manifest.model_dump() if correction_manifest else None,
|
|
1568
|
+
})
|
|
1569
|
+
|
|
1570
|
+
elif name == "devcouncil_list_tasks":
|
|
1571
|
+
assert db is not None
|
|
1572
|
+
status_filter = _optional_string_argument(arguments, "status")
|
|
1573
|
+
if status_filter == "":
|
|
1574
|
+
return _error_text("status must be a string", code="invalid_arguments", argument="status")
|
|
1575
|
+
limit = _int_argument(arguments, "limit", 100, minimum=1, maximum=500)
|
|
1576
|
+
offset = _int_argument(arguments, "offset", 0, minimum=0, maximum=1_000_000)
|
|
1577
|
+
with db.get_session() as session:
|
|
1578
|
+
all_tasks = TaskRepository(session).get_all()
|
|
1579
|
+
if status_filter:
|
|
1580
|
+
all_tasks = [t for t in all_tasks if t.status == status_filter]
|
|
1581
|
+
total = len(all_tasks)
|
|
1582
|
+
window = all_tasks[offset:offset + limit]
|
|
1583
|
+
return _json_text({
|
|
1584
|
+
"tasks": [task.model_dump() for task in window],
|
|
1585
|
+
"total": total,
|
|
1586
|
+
"offset": offset,
|
|
1587
|
+
"limit": limit,
|
|
1588
|
+
"returned": len(window),
|
|
1589
|
+
})
|
|
482
1590
|
|
|
483
1591
|
elif name == "devcouncil_get_prompt":
|
|
484
1592
|
assert db is not None
|
|
485
|
-
task_id,
|
|
486
|
-
if
|
|
487
|
-
return
|
|
1593
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
1594
|
+
if arg_error:
|
|
1595
|
+
return arg_error
|
|
1596
|
+
assert task_id is not None
|
|
488
1597
|
|
|
489
1598
|
with db.get_session() as session:
|
|
490
1599
|
task_repo = TaskRepository(session)
|
|
@@ -506,9 +1615,10 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
506
1615
|
|
|
507
1616
|
elif name == "devcouncil_policy_check_write":
|
|
508
1617
|
assert db is not None
|
|
509
|
-
path,
|
|
510
|
-
if
|
|
511
|
-
return
|
|
1618
|
+
path, arg_error = _required_string_argument(arguments, "path")
|
|
1619
|
+
if arg_error:
|
|
1620
|
+
return arg_error
|
|
1621
|
+
assert path is not None
|
|
512
1622
|
task_id = _optional_string_argument(arguments, "task_id")
|
|
513
1623
|
if task_id == "":
|
|
514
1624
|
return _error_text("task_id must be a string", code="invalid_arguments", argument="task_id")
|
|
@@ -532,11 +1642,11 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
532
1642
|
files = arguments.get("files", [])
|
|
533
1643
|
if not isinstance(files, list):
|
|
534
1644
|
files = []
|
|
535
|
-
context =
|
|
1645
|
+
context = _get_graph_adapter(root).get_context([file for file in files if isinstance(file, str)])
|
|
536
1646
|
return [TextContent(type="text", text=context.model_dump_json(indent=2))]
|
|
537
1647
|
|
|
538
1648
|
elif name == "devcouncil_lsp_status":
|
|
539
|
-
return [TextContent(type="text", text=
|
|
1649
|
+
return [TextContent(type="text", text=_get_lsp_inspector(root).summary_json())]
|
|
540
1650
|
|
|
541
1651
|
elif name == "devcouncil_ast_match":
|
|
542
1652
|
query = _optional_string_argument(arguments, "query")
|
|
@@ -546,7 +1656,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
546
1656
|
if value == "":
|
|
547
1657
|
return _error_text(f"{arg_name} must be a string", code="invalid_arguments", argument=arg_name)
|
|
548
1658
|
limit = _int_argument(arguments, "limit", 100, minimum=1, maximum=500)
|
|
549
|
-
matches =
|
|
1659
|
+
matches = _get_ast_matcher(root).match(
|
|
550
1660
|
query=query or "",
|
|
551
1661
|
language=language,
|
|
552
1662
|
kind=kind,
|
|
@@ -570,9 +1680,10 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
570
1680
|
|
|
571
1681
|
elif name == "devcouncil_prepare_execution":
|
|
572
1682
|
assert db is not None
|
|
573
|
-
task_id,
|
|
574
|
-
if
|
|
575
|
-
return
|
|
1683
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
1684
|
+
if arg_error:
|
|
1685
|
+
return arg_error
|
|
1686
|
+
assert task_id is not None
|
|
576
1687
|
with db.get_session() as session:
|
|
577
1688
|
task_repo = TaskRepository(session)
|
|
578
1689
|
req_repo = RequirementRepository(session)
|
|
@@ -588,6 +1699,807 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
588
1699
|
"expected_tests": task.expected_tests,
|
|
589
1700
|
}, indent=2))]
|
|
590
1701
|
|
|
1702
|
+
elif name == "devcouncil_checkout_task":
|
|
1703
|
+
assert db is not None
|
|
1704
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
1705
|
+
if arg_error:
|
|
1706
|
+
return arg_error
|
|
1707
|
+
client_id, arg_error = _required_string_argument(arguments, "client_id")
|
|
1708
|
+
if arg_error:
|
|
1709
|
+
return arg_error
|
|
1710
|
+
assert task_id is not None and client_id is not None
|
|
1711
|
+
agent = _optional_string_argument(arguments, "agent")
|
|
1712
|
+
if agent == "":
|
|
1713
|
+
return _error_text("agent must be a string", code="invalid_arguments", argument="agent")
|
|
1714
|
+
force_value = arguments.get("force", False)
|
|
1715
|
+
if not isinstance(force_value, bool):
|
|
1716
|
+
return _error_text("force must be a boolean", code="invalid_arguments", argument="force")
|
|
1717
|
+
force = force_value
|
|
1718
|
+
with db.get_session() as session:
|
|
1719
|
+
task_repo = TaskRepository(session)
|
|
1720
|
+
task = task_repo.get_by_id(task_id)
|
|
1721
|
+
if not task:
|
|
1722
|
+
return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
|
|
1723
|
+
lease_repo = TaskLeaseRepository(session)
|
|
1724
|
+
try:
|
|
1725
|
+
lease = lease_repo.acquire(
|
|
1726
|
+
task_id,
|
|
1727
|
+
owner=f"mcp:{client_id}",
|
|
1728
|
+
agent=agent,
|
|
1729
|
+
client_id=client_id,
|
|
1730
|
+
ttl_seconds=_lease_ttl_seconds(root),
|
|
1731
|
+
force=force,
|
|
1732
|
+
)
|
|
1733
|
+
except ValueError as exc:
|
|
1734
|
+
return _error_text(str(exc), code="lease_conflict", task_id=task_id)
|
|
1735
|
+
prompt = PromptBuilder(root).build_task_prompt(task, RequirementRepository(session).get_all())
|
|
1736
|
+
semantic = None
|
|
1737
|
+
semantic_path = root / ".devcouncil" / "semantic" / task_id / "before.json"
|
|
1738
|
+
if semantic_path.exists():
|
|
1739
|
+
semantic = json.loads(semantic_path.read_text(encoding="utf-8"))
|
|
1740
|
+
return _json_text({
|
|
1741
|
+
"ok": True,
|
|
1742
|
+
"lease_token": lease.lease_token,
|
|
1743
|
+
"task_id": task.id,
|
|
1744
|
+
"status": task.status,
|
|
1745
|
+
"expires_at": lease.expires_at,
|
|
1746
|
+
"prompt": prompt,
|
|
1747
|
+
"planned_files": [f.model_dump() for f in task.planned_files],
|
|
1748
|
+
"allowed_commands": task.allowed_commands,
|
|
1749
|
+
"expected_tests": task.expected_tests,
|
|
1750
|
+
"semantic_context": semantic,
|
|
1751
|
+
# The task is now leased and running-ready: surface the inner-loop tools.
|
|
1752
|
+
"allowed_next_tools": _allowed_next_tools(
|
|
1753
|
+
"running",
|
|
1754
|
+
bool(GapRepository(session).get_blocking_for_task(task_id)),
|
|
1755
|
+
),
|
|
1756
|
+
})
|
|
1757
|
+
|
|
1758
|
+
elif name == "devcouncil_release_task":
|
|
1759
|
+
assert db is not None
|
|
1760
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
1761
|
+
if arg_error:
|
|
1762
|
+
return arg_error
|
|
1763
|
+
lease_token, arg_error = _required_string_argument(arguments, "lease_token")
|
|
1764
|
+
if arg_error:
|
|
1765
|
+
return arg_error
|
|
1766
|
+
assert task_id is not None and lease_token is not None
|
|
1767
|
+
with db.get_session() as session:
|
|
1768
|
+
released = TaskLeaseRepository(session).release(task_id, lease_token)
|
|
1769
|
+
if not released:
|
|
1770
|
+
return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
|
|
1771
|
+
return _json_text({"ok": True, "task_id": task_id, "released": True})
|
|
1772
|
+
|
|
1773
|
+
elif name == "devcouncil_renew_lease":
|
|
1774
|
+
assert db is not None
|
|
1775
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
1776
|
+
if arg_error:
|
|
1777
|
+
return arg_error
|
|
1778
|
+
lease_token, arg_error = _required_string_argument(arguments, "lease_token")
|
|
1779
|
+
if arg_error:
|
|
1780
|
+
return arg_error
|
|
1781
|
+
assert task_id is not None and lease_token is not None
|
|
1782
|
+
ttl_value = arguments.get("ttl_seconds")
|
|
1783
|
+
if ttl_value is not None and (not isinstance(ttl_value, int) or isinstance(ttl_value, bool)):
|
|
1784
|
+
return _error_text("ttl_seconds must be an integer", code="invalid_arguments", argument="ttl_seconds")
|
|
1785
|
+
ttl_seconds = ttl_value if isinstance(ttl_value, int) and not isinstance(ttl_value, bool) else _lease_ttl_seconds(root)
|
|
1786
|
+
with db.get_session() as session:
|
|
1787
|
+
renewed_lease = TaskLeaseRepository(session).renew(task_id, lease_token, ttl_seconds)
|
|
1788
|
+
if renewed_lease is None:
|
|
1789
|
+
return _error_text("Invalid or expired lease.", code="invalid_lease", task_id=task_id)
|
|
1790
|
+
return _json_text({
|
|
1791
|
+
"ok": True,
|
|
1792
|
+
"task_id": task_id,
|
|
1793
|
+
"expires_at": renewed_lease.expires_at,
|
|
1794
|
+
"ttl_seconds": ttl_seconds,
|
|
1795
|
+
})
|
|
1796
|
+
|
|
1797
|
+
elif name == "devcouncil_list_leases":
|
|
1798
|
+
assert db is not None
|
|
1799
|
+
active_only = arguments.get("active_only", True)
|
|
1800
|
+
if not isinstance(active_only, bool):
|
|
1801
|
+
return _error_text("active_only must be a boolean", code="invalid_arguments", argument="active_only")
|
|
1802
|
+
with db.get_session() as session:
|
|
1803
|
+
pairs = TaskLeaseRepository(session).list_leases(active_only=active_only)
|
|
1804
|
+
leases = [
|
|
1805
|
+
{
|
|
1806
|
+
"task_id": lease.task_id,
|
|
1807
|
+
"owner": lease.owner,
|
|
1808
|
+
"agent": lease.agent,
|
|
1809
|
+
"status": lease.status,
|
|
1810
|
+
"expires_at": lease.expires_at,
|
|
1811
|
+
"expired": expired,
|
|
1812
|
+
}
|
|
1813
|
+
for lease, expired in pairs
|
|
1814
|
+
]
|
|
1815
|
+
return _json_text({"ok": True, "leases": leases, "count": len(leases)})
|
|
1816
|
+
|
|
1817
|
+
elif name == "devcouncil_update_task_scope":
|
|
1818
|
+
assert db is not None
|
|
1819
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
1820
|
+
if arg_error:
|
|
1821
|
+
return arg_error
|
|
1822
|
+
lease_token, arg_error = _required_string_argument(arguments, "lease_token")
|
|
1823
|
+
if arg_error:
|
|
1824
|
+
return arg_error
|
|
1825
|
+
assert task_id is not None and lease_token is not None
|
|
1826
|
+
expected_tests, arg_error = _optional_string_list_argument(arguments, "expected_tests")
|
|
1827
|
+
if arg_error:
|
|
1828
|
+
return arg_error
|
|
1829
|
+
allowed_commands, arg_error = _optional_string_list_argument(arguments, "allowed_commands")
|
|
1830
|
+
if arg_error:
|
|
1831
|
+
return arg_error
|
|
1832
|
+
with db.get_session() as session:
|
|
1833
|
+
lease_repo = TaskLeaseRepository(session)
|
|
1834
|
+
if not lease_repo.validate(task_id, lease_token):
|
|
1835
|
+
return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
|
|
1836
|
+
task_repo = TaskRepository(session)
|
|
1837
|
+
task = task_repo.get_by_id(task_id)
|
|
1838
|
+
if not task:
|
|
1839
|
+
return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
|
|
1840
|
+
for cmd in allowed_commands:
|
|
1841
|
+
if cmd not in task.allowed_commands:
|
|
1842
|
+
task.allowed_commands.append(cmd)
|
|
1843
|
+
for test in expected_tests:
|
|
1844
|
+
if test not in task.expected_tests:
|
|
1845
|
+
task.expected_tests.append(test)
|
|
1846
|
+
task_repo.save(task)
|
|
1847
|
+
return _json_text({
|
|
1848
|
+
"ok": True,
|
|
1849
|
+
"task_id": task_id,
|
|
1850
|
+
"allowed_commands": task.allowed_commands,
|
|
1851
|
+
"expected_tests": task.expected_tests,
|
|
1852
|
+
})
|
|
1853
|
+
|
|
1854
|
+
elif name == "devcouncil_append_evidence":
|
|
1855
|
+
assert db is not None
|
|
1856
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
1857
|
+
if arg_error:
|
|
1858
|
+
return arg_error
|
|
1859
|
+
lease_token, arg_error = _required_string_argument(arguments, "lease_token")
|
|
1860
|
+
if arg_error:
|
|
1861
|
+
return arg_error
|
|
1862
|
+
command, arg_error = _required_string_argument(arguments, "command")
|
|
1863
|
+
if arg_error:
|
|
1864
|
+
return arg_error
|
|
1865
|
+
summary_text, arg_error = _required_string_argument(arguments, "summary")
|
|
1866
|
+
if arg_error:
|
|
1867
|
+
return arg_error
|
|
1868
|
+
assert task_id is not None and lease_token is not None
|
|
1869
|
+
exit_code = arguments.get("exit_code", 0)
|
|
1870
|
+
if not isinstance(exit_code, int) or isinstance(exit_code, bool):
|
|
1871
|
+
return _error_text("exit_code must be an integer", code="invalid_arguments")
|
|
1872
|
+
with db.get_session() as session:
|
|
1873
|
+
if not TaskLeaseRepository(session).validate(task_id, lease_token):
|
|
1874
|
+
return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
|
|
1875
|
+
EvidenceRepository(session).save_command_result(
|
|
1876
|
+
task_id,
|
|
1877
|
+
CommandResult(
|
|
1878
|
+
command=command or "",
|
|
1879
|
+
exit_code=exit_code,
|
|
1880
|
+
stdout_path="",
|
|
1881
|
+
stderr_path="",
|
|
1882
|
+
summary=summary_text or "",
|
|
1883
|
+
),
|
|
1884
|
+
)
|
|
1885
|
+
return _json_text({"ok": True, "task_id": task_id, "recorded": True})
|
|
1886
|
+
|
|
1887
|
+
elif name == "devcouncil_record_command":
|
|
1888
|
+
assert db is not None
|
|
1889
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
1890
|
+
if arg_error:
|
|
1891
|
+
return arg_error
|
|
1892
|
+
lease_token, arg_error = _required_string_argument(arguments, "lease_token")
|
|
1893
|
+
if arg_error:
|
|
1894
|
+
return arg_error
|
|
1895
|
+
command, arg_error = _required_string_argument(arguments, "command")
|
|
1896
|
+
if arg_error:
|
|
1897
|
+
return arg_error
|
|
1898
|
+
status, arg_error = _required_string_argument(arguments, "status")
|
|
1899
|
+
if arg_error:
|
|
1900
|
+
return arg_error
|
|
1901
|
+
assert task_id is not None and lease_token is not None
|
|
1902
|
+
if status not in _RECORD_COMMAND_STATUSES:
|
|
1903
|
+
return _error_text(
|
|
1904
|
+
f"status must be one of {sorted(_RECORD_COMMAND_STATUSES)}",
|
|
1905
|
+
code="invalid_arguments", argument="status",
|
|
1906
|
+
)
|
|
1907
|
+
with db.get_session() as session:
|
|
1908
|
+
if not TaskLeaseRepository(session).validate(task_id, lease_token):
|
|
1909
|
+
return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
|
|
1910
|
+
exit_code = arguments.get("exit_code")
|
|
1911
|
+
if exit_code is not None and (not isinstance(exit_code, int) or isinstance(exit_code, bool)):
|
|
1912
|
+
return _error_text("exit_code must be an integer", code="invalid_arguments")
|
|
1913
|
+
ShellCommandRepository(session).record(
|
|
1914
|
+
task_id,
|
|
1915
|
+
command or "",
|
|
1916
|
+
status or "finished",
|
|
1917
|
+
exit_code=exit_code if isinstance(exit_code, int) else None,
|
|
1918
|
+
reason=str(arguments.get("reason") or ""),
|
|
1919
|
+
)
|
|
1920
|
+
return _json_text({"ok": True, "task_id": task_id, "recorded": True})
|
|
1921
|
+
|
|
1922
|
+
elif name == "devcouncil_write_file":
|
|
1923
|
+
assert db is not None
|
|
1924
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
1925
|
+
if arg_error:
|
|
1926
|
+
return arg_error
|
|
1927
|
+
lease_token, arg_error = _required_string_argument(arguments, "lease_token")
|
|
1928
|
+
if arg_error:
|
|
1929
|
+
return arg_error
|
|
1930
|
+
rel_path, arg_error = _required_string_argument(arguments, "path")
|
|
1931
|
+
if arg_error:
|
|
1932
|
+
return arg_error
|
|
1933
|
+
content = arguments.get("content")
|
|
1934
|
+
if not isinstance(content, str):
|
|
1935
|
+
return _error_text("content must be a string", code="invalid_arguments", argument="content")
|
|
1936
|
+
assert task_id is not None and lease_token is not None and rel_path is not None
|
|
1937
|
+
with db.get_session() as session:
|
|
1938
|
+
lease_record = TaskLeaseRepository(session).active_for_task(task_id)
|
|
1939
|
+
if lease_record is None or lease_record.lease_token != lease_token:
|
|
1940
|
+
return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
|
|
1941
|
+
task = TaskRepository(session).get_by_id(task_id)
|
|
1942
|
+
if not task:
|
|
1943
|
+
return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
|
|
1944
|
+
|
|
1945
|
+
decision = HookPolicy(project_root=root).evaluate_file_write(rel_path, task, content=content)
|
|
1946
|
+
target = _within_root(root, rel_path)
|
|
1947
|
+
if target is None:
|
|
1948
|
+
FileChangeRepository(session).record(
|
|
1949
|
+
rel_path, "write", False, task_id=task_id, lease_id=lease_record.id,
|
|
1950
|
+
reason="path escapes the project root",
|
|
1951
|
+
)
|
|
1952
|
+
return _json_text({
|
|
1953
|
+
"ok": False, "task_id": task_id, "applied_files": [],
|
|
1954
|
+
"rejected_files": [{"path": rel_path, "reason": "path escapes the project root"}],
|
|
1955
|
+
})
|
|
1956
|
+
if not decision.allowed:
|
|
1957
|
+
FileChangeRepository(session).record(
|
|
1958
|
+
rel_path, "write", False, task_id=task_id, lease_id=lease_record.id, reason=decision.reason,
|
|
1959
|
+
)
|
|
1960
|
+
return _json_text({
|
|
1961
|
+
"ok": False, "task_id": task_id, "applied_files": [],
|
|
1962
|
+
"rejected_files": [{"path": rel_path, "reason": decision.reason}],
|
|
1963
|
+
})
|
|
1964
|
+
# Atomic write: stage to a sibling temp file, then replace.
|
|
1965
|
+
try:
|
|
1966
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1967
|
+
tmp = target.with_name(target.name + ".devcouncil-tmp")
|
|
1968
|
+
tmp.write_text(content, encoding="utf-8")
|
|
1969
|
+
os.replace(tmp, target)
|
|
1970
|
+
except OSError as exc:
|
|
1971
|
+
return _error_text(f"Write failed: {exc}", code="write_failed", task_id=task_id)
|
|
1972
|
+
FileChangeRepository(session).record(
|
|
1973
|
+
rel_path, "write", True, task_id=task_id, lease_id=lease_record.id, reason=decision.reason,
|
|
1974
|
+
)
|
|
1975
|
+
return _json_text({
|
|
1976
|
+
"ok": True, "task_id": task_id, "applied_files": [rel_path], "rejected_files": [],
|
|
1977
|
+
})
|
|
1978
|
+
|
|
1979
|
+
elif name == "devcouncil_apply_patch":
|
|
1980
|
+
assert db is not None
|
|
1981
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
1982
|
+
if arg_error:
|
|
1983
|
+
return arg_error
|
|
1984
|
+
lease_token, arg_error = _required_string_argument(arguments, "lease_token")
|
|
1985
|
+
if arg_error:
|
|
1986
|
+
return arg_error
|
|
1987
|
+
unified_diff = arguments.get("unified_diff")
|
|
1988
|
+
if not isinstance(unified_diff, str) or not unified_diff.strip():
|
|
1989
|
+
return _error_text("unified_diff must be a non-empty string", code="invalid_arguments", argument="unified_diff")
|
|
1990
|
+
assert task_id is not None and lease_token is not None
|
|
1991
|
+
if not _is_git_repo(root):
|
|
1992
|
+
return _error_text(
|
|
1993
|
+
"apply_patch requires a git repository. Use devcouncil_write_file instead.",
|
|
1994
|
+
code="not_a_git_repo", task_id=task_id,
|
|
1995
|
+
)
|
|
1996
|
+
targets = _diff_target_paths(unified_diff)
|
|
1997
|
+
if not targets:
|
|
1998
|
+
return _error_text("No target files found in the diff.", code="empty_patch", task_id=task_id)
|
|
1999
|
+
with db.get_session() as session:
|
|
2000
|
+
lease_record = TaskLeaseRepository(session).active_for_task(task_id)
|
|
2001
|
+
if lease_record is None or lease_record.lease_token != lease_token:
|
|
2002
|
+
return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
|
|
2003
|
+
task = TaskRepository(session).get_by_id(task_id)
|
|
2004
|
+
if not task:
|
|
2005
|
+
return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
|
|
2006
|
+
|
|
2007
|
+
# Policy-check EVERY target before touching the tree. Any rejection aborts
|
|
2008
|
+
# the whole patch — never a partial apply.
|
|
2009
|
+
policy = HookPolicy(project_root=root)
|
|
2010
|
+
rejected: list[dict[str, str]] = []
|
|
2011
|
+
for path in targets:
|
|
2012
|
+
if _within_root(root, path) is None:
|
|
2013
|
+
rejected.append({"path": path, "reason": "path escapes the project root"})
|
|
2014
|
+
continue
|
|
2015
|
+
d = policy.evaluate_file_write(path, task)
|
|
2016
|
+
if not d.allowed:
|
|
2017
|
+
rejected.append({"path": path, "reason": d.reason})
|
|
2018
|
+
if rejected:
|
|
2019
|
+
for item in rejected:
|
|
2020
|
+
FileChangeRepository(session).record(
|
|
2021
|
+
item["path"], "apply_patch", False, task_id=task_id, lease_id=lease_record.id, reason=item["reason"],
|
|
2022
|
+
)
|
|
2023
|
+
return _json_text({
|
|
2024
|
+
"ok": False, "task_id": task_id, "applied_files": [], "rejected_files": rejected,
|
|
2025
|
+
})
|
|
2026
|
+
|
|
2027
|
+
# Validate then apply atomically (git apply is all-or-nothing).
|
|
2028
|
+
patch_path = root / ".devcouncil" / f"mcp-apply-{lease_record.id}.patch"
|
|
2029
|
+
patch_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2030
|
+
patch_path.write_text(unified_diff, encoding="utf-8")
|
|
2031
|
+
try:
|
|
2032
|
+
check = subprocess.run(
|
|
2033
|
+
["git", "apply", "--check", "--ignore-whitespace", str(patch_path)],
|
|
2034
|
+
cwd=root, capture_output=True, text=True,
|
|
2035
|
+
)
|
|
2036
|
+
if check.returncode != 0:
|
|
2037
|
+
return _error_text(
|
|
2038
|
+
f"Patch does not apply cleanly: {check.stderr.strip()}",
|
|
2039
|
+
code="patch_rejected", task_id=task_id,
|
|
2040
|
+
)
|
|
2041
|
+
applied = subprocess.run(
|
|
2042
|
+
["git", "apply", "--ignore-whitespace", str(patch_path)],
|
|
2043
|
+
cwd=root, capture_output=True, text=True,
|
|
2044
|
+
)
|
|
2045
|
+
if applied.returncode != 0:
|
|
2046
|
+
return _error_text(
|
|
2047
|
+
f"Patch apply failed: {applied.stderr.strip()}",
|
|
2048
|
+
code="patch_failed", task_id=task_id,
|
|
2049
|
+
)
|
|
2050
|
+
finally:
|
|
2051
|
+
try:
|
|
2052
|
+
patch_path.unlink()
|
|
2053
|
+
except OSError:
|
|
2054
|
+
pass
|
|
2055
|
+
for path in targets:
|
|
2056
|
+
FileChangeRepository(session).record(
|
|
2057
|
+
path, "apply_patch", True, task_id=task_id, lease_id=lease_record.id, reason="policy allowed",
|
|
2058
|
+
)
|
|
2059
|
+
return _json_text({
|
|
2060
|
+
"ok": True, "task_id": task_id, "applied_files": targets, "rejected_files": [],
|
|
2061
|
+
})
|
|
2062
|
+
|
|
2063
|
+
elif name == "devcouncil_verify_task":
|
|
2064
|
+
assert db is not None
|
|
2065
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
2066
|
+
if arg_error:
|
|
2067
|
+
return arg_error
|
|
2068
|
+
lease_token, arg_error = _required_string_argument(arguments, "lease_token")
|
|
2069
|
+
if arg_error:
|
|
2070
|
+
return arg_error
|
|
2071
|
+
assert task_id is not None and lease_token is not None
|
|
2072
|
+
sandbox = _optional_string_argument(arguments, "sandbox") or "local"
|
|
2073
|
+
if sandbox in {"docker", "nix"}:
|
|
2074
|
+
return _json_text({
|
|
2075
|
+
"ok": False,
|
|
2076
|
+
"code": "unsupported_sandbox",
|
|
2077
|
+
"reason": f"Sandbox {sandbox} is not available in this build.",
|
|
2078
|
+
"sandbox": sandbox,
|
|
2079
|
+
})
|
|
2080
|
+
with db.get_session() as session:
|
|
2081
|
+
if not TaskLeaseRepository(session).validate(task_id, lease_token):
|
|
2082
|
+
return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
|
|
2083
|
+
task_repo = TaskRepository(session)
|
|
2084
|
+
task = task_repo.get_by_id(task_id)
|
|
2085
|
+
if not task:
|
|
2086
|
+
return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
|
|
2087
|
+
from devcouncil.verification.verifier import Verifier
|
|
2088
|
+
|
|
2089
|
+
GapRepository(session).delete_for_task(task_id)
|
|
2090
|
+
EvidenceRepository(session).delete_for_task(task_id)
|
|
2091
|
+
# Run the STRONG gate when a provider key is configured (compiled
|
|
2092
|
+
# per-criterion checks); otherwise fall back to coarse mode and report it.
|
|
2093
|
+
verifier = Verifier(root, router=_load_router(root))
|
|
2094
|
+
evidence_gaps, evidence = await verifier.verify_task(
|
|
2095
|
+
task, RequirementRepository(session).get_all()
|
|
2096
|
+
)
|
|
2097
|
+
gaps = evidence_gaps
|
|
2098
|
+
for gap in gaps:
|
|
2099
|
+
GapRepository(session).save(gap)
|
|
2100
|
+
for ev in evidence:
|
|
2101
|
+
if isinstance(ev, CommandResult):
|
|
2102
|
+
EvidenceRepository(session).save_command_result(task_id, ev)
|
|
2103
|
+
elif isinstance(ev, DiffCoverageEvidence):
|
|
2104
|
+
EvidenceRepository(session).save_diff_coverage_evidence(ev)
|
|
2105
|
+
elif isinstance(ev, DiffEvidence):
|
|
2106
|
+
EvidenceRepository(session).save_diff_evidence(ev)
|
|
2107
|
+
elif isinstance(ev, TestEvidence):
|
|
2108
|
+
EvidenceRepository(session).save_test_evidence(ev, task_id)
|
|
2109
|
+
task.status = "blocked" if any(g.blocking for g in gaps) else "verified"
|
|
2110
|
+
task_repo.save(task)
|
|
2111
|
+
blocking = [g.model_dump() for g in gaps if g.blocking]
|
|
2112
|
+
# The typed next-actions contract: structured, routable steps the agent
|
|
2113
|
+
# can act on to self-repair and re-verify without a human pasting prose.
|
|
2114
|
+
# blocking_actions must be cleared to pass; advisory_actions (e.g. the
|
|
2115
|
+
# diff↔coverage "tests passed but new code never ran" signal) are quality
|
|
2116
|
+
# signals the agent can act on without confusing them with the gate.
|
|
2117
|
+
blocking_actions, advisory_actions = split_next_actions(gaps)
|
|
2118
|
+
outcome = verifier.last_outcome
|
|
2119
|
+
return _json_text({
|
|
2120
|
+
"ok": True,
|
|
2121
|
+
"task_id": task_id,
|
|
2122
|
+
"status": task.status,
|
|
2123
|
+
"sandbox": sandbox,
|
|
2124
|
+
"blocking_gaps": blocking,
|
|
2125
|
+
"next_actions": [a.model_dump() for a in blocking_actions],
|
|
2126
|
+
"advisory_actions": [a.model_dump() for a in advisory_actions],
|
|
2127
|
+
# Computed from the post-verify status: verified -> release only,
|
|
2128
|
+
# blocked -> the read/edit/test repair loop.
|
|
2129
|
+
"allowed_next_tools": _allowed_next_tools(task.status, len(blocking) > 0),
|
|
2130
|
+
"passed": len(blocking) == 0,
|
|
2131
|
+
# Rigor of this run so the agent never reads passed==True as proven
|
|
2132
|
+
# when the gate could not actually check.
|
|
2133
|
+
"verification_mode": outcome.mode if outcome else "unknown",
|
|
2134
|
+
"compiler_active": outcome.compiler_active if outcome else False,
|
|
2135
|
+
"diff_empty": outcome.diff_empty if outcome else False,
|
|
2136
|
+
"coverage_measured": outcome.coverage_measured if outcome else False,
|
|
2137
|
+
"coverage_skipped_reason": outcome.coverage_skipped_reason if outcome else None,
|
|
2138
|
+
})
|
|
2139
|
+
|
|
2140
|
+
elif name == "devcouncil_handoff_agent":
|
|
2141
|
+
assert db is not None
|
|
2142
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
2143
|
+
if arg_error:
|
|
2144
|
+
return arg_error
|
|
2145
|
+
lease_token, arg_error = _required_string_argument(arguments, "lease_token")
|
|
2146
|
+
if arg_error:
|
|
2147
|
+
return arg_error
|
|
2148
|
+
from_agent, arg_error = _required_string_argument(arguments, "from_agent")
|
|
2149
|
+
if arg_error:
|
|
2150
|
+
return arg_error
|
|
2151
|
+
to_agent, arg_error = _required_string_argument(arguments, "to_agent")
|
|
2152
|
+
if arg_error:
|
|
2153
|
+
return arg_error
|
|
2154
|
+
assert task_id is not None and lease_token is not None
|
|
2155
|
+
with db.get_session() as session:
|
|
2156
|
+
if not TaskLeaseRepository(session).validate(task_id, lease_token):
|
|
2157
|
+
return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
|
|
2158
|
+
try:
|
|
2159
|
+
from devcouncil.execution.handoff import HandoffService
|
|
2160
|
+
|
|
2161
|
+
manifest, handoff_path, run_id = HandoffService(root).create(
|
|
2162
|
+
task_id,
|
|
2163
|
+
from_agent or "",
|
|
2164
|
+
to_agent or "",
|
|
2165
|
+
instruction=str(arguments.get("instruction") or ""),
|
|
2166
|
+
)
|
|
2167
|
+
return _json_text({
|
|
2168
|
+
"ok": True,
|
|
2169
|
+
"task_id": task_id,
|
|
2170
|
+
"manifest_path": str(handoff_path),
|
|
2171
|
+
"run_id": run_id,
|
|
2172
|
+
"manifest": manifest.model_dump(),
|
|
2173
|
+
})
|
|
2174
|
+
except ValueError as exc:
|
|
2175
|
+
return _error_text(str(exc), code="handoff_failed", task_id=task_id)
|
|
2176
|
+
|
|
2177
|
+
elif name == "devcouncil_read_file":
|
|
2178
|
+
rel_path, arg_error = _required_string_argument(arguments, "path")
|
|
2179
|
+
if arg_error:
|
|
2180
|
+
return arg_error
|
|
2181
|
+
assert rel_path is not None
|
|
2182
|
+
if _is_secret_path(root, rel_path):
|
|
2183
|
+
return _error_text(
|
|
2184
|
+
"Refusing to read a secret/credential path.",
|
|
2185
|
+
code="secret_path", path=rel_path,
|
|
2186
|
+
)
|
|
2187
|
+
target = _within_root(root, rel_path)
|
|
2188
|
+
if target is None:
|
|
2189
|
+
return _error_text("path escapes the project root", code="path_escape", path=rel_path)
|
|
2190
|
+
if not target.exists() or not target.is_file():
|
|
2191
|
+
return _error_text(f"File not found: {rel_path}", code="not_found", path=rel_path)
|
|
2192
|
+
try:
|
|
2193
|
+
raw = target.read_bytes()
|
|
2194
|
+
except OSError as exc:
|
|
2195
|
+
return _error_text(f"Read failed: {exc}", code="read_failed", path=rel_path)
|
|
2196
|
+
sha256 = hashlib.sha256(raw).hexdigest()
|
|
2197
|
+
text = raw.decode("utf-8", errors="replace")
|
|
2198
|
+
all_lines = text.splitlines()
|
|
2199
|
+
line_count = len(all_lines)
|
|
2200
|
+
# Optional windowing: line_range ('10-40', 1-based inclusive) wins over offset/limit.
|
|
2201
|
+
line_range = _optional_string_argument(arguments, "line_range")
|
|
2202
|
+
if line_range == "":
|
|
2203
|
+
return _error_text("line_range must be a string", code="invalid_arguments", argument="line_range")
|
|
2204
|
+
selected = all_lines
|
|
2205
|
+
if line_range:
|
|
2206
|
+
try:
|
|
2207
|
+
start_str, _, end_str = line_range.partition("-")
|
|
2208
|
+
start = max(1, int(start_str))
|
|
2209
|
+
end = int(end_str) if end_str else line_count
|
|
2210
|
+
except ValueError:
|
|
2211
|
+
return _error_text("line_range must look like '10-40'", code="invalid_arguments", argument="line_range")
|
|
2212
|
+
selected = all_lines[start - 1:end]
|
|
2213
|
+
else:
|
|
2214
|
+
offset = _int_argument(arguments, "offset", 0, minimum=0, maximum=10_000_000)
|
|
2215
|
+
limit_value = arguments.get("limit")
|
|
2216
|
+
if isinstance(limit_value, int) and not isinstance(limit_value, bool):
|
|
2217
|
+
limit = max(1, limit_value)
|
|
2218
|
+
selected = all_lines[offset:offset + limit]
|
|
2219
|
+
elif offset:
|
|
2220
|
+
selected = all_lines[offset:]
|
|
2221
|
+
windowed = "\n".join(selected)
|
|
2222
|
+
content, truncated = _truncate_text(windowed)
|
|
2223
|
+
return _json_text({
|
|
2224
|
+
"ok": True,
|
|
2225
|
+
"path": rel_path.replace("\\", "/"),
|
|
2226
|
+
"content": content,
|
|
2227
|
+
"sha256": sha256,
|
|
2228
|
+
"line_count": line_count,
|
|
2229
|
+
"truncated": truncated,
|
|
2230
|
+
})
|
|
2231
|
+
|
|
2232
|
+
elif name == "devcouncil_get_diff":
|
|
2233
|
+
if not _is_git_repo(root):
|
|
2234
|
+
return _error_text("get_diff requires a git repository.", code="not_a_git_repo")
|
|
2235
|
+
task_id = _optional_string_argument(arguments, "task_id")
|
|
2236
|
+
if task_id == "":
|
|
2237
|
+
return _error_text("task_id must be a string", code="invalid_arguments", argument="task_id")
|
|
2238
|
+
explicit_paths, arg_error = _optional_string_list_argument(arguments, "paths")
|
|
2239
|
+
if arg_error:
|
|
2240
|
+
return arg_error
|
|
2241
|
+
staged_value = arguments.get("staged", False)
|
|
2242
|
+
if not isinstance(staged_value, bool):
|
|
2243
|
+
return _error_text("staged must be a boolean", code="invalid_arguments", argument="staged")
|
|
2244
|
+
scope_paths: list[str] = list(explicit_paths)
|
|
2245
|
+
if task_id and db:
|
|
2246
|
+
with db.get_session() as session:
|
|
2247
|
+
task = TaskRepository(session).get_by_id(task_id)
|
|
2248
|
+
if task is None:
|
|
2249
|
+
return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
|
|
2250
|
+
for planned in task.planned_files:
|
|
2251
|
+
p = planned.path.replace("\\", "/")
|
|
2252
|
+
if p not in scope_paths:
|
|
2253
|
+
scope_paths.append(p)
|
|
2254
|
+
return _json_text(await _git_diff(root, scope_paths, staged_value))
|
|
2255
|
+
|
|
2256
|
+
elif name == "devcouncil_get_evidence":
|
|
2257
|
+
assert db is not None
|
|
2258
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
2259
|
+
if arg_error:
|
|
2260
|
+
return arg_error
|
|
2261
|
+
assert task_id is not None
|
|
2262
|
+
command_filter = _optional_string_argument(arguments, "command")
|
|
2263
|
+
if command_filter == "":
|
|
2264
|
+
return _error_text("command must be a string", code="invalid_arguments", argument="command")
|
|
2265
|
+
limit = _int_argument(arguments, "limit", 20, minimum=1, maximum=100)
|
|
2266
|
+
with db.get_session() as session:
|
|
2267
|
+
results = EvidenceRepository(session).get_command_results_for_task(task_id)
|
|
2268
|
+
evidence_rows: list[dict[str, object]] = []
|
|
2269
|
+
for result in results:
|
|
2270
|
+
if command_filter and command_filter not in result.command:
|
|
2271
|
+
continue
|
|
2272
|
+
stdout, stdout_truncated = _truncate_text(_read_log_file(result.stdout_path))
|
|
2273
|
+
stderr, stderr_truncated = _truncate_text(_read_log_file(result.stderr_path))
|
|
2274
|
+
evidence_rows.append({
|
|
2275
|
+
"command": result.command,
|
|
2276
|
+
"exit_code": result.exit_code,
|
|
2277
|
+
"summary": result.summary,
|
|
2278
|
+
"stdout": stdout,
|
|
2279
|
+
"stderr": stderr,
|
|
2280
|
+
"truncated": stdout_truncated or stderr_truncated,
|
|
2281
|
+
})
|
|
2282
|
+
if len(evidence_rows) >= limit:
|
|
2283
|
+
break
|
|
2284
|
+
return _json_text({"ok": True, "task_id": task_id, "evidence": evidence_rows})
|
|
2285
|
+
|
|
2286
|
+
elif name == "devcouncil_run_command":
|
|
2287
|
+
assert db is not None
|
|
2288
|
+
task_id, arg_error = _required_string_argument(arguments, "task_id")
|
|
2289
|
+
if arg_error:
|
|
2290
|
+
return arg_error
|
|
2291
|
+
lease_token, arg_error = _required_string_argument(arguments, "lease_token")
|
|
2292
|
+
if arg_error:
|
|
2293
|
+
return arg_error
|
|
2294
|
+
command, arg_error = _required_string_argument(arguments, "command")
|
|
2295
|
+
if arg_error:
|
|
2296
|
+
return arg_error
|
|
2297
|
+
assert task_id is not None and lease_token is not None and command is not None
|
|
2298
|
+
normalized = " ".join(command.split())
|
|
2299
|
+
with db.get_session() as session:
|
|
2300
|
+
if not TaskLeaseRepository(session).validate(task_id, lease_token):
|
|
2301
|
+
return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
|
|
2302
|
+
task = TaskRepository(session).get_by_id(task_id)
|
|
2303
|
+
if not task:
|
|
2304
|
+
return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
|
|
2305
|
+
from devcouncil.execution.policy_engine import TaskPolicyEngine
|
|
2306
|
+
|
|
2307
|
+
policy_decision = TaskPolicyEngine(root).evaluate_command(normalized, task)
|
|
2308
|
+
if policy_decision.action == "deny":
|
|
2309
|
+
# Record nothing executed; the gate refused before any side effect.
|
|
2310
|
+
ShellCommandRepository(session).record(
|
|
2311
|
+
task_id, normalized, "blocked", reason=policy_decision.reason,
|
|
2312
|
+
)
|
|
2313
|
+
return _error_text(
|
|
2314
|
+
policy_decision.reason or "Command is not in the task allowlist.",
|
|
2315
|
+
code="command_not_allowed", task_id=task_id, command=normalized,
|
|
2316
|
+
)
|
|
2317
|
+
try:
|
|
2318
|
+
import shlex
|
|
2319
|
+
|
|
2320
|
+
args = shlex.split(normalized, posix=(os.name != "nt"))
|
|
2321
|
+
completed = subprocess.run(
|
|
2322
|
+
args,
|
|
2323
|
+
cwd=root,
|
|
2324
|
+
capture_output=True,
|
|
2325
|
+
text=True,
|
|
2326
|
+
encoding="utf-8",
|
|
2327
|
+
errors="replace",
|
|
2328
|
+
env=clean_subprocess_env(),
|
|
2329
|
+
timeout=_CLI_TIMEOUT_SECONDS,
|
|
2330
|
+
)
|
|
2331
|
+
exit_code = completed.returncode
|
|
2332
|
+
stdout, stdout_truncated = _truncate_text(completed.stdout)
|
|
2333
|
+
stderr, stderr_truncated = _truncate_text(completed.stderr)
|
|
2334
|
+
timed_out = False
|
|
2335
|
+
except subprocess.TimeoutExpired as exc:
|
|
2336
|
+
exit_code = None
|
|
2337
|
+
stdout, stdout_truncated = _truncate_text(exc.output)
|
|
2338
|
+
stderr, stderr_truncated = _truncate_text(exc.stderr)
|
|
2339
|
+
timed_out = True
|
|
2340
|
+
except (FileNotFoundError, OSError, ValueError) as exc:
|
|
2341
|
+
ShellCommandRepository(session).record(
|
|
2342
|
+
task_id, normalized, "failed", reason=str(exc),
|
|
2343
|
+
)
|
|
2344
|
+
return _error_text(f"Could not run command: {exc}", code="run_failed", task_id=task_id)
|
|
2345
|
+
ShellCommandRepository(session).record(
|
|
2346
|
+
task_id,
|
|
2347
|
+
normalized,
|
|
2348
|
+
"finished" if exit_code == 0 else "failed",
|
|
2349
|
+
exit_code=exit_code,
|
|
2350
|
+
)
|
|
2351
|
+
return _json_text({
|
|
2352
|
+
"ok": exit_code == 0,
|
|
2353
|
+
"task_id": task_id,
|
|
2354
|
+
"exit_code": exit_code,
|
|
2355
|
+
"stdout": stdout,
|
|
2356
|
+
"stderr": stderr,
|
|
2357
|
+
"truncated": stdout_truncated or stderr_truncated,
|
|
2358
|
+
"timed_out": timed_out,
|
|
2359
|
+
})
|
|
2360
|
+
|
|
2361
|
+
elif name == "devcouncil_next_task":
|
|
2362
|
+
assert db is not None
|
|
2363
|
+
status_filter = _optional_string_argument(arguments, "status")
|
|
2364
|
+
if status_filter == "":
|
|
2365
|
+
return _error_text("status must be a string", code="invalid_arguments", argument="status")
|
|
2366
|
+
client_id = _optional_string_argument(arguments, "client_id")
|
|
2367
|
+
if client_id == "":
|
|
2368
|
+
return _error_text("client_id must be a string", code="invalid_arguments", argument="client_id")
|
|
2369
|
+
with db.get_session() as session:
|
|
2370
|
+
tasks = TaskRepository(session).get_all()
|
|
2371
|
+
leased_task_ids = {
|
|
2372
|
+
lease.task_id
|
|
2373
|
+
for lease, expired in TaskLeaseRepository(session).list_leases(active_only=True)
|
|
2374
|
+
if not expired
|
|
2375
|
+
}
|
|
2376
|
+
blocking_by_task: dict[str, int] = {}
|
|
2377
|
+
for gap in GapRepository(session).get_all():
|
|
2378
|
+
if gap.blocking and gap.task_id:
|
|
2379
|
+
blocking_by_task[gap.task_id] = blocking_by_task.get(gap.task_id, 0) + 1
|
|
2380
|
+
done_ids = {t.id for t in tasks if t.status in {"verified", "done"}}
|
|
2381
|
+
# Candidate set: not finished, not actively leased, deps satisfied, matching the
|
|
2382
|
+
# optional status filter (default to planned/ready bootstrap states).
|
|
2383
|
+
wanted_statuses = {status_filter} if status_filter else {"planned", "ready"}
|
|
2384
|
+
candidates = []
|
|
2385
|
+
for task in tasks:
|
|
2386
|
+
if task.status not in wanted_statuses:
|
|
2387
|
+
continue
|
|
2388
|
+
if task.id in leased_task_ids:
|
|
2389
|
+
continue
|
|
2390
|
+
if any(dep not in done_ids for dep in task.depends_on):
|
|
2391
|
+
continue
|
|
2392
|
+
candidates.append(task)
|
|
2393
|
+
if not candidates:
|
|
2394
|
+
return _json_text({
|
|
2395
|
+
"ok": True,
|
|
2396
|
+
"task": None,
|
|
2397
|
+
"reason": "No unblocked, unleased task is available.",
|
|
2398
|
+
})
|
|
2399
|
+
# Deterministic "highest priority": fewest unmet deps, then task id order, so the
|
|
2400
|
+
# same task is chosen on every call (no race with list_tasks ordering).
|
|
2401
|
+
candidates.sort(key=lambda t: (len(t.depends_on), t.id))
|
|
2402
|
+
chosen = candidates[0]
|
|
2403
|
+
blocking_count = blocking_by_task.get(chosen.id, 0)
|
|
2404
|
+
return _json_text({
|
|
2405
|
+
"ok": True,
|
|
2406
|
+
"task": chosen.model_dump(),
|
|
2407
|
+
"blocking_gap_count": blocking_count,
|
|
2408
|
+
"ready_to_checkout": blocking_count == 0,
|
|
2409
|
+
"allowed_next_tools": _allowed_next_tools(chosen.status, blocking_count > 0),
|
|
2410
|
+
})
|
|
2411
|
+
|
|
2412
|
+
elif name == "devcouncil_list_agent_runs":
|
|
2413
|
+
from devcouncil.cli.commands.runs import _collect_runs, _orphan_after_seconds
|
|
2414
|
+
|
|
2415
|
+
status_filter = _optional_string_argument(arguments, "status")
|
|
2416
|
+
if status_filter == "":
|
|
2417
|
+
return _error_text("status must be a string", code="invalid_arguments", argument="status")
|
|
2418
|
+
limit = _int_argument(arguments, "limit", 20, minimum=1, maximum=500)
|
|
2419
|
+
run_rows = _collect_runs(root, orphan_after=_orphan_after_seconds(root))
|
|
2420
|
+
if status_filter:
|
|
2421
|
+
run_rows = [row for row in run_rows if row.get("status") == status_filter]
|
|
2422
|
+
total = len(run_rows)
|
|
2423
|
+
run_window = run_rows[:limit]
|
|
2424
|
+
return _json_text({"ok": True, "runs": run_window, "total": total, "returned": len(run_window)})
|
|
2425
|
+
|
|
2426
|
+
elif name == "devcouncil_get_run":
|
|
2427
|
+
from devcouncil.cli.commands.runs import (
|
|
2428
|
+
_find_transcript,
|
|
2429
|
+
_is_orphaned,
|
|
2430
|
+
_load_manifest,
|
|
2431
|
+
_orphan_after_seconds,
|
|
2432
|
+
_runs_dir,
|
|
2433
|
+
_transcript_tail,
|
|
2434
|
+
)
|
|
2435
|
+
import time as _time
|
|
2436
|
+
|
|
2437
|
+
target_run_id, arg_error = _required_string_argument(arguments, "run_id")
|
|
2438
|
+
if arg_error:
|
|
2439
|
+
return arg_error
|
|
2440
|
+
assert target_run_id is not None
|
|
2441
|
+
run_dir = _runs_dir(root) / target_run_id
|
|
2442
|
+
manifest_path = run_dir / "agent-run.json"
|
|
2443
|
+
run_manifest = _load_manifest(manifest_path)
|
|
2444
|
+
if run_manifest is None:
|
|
2445
|
+
return _error_text(f"Run {target_run_id} not found.", code="not_found", run_id=target_run_id)
|
|
2446
|
+
orphaned = _is_orphaned(
|
|
2447
|
+
run_manifest, manifest_path, orphan_after=_orphan_after_seconds(root), now=_time.time()
|
|
2448
|
+
)
|
|
2449
|
+
transcript_path = _find_transcript(run_dir, run_manifest)
|
|
2450
|
+
transcript_tail = _transcript_tail(transcript_path) if transcript_path else ""
|
|
2451
|
+
tail, truncated = _truncate_text(transcript_tail)
|
|
2452
|
+
return _json_text({
|
|
2453
|
+
"ok": True,
|
|
2454
|
+
"run_id": target_run_id,
|
|
2455
|
+
"manifest": run_manifest,
|
|
2456
|
+
"orphaned": orphaned,
|
|
2457
|
+
"transcript_path": str(transcript_path) if transcript_path else None,
|
|
2458
|
+
"transcript_tail": tail,
|
|
2459
|
+
"transcript_truncated": truncated,
|
|
2460
|
+
})
|
|
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)
|
|
591
2503
|
return _error_text(f"Unknown tool: {name}", code="unknown_tool", tool=name)
|
|
592
2504
|
|
|
593
2505
|
async def run():
|