devcouncil 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +144 -308
  2. package/bin/devcouncil.js +130 -32
  3. package/package.json +22 -19
  4. package/pyproject.toml +0 -66
  5. package/src/devcouncil/__init__.py +0 -0
  6. package/src/devcouncil/__main__.py +0 -4
  7. package/src/devcouncil/app/__init__.py +0 -28
  8. package/src/devcouncil/app/config.py +0 -320
  9. package/src/devcouncil/app/errors.py +0 -23
  10. package/src/devcouncil/app/events.py +0 -44
  11. package/src/devcouncil/app/orchestrator.py +0 -92
  12. package/src/devcouncil/app/project_status.py +0 -29
  13. package/src/devcouncil/app/run_context.py +0 -39
  14. package/src/devcouncil/app/state_machine.py +0 -108
  15. package/src/devcouncil/artifacts/__init__.py +0 -1
  16. package/src/devcouncil/artifacts/coverage.py +0 -96
  17. package/src/devcouncil/artifacts/graph.py +0 -163
  18. package/src/devcouncil/artifacts/migrations.py +0 -20
  19. package/src/devcouncil/artifacts/schemas.py +0 -23
  20. package/src/devcouncil/artifacts/serializer.py +0 -21
  21. package/src/devcouncil/artifacts/validators.py +0 -27
  22. package/src/devcouncil/assets/__init__.py +0 -1
  23. package/src/devcouncil/assets/devcouncil-logo.svg +0 -60
  24. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  25. package/src/devcouncil/cli/__init__.py +0 -0
  26. package/src/devcouncil/cli/commands/__init__.py +0 -0
  27. package/src/devcouncil/cli/commands/agents.py +0 -292
  28. package/src/devcouncil/cli/commands/artifacts.py +0 -54
  29. package/src/devcouncil/cli/commands/ast.py +0 -22
  30. package/src/devcouncil/cli/commands/baseline.py +0 -35
  31. package/src/devcouncil/cli/commands/check.py +0 -209
  32. package/src/devcouncil/cli/commands/config.py +0 -115
  33. package/src/devcouncil/cli/commands/cost.py +0 -57
  34. package/src/devcouncil/cli/commands/dashboard.py +0 -31
  35. package/src/devcouncil/cli/commands/doctor.py +0 -340
  36. package/src/devcouncil/cli/commands/evidence.py +0 -48
  37. package/src/devcouncil/cli/commands/go.py +0 -656
  38. package/src/devcouncil/cli/commands/handoff.py +0 -69
  39. package/src/devcouncil/cli/commands/hook.py +0 -237
  40. package/src/devcouncil/cli/commands/init.py +0 -289
  41. package/src/devcouncil/cli/commands/integrate.py +0 -1515
  42. package/src/devcouncil/cli/commands/lsp.py +0 -20
  43. package/src/devcouncil/cli/commands/map.py +0 -112
  44. package/src/devcouncil/cli/commands/mcp_server.py +0 -18
  45. package/src/devcouncil/cli/commands/plan.py +0 -488
  46. package/src/devcouncil/cli/commands/prompt.py +0 -61
  47. package/src/devcouncil/cli/commands/repair.py +0 -89
  48. package/src/devcouncil/cli/commands/report.py +0 -137
  49. package/src/devcouncil/cli/commands/reset_demo_state.py +0 -33
  50. package/src/devcouncil/cli/commands/rollback.py +0 -59
  51. package/src/devcouncil/cli/commands/run.py +0 -289
  52. package/src/devcouncil/cli/commands/runs.py +0 -223
  53. package/src/devcouncil/cli/commands/scaffold.py +0 -32
  54. package/src/devcouncil/cli/commands/semantic.py +0 -47
  55. package/src/devcouncil/cli/commands/setup.py +0 -362
  56. package/src/devcouncil/cli/commands/shell.py +0 -73
  57. package/src/devcouncil/cli/commands/show.py +0 -76
  58. package/src/devcouncil/cli/commands/skills.py +0 -88
  59. package/src/devcouncil/cli/commands/status.py +0 -141
  60. package/src/devcouncil/cli/commands/tasks.py +0 -55
  61. package/src/devcouncil/cli/commands/trace.py +0 -88
  62. package/src/devcouncil/cli/commands/verify.py +0 -328
  63. package/src/devcouncil/cli/commands/version.py +0 -20
  64. package/src/devcouncil/cli/commands/watch.py +0 -574
  65. package/src/devcouncil/cli/commands/watch_fs.py +0 -40
  66. package/src/devcouncil/cli/main.py +0 -137
  67. package/src/devcouncil/council/__init__.py +0 -0
  68. package/src/devcouncil/council/prompts/__init__.py +0 -0
  69. package/src/devcouncil/council/prompts/arbiter.md +0 -19
  70. package/src/devcouncil/council/prompts/critic_a.md +0 -10
  71. package/src/devcouncil/council/prompts/critic_b.md +0 -10
  72. package/src/devcouncil/council/prompts/implementation_reviewer.md +0 -16
  73. package/src/devcouncil/council/prompts/planner_a.md +0 -16
  74. package/src/devcouncil/council/prompts/planner_b.md +0 -16
  75. package/src/devcouncil/council/prompts/rebuttal.md +0 -10
  76. package/src/devcouncil/council/prompts/spec_writer.md +0 -12
  77. package/src/devcouncil/domain/__init__.py +0 -0
  78. package/src/devcouncil/domain/assumption.py +0 -17
  79. package/src/devcouncil/domain/critique.py +0 -32
  80. package/src/devcouncil/domain/evidence.py +0 -47
  81. package/src/devcouncil/domain/gap.py +0 -52
  82. package/src/devcouncil/domain/requirement.py +0 -22
  83. package/src/devcouncil/domain/task.py +0 -55
  84. package/src/devcouncil/execution/__init__.py +0 -1
  85. package/src/devcouncil/execution/checkpoints.py +0 -246
  86. package/src/devcouncil/execution/context_builder.py +0 -60
  87. package/src/devcouncil/execution/executor.py +0 -15
  88. package/src/devcouncil/execution/fs_watcher.py +0 -180
  89. package/src/devcouncil/execution/handoff.py +0 -102
  90. package/src/devcouncil/execution/hook_policy.py +0 -253
  91. package/src/devcouncil/execution/patch.py +0 -77
  92. package/src/devcouncil/execution/paths.py +0 -14
  93. package/src/devcouncil/execution/permissions.py +0 -85
  94. package/src/devcouncil/execution/policy_engine.py +0 -343
  95. package/src/devcouncil/execution/prompt_builder.py +0 -671
  96. package/src/devcouncil/execution/shell_session.py +0 -225
  97. package/src/devcouncil/execution/task_runner.py +0 -170
  98. package/src/devcouncil/executors/__init__.py +0 -1
  99. package/src/devcouncil/executors/agent_registry.py +0 -575
  100. package/src/devcouncil/executors/coding_cli.py +0 -736
  101. package/src/devcouncil/executors/mini_swe.py +0 -73
  102. package/src/devcouncil/executors/native/__init__.py +0 -0
  103. package/src/devcouncil/executors/native/agent.py +0 -208
  104. package/src/devcouncil/executors/openhands.py +0 -71
  105. package/src/devcouncil/gating/__init__.py +0 -1
  106. package/src/devcouncil/gating/checks/__init__.py +0 -0
  107. package/src/devcouncil/gating/checks/clean_git.py +0 -52
  108. package/src/devcouncil/gating/checks/planned_files_check.py +0 -32
  109. package/src/devcouncil/gating/checks/requirement_coverage.py +0 -26
  110. package/src/devcouncil/gating/checks/secret_scan_check.py +0 -53
  111. package/src/devcouncil/gating/policy.py +0 -338
  112. package/src/devcouncil/hardware.py +0 -184
  113. package/src/devcouncil/indexing/__init__.py +0 -1
  114. package/src/devcouncil/indexing/ast_matcher.py +0 -168
  115. package/src/devcouncil/indexing/graph_index.py +0 -48
  116. package/src/devcouncil/indexing/lsp.py +0 -161
  117. package/src/devcouncil/indexing/repo_mapper.py +0 -1455
  118. package/src/devcouncil/indexing/semantic_index.py +0 -205
  119. package/src/devcouncil/integrations/actions.py +0 -146
  120. package/src/devcouncil/integrations/check.py +0 -423
  121. package/src/devcouncil/integrations/code_review_graph.py +0 -163
  122. package/src/devcouncil/integrations/github.py +0 -39
  123. package/src/devcouncil/integrations/github_intent.py +0 -142
  124. package/src/devcouncil/integrations/gitnexus.py +0 -62
  125. package/src/devcouncil/integrations/graphify.py +0 -34
  126. package/src/devcouncil/integrations/mcp/__init__.py +0 -0
  127. package/src/devcouncil/integrations/mcp/server.py +0 -2122
  128. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +0 -24
  129. package/src/devcouncil/integrations/pr_comments.py +0 -62
  130. package/src/devcouncil/live/__init__.py +0 -2
  131. package/src/devcouncil/live/cards.py +0 -349
  132. package/src/devcouncil/live/models.py +0 -63
  133. package/src/devcouncil/live/repair_prompt.py +0 -83
  134. package/src/devcouncil/live/reviewer.py +0 -70
  135. package/src/devcouncil/live/signals.py +0 -135
  136. package/src/devcouncil/live/summary.py +0 -34
  137. package/src/devcouncil/live/tasks.py +0 -18
  138. package/src/devcouncil/live/transcripts.py +0 -141
  139. package/src/devcouncil/llm/__init__.py +0 -1
  140. package/src/devcouncil/llm/cache.py +0 -42
  141. package/src/devcouncil/llm/model_defaults.yaml +0 -44
  142. package/src/devcouncil/llm/provider.py +0 -627
  143. package/src/devcouncil/llm/router.py +0 -310
  144. package/src/devcouncil/optimization/__init__.py +0 -1
  145. package/src/devcouncil/optimization/gepa_agent.py +0 -318
  146. package/src/devcouncil/planning/__init__.py +0 -1
  147. package/src/devcouncil/planning/arbiter_service.py +0 -57
  148. package/src/devcouncil/planning/correction_manifest.py +0 -303
  149. package/src/devcouncil/planning/critique_service.py +0 -71
  150. package/src/devcouncil/planning/plan_service.py +0 -60
  151. package/src/devcouncil/planning/prompt_enhancer_service.py +0 -167
  152. package/src/devcouncil/planning/repair_service.py +0 -39
  153. package/src/devcouncil/planning/spec_service.py +0 -70
  154. package/src/devcouncil/repo/__init__.py +0 -0
  155. package/src/devcouncil/repo/ci_scaffold.py +0 -157
  156. package/src/devcouncil/repo/gitignore.py +0 -123
  157. package/src/devcouncil/repo/sca.py +0 -374
  158. package/src/devcouncil/reporting/__init__.py +0 -0
  159. package/src/devcouncil/reporting/github_check.py +0 -32
  160. package/src/devcouncil/reporting/json_report.py +0 -30
  161. package/src/devcouncil/reporting/markdown_report.py +0 -83
  162. package/src/devcouncil/reporting/report_builder.py +0 -14
  163. package/src/devcouncil/skills/__init__.py +0 -19
  164. package/src/devcouncil/skills/library/README.md +0 -46
  165. package/src/devcouncil/skills/library/ai-training.md +0 -50
  166. package/src/devcouncil/skills/library/android.md +0 -50
  167. package/src/devcouncil/skills/library/backend.md +0 -52
  168. package/src/devcouncil/skills/library/core-engineering.md +0 -95
  169. package/src/devcouncil/skills/library/data-engineering.md +0 -47
  170. package/src/devcouncil/skills/library/desktop.md +0 -46
  171. package/src/devcouncil/skills/library/devops.md +0 -48
  172. package/src/devcouncil/skills/library/game-dev.md +0 -46
  173. package/src/devcouncil/skills/library/ios.md +0 -48
  174. package/src/devcouncil/skills/library/mobile-cross-platform.md +0 -46
  175. package/src/devcouncil/skills/library/security.md +0 -48
  176. package/src/devcouncil/skills/library/systems.md +0 -48
  177. package/src/devcouncil/skills/library/web.md +0 -47
  178. package/src/devcouncil/skills/library/windows.md +0 -47
  179. package/src/devcouncil/skills/registry.py +0 -330
  180. package/src/devcouncil/storage/__init__.py +0 -0
  181. package/src/devcouncil/storage/db.py +0 -147
  182. package/src/devcouncil/storage/models.py +0 -204
  183. package/src/devcouncil/storage/native.py +0 -557
  184. package/src/devcouncil/storage/repositories.py +0 -485
  185. package/src/devcouncil/telemetry/__init__.py +0 -0
  186. package/src/devcouncil/telemetry/cost.py +0 -140
  187. package/src/devcouncil/telemetry/model_pricing.yaml +0 -48
  188. package/src/devcouncil/telemetry/pricing.py +0 -28
  189. package/src/devcouncil/telemetry/traces.py +0 -146
  190. package/src/devcouncil/telemetry/tracker.py +0 -52
  191. package/src/devcouncil/ui/__init__.py +0 -1
  192. package/src/devcouncil/ui/dashboard.py +0 -423
  193. package/src/devcouncil/utils/__init__.py +0 -1
  194. package/src/devcouncil/utils/redaction.py +0 -147
  195. package/src/devcouncil/utils/subprocess_env.py +0 -69
  196. package/src/devcouncil/verification/__init__.py +0 -1
  197. package/src/devcouncil/verification/acceptance_compiler.py +0 -125
  198. package/src/devcouncil/verification/ad_hoc_check.py +0 -129
  199. package/src/devcouncil/verification/diff_coverage.py +0 -353
  200. package/src/devcouncil/verification/implementation_reviewer.py +0 -55
  201. package/src/devcouncil/verification/next_actions.py +0 -189
  202. package/src/devcouncil/verification/sandbox.py +0 -178
  203. package/src/devcouncil/verification/test_resolver.py +0 -91
  204. package/src/devcouncil/verification/verifier.py +0 -1548
  205. package/uv.lock +0 -1226
@@ -1,2122 +0,0 @@
1
- import asyncio
2
- import hashlib
3
- import json
4
- import os
5
- import subprocess
6
- import sys
7
- from pathlib import Path
8
- from mcp.server import Server
9
- from mcp.server.stdio import stdio_server
10
- from mcp.types import Tool, TextContent, Resource
11
- from pydantic import AnyUrl
12
- from devcouncil.storage.db import get_db
13
- from devcouncil.storage.repositories import (
14
- TaskRepository,
15
- ArtifactGraphRepository,
16
- StateRepository,
17
- RequirementRepository,
18
- EvidenceRepository,
19
- GapRepository,
20
- )
21
- from devcouncil.storage.native import (
22
- TaskLeaseRepository,
23
- ShellCommandRepository,
24
- FileChangeRepository,
25
- VerificationRunRepository,
26
- CorrectionManifestRepository,
27
- )
28
- from devcouncil.domain.evidence import CommandResult, DiffEvidence, DiffCoverageEvidence, TestEvidence
29
- from devcouncil.verification.next_actions import split_next_actions
30
- from devcouncil.reporting.report_builder import ReportBuilder
31
- from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
32
- from devcouncil.execution.hook_policy import HookPolicy
33
- from devcouncil.execution.prompt_builder import PromptBuilder
34
- from devcouncil.utils.subprocess_env import clean_subprocess_env
35
- from devcouncil.telemetry.traces import read_trace_events
36
- from devcouncil.indexing.ast_matcher import AstMatcher
37
- from devcouncil.indexing.lsp import LspInspector
38
- from devcouncil.app.project_status import compute_phase
39
- from devcouncil.live.cards import filter_cards, get_card, load_cards
40
- from devcouncil.live.repair_prompt import build_bulk_live_repair_prompt, build_live_repair_prompt
41
- from devcouncil.integrations.check import integration_status_summary
42
- from devcouncil.live.summary import live_review_summary
43
-
44
- app = Server("devcouncil")
45
- _DB_REQUIRED_TOOLS = {
46
- "devcouncil_status",
47
- "devcouncil_report",
48
- "devcouncil_get_task",
49
- "devcouncil_list_tasks",
50
- "devcouncil_get_gaps",
51
- "devcouncil_get_next_actions",
52
- "devcouncil_get_task_provenance",
53
- "devcouncil_list_leases",
54
- "devcouncil_renew_lease",
55
- "devcouncil_get_prompt",
56
- "devcouncil_tail_trace",
57
- "devcouncil_policy_check_write",
58
- "devcouncil_graph_context",
59
- "devcouncil_prepare_execution",
60
- "devcouncil_checkout_task",
61
- "devcouncil_release_task",
62
- "devcouncil_update_task_scope",
63
- "devcouncil_append_evidence",
64
- "devcouncil_record_command",
65
- "devcouncil_write_file",
66
- "devcouncil_apply_patch",
67
- "devcouncil_verify_task",
68
- "devcouncil_handoff_agent",
69
- "devcouncil_get_evidence",
70
- "devcouncil_run_command",
71
- "devcouncil_next_task",
72
- }
73
- _CLI_ALLOWED_ROOTS = {"status", "tasks", "report", "map", "prompt", "show", "trace", "lsp", "ast", "verify"}
74
- _CLI_FORBIDDEN_FLAGS = {"--project-root", "--github", "--github-pr-comment", "--gitlab-pr-comment"}
75
- _CLI_TIMEOUT_SECONDS = 120
76
- _CLI_OUTPUT_LIMIT = 20_000
77
- # Allowed values for devcouncil_record_command.status (validated, not free-text).
78
- _RECORD_COMMAND_STATUSES = {"started", "finished", "failed", "blocked"}
79
-
80
-
81
- def _forbidden_cli_flags(args: list[str]) -> list[str]:
82
- forbidden: set[str] = set()
83
- for arg in args:
84
- for flag in _CLI_FORBIDDEN_FLAGS:
85
- if arg == flag or arg.startswith(f"{flag}="):
86
- forbidden.add(flag)
87
- return sorted(forbidden)
88
-
89
-
90
- def _truncate_text(value: str | bytes | None, limit: int = _CLI_OUTPUT_LIMIT) -> tuple[str, bool]:
91
- if value is None:
92
- return "", False
93
- if isinstance(value, bytes):
94
- value = value.decode("utf-8", errors="replace")
95
- if len(value) <= limit:
96
- return value, False
97
- marker = f"\n...[truncated to {limit} characters]"
98
- return value[:limit] + marker, True
99
-
100
-
101
- def _json_text(payload: dict[str, object]) -> list[TextContent]:
102
- return [TextContent(type="text", text=json.dumps(payload, indent=2))]
103
-
104
-
105
- def _is_git_repo(root: Path) -> bool:
106
- try:
107
- result = subprocess.run(
108
- ["git", "rev-parse", "--is-inside-work-tree"],
109
- cwd=root, capture_output=True, text=True,
110
- )
111
- return result.returncode == 0 and result.stdout.strip() == "true"
112
- except Exception:
113
- return False
114
-
115
-
116
- def _read_log_file(path: str | None) -> str:
117
- """Best-effort read of a persisted stdout/stderr log; tolerate a missing file."""
118
- if not path:
119
- return ""
120
- try:
121
- return Path(path).read_text(encoding="utf-8", errors="replace")
122
- except OSError:
123
- return ""
124
-
125
-
126
- def _git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, object]:
127
- """Compute a (optionally path-scoped, optionally staged) git diff.
128
-
129
- Returns {ok, files:[{path,status,additions,deletions}], unified_diff (truncated),
130
- truncated}. Never raises — a git failure is reported as an empty diff with the
131
- stderr in an error field so the agent can act on it."""
132
- diff_args = ["git", "diff"]
133
- numstat_args = ["git", "diff", "--numstat"]
134
- namestatus_args = ["git", "diff", "--name-status"]
135
- if staged:
136
- for args in (diff_args, numstat_args, namestatus_args):
137
- args.append("--cached")
138
- if paths:
139
- for args in (diff_args, numstat_args, namestatus_args):
140
- args.append("--")
141
- args.extend(paths)
142
-
143
- def _run(args: list[str]) -> subprocess.CompletedProcess[str]:
144
- return subprocess.run(
145
- args, cwd=root, capture_output=True, text=True,
146
- encoding="utf-8", errors="replace", timeout=_CLI_TIMEOUT_SECONDS,
147
- )
148
-
149
- try:
150
- diff_proc = _run(diff_args)
151
- numstat_proc = _run(numstat_args)
152
- namestatus_proc = _run(namestatus_args)
153
- except (OSError, subprocess.TimeoutExpired) as exc:
154
- return {"ok": False, "files": [], "unified_diff": "", "truncated": False, "error": str(exc)}
155
-
156
- status_by_path: dict[str, str] = {}
157
- for line in namestatus_proc.stdout.splitlines():
158
- parts = line.split("\t")
159
- if len(parts) >= 2:
160
- status_by_path[parts[-1].replace("\\", "/")] = parts[0]
161
-
162
- files: list[dict[str, object]] = []
163
- for line in numstat_proc.stdout.splitlines():
164
- parts = line.split("\t")
165
- if len(parts) < 3:
166
- continue
167
- added_str, deleted_str, file_path = parts[0], parts[1], parts[-1]
168
- file_path = file_path.replace("\\", "/")
169
- files.append({
170
- "path": file_path,
171
- "status": status_by_path.get(file_path, "M"),
172
- "additions": int(added_str) if added_str.isdigit() else 0,
173
- "deletions": int(deleted_str) if deleted_str.isdigit() else 0,
174
- })
175
-
176
- unified_diff, truncated = _truncate_text(diff_proc.stdout)
177
- return {"ok": True, "files": files, "unified_diff": unified_diff, "truncated": truncated, "staged": staged}
178
-
179
-
180
- def _within_root(root: Path, rel_or_abs: str) -> Path | None:
181
- """Resolve a path against the project root and confirm it stays inside it.
182
-
183
- Returns the absolute resolved path, or None when the path escapes the project
184
- (a containment violation — a write tool must refuse it)."""
185
- raw = rel_or_abs.strip().strip('"').replace("\\", "/")
186
- try:
187
- candidate = Path(raw)
188
- resolved = candidate.resolve() if candidate.is_absolute() else (root / raw).resolve()
189
- resolved.relative_to(root.resolve())
190
- return resolved
191
- except (OSError, ValueError):
192
- return None
193
-
194
-
195
- def _diff_target_paths(unified_diff: str) -> list[str]:
196
- """Extract EVERY repo-relative file a unified diff touches — both sides.
197
-
198
- Captures pre- and post-image paths from ``---``/``+++`` hunk headers AND the
199
- ``rename from/to`` / ``copy from/to`` lines (a pure rename has no hunk headers, so
200
- its source would otherwise escape the policy check — letting a protected file be
201
- moved out of scope). Handles paths with spaces and git's C-quoting. Every target is
202
- then policy-checked before the patch is applied."""
203
- targets: list[str] = []
204
- seen: set[str] = set()
205
-
206
- def _clean(token: str) -> str | None:
207
- token = token.strip()
208
- if len(token) >= 2 and token.startswith('"') and token.endswith('"'):
209
- try:
210
- token = token[1:-1].encode("utf-8").decode("unicode_escape")
211
- except Exception:
212
- token = token[1:-1]
213
- if not token or token == "/dev/null":
214
- return None
215
- if token[:2] in ("a/", "b/"):
216
- token = token[2:]
217
- return token or None
218
-
219
- def _add(path: str | None) -> None:
220
- if path and path not in seen:
221
- seen.add(path)
222
- targets.append(path)
223
-
224
- for line in unified_diff.splitlines():
225
- if line.startswith("--- ") or line.startswith("+++ "):
226
- _add(_clean(line[4:]))
227
- elif line.startswith(("rename from ", "rename to ", "copy from ", "copy to ")):
228
- _add(_clean(line.split(" ", 2)[2]))
229
- elif line.startswith("diff --git "):
230
- # Fallback for metadata-only changes (e.g. mode change) that have no hunk or
231
- # rename lines; best-effort split handles the common no-space case.
232
- parts = line[len("diff --git "):].split()
233
- if len(parts) == 2:
234
- _add(_clean(parts[0]))
235
- _add(_clean(parts[1]))
236
- return targets
237
-
238
-
239
- def _lease_ttl_seconds(root: Path) -> int:
240
- """Default MCP lease TTL from config (so a crashed agent's lease auto-expires)."""
241
- try:
242
- from devcouncil.app.config import load_config
243
-
244
- return max(0, int(load_config(root).execution.lease_ttl_seconds))
245
- except Exception:
246
- return 1800
247
-
248
-
249
- def _load_router(root: Path):
250
- """Build a ModelRouter from project config, or return None when no provider key
251
- is configured. When present, the verifier runs DevCouncil's strong compiled
252
- per-criterion acceptance checks; when None, it falls back to coarse mode (which
253
- the verify response now reports explicitly so the agent is never misled)."""
254
- try:
255
- from devcouncil.app.config import load_config, get_api_key
256
- from devcouncil.llm.provider import create_provider, validate_model_provider
257
- from devcouncil.llm.router import ModelRouter
258
-
259
- config = load_config(root)
260
- validate_model_provider(config.models.provider)
261
- api_key = get_api_key(config.models.provider, root)
262
- provider = create_provider(config.models.provider, api_key, project_root=root)
263
- role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
264
- return ModelRouter(provider, role_config, project_root=root)
265
- except Exception:
266
- return None
267
-
268
-
269
- def _error_text(message: str, *, code: str = "error", **details: object) -> list[TextContent]:
270
- return _json_text({"ok": False, "error": message, "code": code, **details})
271
-
272
-
273
- def _normalize_arguments(arguments: object) -> dict:
274
- return arguments if isinstance(arguments, dict) else {}
275
-
276
-
277
- def _int_argument(arguments: dict, name: str, default: int, *, minimum: int, maximum: int) -> int:
278
- value = arguments.get(name, default)
279
- if not isinstance(value, int) or isinstance(value, bool):
280
- value = default
281
- return max(minimum, min(value, maximum))
282
-
283
-
284
- def _optional_string_argument(arguments: dict, name: str) -> str | None:
285
- value = arguments.get(name)
286
- if value is None:
287
- return None
288
- return value if isinstance(value, str) else ""
289
-
290
-
291
- def _optional_string_list_argument(arguments: dict, name: str) -> tuple[list[str], list[TextContent] | None]:
292
- value = arguments.get(name)
293
- if value is None:
294
- return [], None
295
- if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
296
- return [], _error_text(f"{name} must be a string array", code="invalid_arguments", argument=name)
297
- return value, None
298
-
299
-
300
- def _required_string_argument(arguments: dict, name: str) -> tuple[str | None, list[TextContent] | None]:
301
- value = arguments.get(name)
302
- if value is None or value == "":
303
- return None, _error_text(f"Missing {name}", code="missing_argument", argument=name)
304
- if not isinstance(value, str):
305
- return None, _error_text(f"{name} must be a string", code="invalid_arguments", argument=name)
306
- return value, None
307
-
308
-
309
- def _run_cli_command(args: list[str], root: Path) -> dict[str, object]:
310
- command = [sys.executable, "-m", "devcouncil", *args, "--project-root", str(root)]
311
- try:
312
- result = subprocess.run(
313
- command,
314
- cwd=root,
315
- capture_output=True,
316
- text=True,
317
- encoding="utf-8",
318
- errors="replace",
319
- timeout=_CLI_TIMEOUT_SECONDS,
320
- )
321
- stdout, stdout_truncated = _truncate_text(result.stdout)
322
- stderr, stderr_truncated = _truncate_text(result.stderr)
323
- return {
324
- "ok": result.returncode == 0,
325
- "returncode": result.returncode,
326
- "stdout": stdout,
327
- "stderr": stderr,
328
- "stdout_truncated": stdout_truncated,
329
- "stderr_truncated": stderr_truncated,
330
- "timed_out": False,
331
- }
332
- except subprocess.TimeoutExpired as exc:
333
- stdout, stdout_truncated = _truncate_text(exc.output)
334
- stderr, stderr_truncated = _truncate_text(exc.stderr)
335
- return {
336
- "ok": False,
337
- "returncode": None,
338
- "stdout": stdout,
339
- "stderr": stderr,
340
- "stdout_truncated": stdout_truncated,
341
- "stderr_truncated": stderr_truncated,
342
- "timed_out": True,
343
- "timeout_seconds": _CLI_TIMEOUT_SECONDS,
344
- }
345
-
346
-
347
- def _project_root() -> Path:
348
- configured = os.environ.get("DEVCOUNCIL_PROJECT_ROOT")
349
- return Path(configured).expanduser().resolve() if configured else Path(".")
350
-
351
-
352
- def _is_secret_path(root: Path, rel_or_abs: str) -> bool:
353
- """True when a path matches a protected secret/credential glob.
354
-
355
- Reuses the shared SECRET_PATH_PATTERNS (the single source of truth in the policy
356
- engine) so read tools refuse exactly the same files the write gate refuses — an
357
- MCP agent must never be able to exfiltrate a credential through a read tool."""
358
- from devcouncil.execution.policy_engine import SECRET_PATH_PATTERNS
359
-
360
- normalized = rel_or_abs.strip().strip('"').replace("\\", "/")
361
- try:
362
- candidate = Path(normalized)
363
- if candidate.is_absolute():
364
- resolved = candidate.resolve()
365
- try:
366
- normalized = resolved.relative_to(root.resolve()).as_posix()
367
- except ValueError:
368
- normalized = resolved.as_posix()
369
- except OSError:
370
- pass
371
- import fnmatch as _fnmatch
372
-
373
- return any(_fnmatch.fnmatch(normalized, pattern) for pattern in SECRET_PATH_PATTERNS)
374
-
375
-
376
- def _allowed_next_tools(status: str, has_blocking_gaps: bool) -> list[str]:
377
- """Compute the self-describing next-tool contract from task state.
378
-
379
- Replaces a hardcoded list so an agent is steered by what the task actually
380
- needs: a verified task only needs releasing; a blocked/running task gets the
381
- full read->edit->test loop; a planned task should check out first."""
382
- if status == "verified":
383
- return ["devcouncil_release_task"]
384
- if status == "done":
385
- return []
386
- if status in {"running", "blocked"} or has_blocking_gaps:
387
- return [
388
- "devcouncil_read_file",
389
- "devcouncil_get_evidence",
390
- "devcouncil_get_diff",
391
- "devcouncil_run_command",
392
- "devcouncil_apply_patch",
393
- "devcouncil_write_file",
394
- "devcouncil_verify_task",
395
- ]
396
- # planned / ready and not yet leased: bootstrap by checking out.
397
- return [
398
- "devcouncil_checkout_task",
399
- "devcouncil_read_file",
400
- "devcouncil_get_diff",
401
- ]
402
-
403
-
404
- @app.list_tools()
405
- async def list_tools() -> list[Tool]:
406
- return [
407
- Tool(
408
- name="devcouncil_status",
409
- description="Get the current status of the DevCouncil project, including phase, tasks, and gaps.",
410
- inputSchema={
411
- "type": "object",
412
- "properties": {}
413
- }
414
- ),
415
- Tool(
416
- name="devcouncil_integration_status",
417
- description="Get read-only coding CLI integration status, capability rows, detected clients, and recommended executor.",
418
- inputSchema={"type": "object", "properties": {}},
419
- ),
420
- Tool(
421
- name="devcouncil_report",
422
- description="Get the full coverage report and a list of all requirements and blocking gaps.",
423
- inputSchema={
424
- "type": "object",
425
- "properties": {}
426
- }
427
- ),
428
- Tool(
429
- name="devcouncil_get_task",
430
- description="Get details, constraints, and requirements for a specific implementation task.",
431
- inputSchema={
432
- "type": "object",
433
- "properties": {
434
- "task_id": {
435
- "type": "string",
436
- "description": "The ID of the task, e.g. TASK-001"
437
- }
438
- },
439
- "required": ["task_id"]
440
- }
441
- ),
442
- Tool(
443
- name="devcouncil_get_gaps",
444
- description=(
445
- "Read the persisted verification gaps for a task WITHOUT re-running "
446
- "verification. Cheap and idempotent — use it to resume after a "
447
- "reconnect or to inspect outstanding work before deciding to repair."
448
- ),
449
- inputSchema={
450
- "type": "object",
451
- "properties": {
452
- "task_id": {"type": "string"},
453
- "blocking_only": {"type": "boolean", "default": False},
454
- },
455
- "required": ["task_id"],
456
- },
457
- ),
458
- Tool(
459
- name="devcouncil_get_next_actions",
460
- description=(
461
- "Get the typed, machine-routable next-actions contract for a task from "
462
- "its persisted gaps, WITHOUT re-verifying. Returns blocking next_actions "
463
- "plus advisory_actions and the tools allowed next."
464
- ),
465
- inputSchema={
466
- "type": "object",
467
- "properties": {"task_id": {"type": "string"}},
468
- "required": ["task_id"],
469
- },
470
- ),
471
- Tool(
472
- name="devcouncil_get_task_provenance",
473
- description=(
474
- "Inspect the recorded audit trail for a task: gated file changes "
475
- "(write_file/apply_patch and hook events), verification runs, diff-coverage "
476
- "evidence (was the changed code actually exercised), and the latest "
477
- "correction manifest. Read-only — lets a developer or agent trust what "
478
- "actually happened on disk."
479
- ),
480
- inputSchema={
481
- "type": "object",
482
- "properties": {"task_id": {"type": "string"}},
483
- "required": ["task_id"],
484
- },
485
- ),
486
- Tool(
487
- name="devcouncil_live_review",
488
- description="Get live coding-agent review status, pending signals, critique-card counts, and blockers.",
489
- inputSchema={
490
- "type": "object",
491
- "properties": {
492
- "task_id": {
493
- "type": "string",
494
- "description": "Optional task scope for live-review blocker calculation.",
495
- }
496
- },
497
- },
498
- ),
499
- Tool(
500
- name="devcouncil_live_cards",
501
- description="List live-review critique cards with optional task, status, verdict, and client filters.",
502
- inputSchema={
503
- "type": "object",
504
- "properties": {
505
- "task_id": {
506
- "type": "string",
507
- "description": "Optional task scope for critique cards.",
508
- },
509
- "status": {
510
- "type": "string",
511
- "enum": ["open", "resolved", "ignored"],
512
- "description": "Optional card status filter.",
513
- },
514
- "verdict": {
515
- "type": "string",
516
- "enum": ["approved", "concerns", "critical"],
517
- "description": "Optional card verdict filter.",
518
- },
519
- "client": {
520
- "type": "string",
521
- "description": "Optional coding-agent client filter.",
522
- },
523
- "limit": {
524
- "type": "integer",
525
- "minimum": 1,
526
- "maximum": 200,
527
- "default": 20,
528
- },
529
- },
530
- },
531
- ),
532
- Tool(
533
- name="devcouncil_live_repair_prompt",
534
- description="Generate a ready-to-paste repair prompt for a live-review critique card.",
535
- inputSchema={
536
- "type": "object",
537
- "properties": {
538
- "card_id": {
539
- "type": "string",
540
- "description": "The critique card ID, e.g. CARD-abc123.",
541
- }
542
- },
543
- "required": ["card_id"],
544
- },
545
- ),
546
- Tool(
547
- name="devcouncil_live_repair_all",
548
- description="Generate one repair prompt for all blocking live-review critique cards in scope.",
549
- inputSchema={
550
- "type": "object",
551
- "properties": {
552
- "task_id": {
553
- "type": "string",
554
- "description": "Optional task scope for blocking live-review cards.",
555
- }
556
- },
557
- },
558
- ),
559
- Tool(
560
- name="devcouncil_list_tasks",
561
- 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.",
562
- inputSchema={
563
- "type": "object",
564
- "properties": {
565
- "status": {"type": "string", "description": "Optional status filter (e.g. planned, running, blocked, verified, done)."},
566
- "limit": {"type": "integer", "description": "Max tasks to return (default 100, max 500)."},
567
- "offset": {"type": "integer", "description": "Number of tasks to skip (default 0)."},
568
- },
569
- },
570
- ),
571
- Tool(
572
- name="devcouncil_get_prompt",
573
- description="Get the raw implementation prompt for a DevCouncil task.",
574
- inputSchema={
575
- "type": "object",
576
- "properties": {
577
- "task_id": {"type": "string", "description": "The ID of the task, e.g. TASK-001"},
578
- },
579
- "required": ["task_id"],
580
- },
581
- ),
582
- Tool(
583
- name="devcouncil_tail_trace",
584
- description="Return recent DevCouncil trace events as JSON.",
585
- inputSchema={
586
- "type": "object",
587
- "properties": {
588
- "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 20},
589
- },
590
- },
591
- ),
592
- Tool(
593
- name="devcouncil_policy_check_write",
594
- description="Check whether a file write is allowed for a task or the active running task.",
595
- inputSchema={
596
- "type": "object",
597
- "properties": {
598
- "path": {"type": "string", "description": "Repository-relative or absolute path to check."},
599
- "task_id": {"type": "string", "description": "Optional task ID. Defaults to the running task."},
600
- },
601
- "required": ["path"],
602
- },
603
- ),
604
- Tool(
605
- name="devcouncil_graph_context",
606
- description="Get optional code-review-graph structural context for changed or planned files.",
607
- inputSchema={
608
- "type": "object",
609
- "properties": {
610
- "files": {
611
- "type": "array",
612
- "items": {"type": "string"},
613
- "description": "Repository-relative files to contextualize.",
614
- }
615
- },
616
- },
617
- ),
618
- Tool(
619
- name="devcouncil_lsp_status",
620
- description="Return detected language servers and starter LSP initialize payloads.",
621
- inputSchema={"type": "object", "properties": {}},
622
- ),
623
- Tool(
624
- name="devcouncil_ast_match",
625
- description="Search code symbols structurally using optional tree-sitter support and deterministic fallbacks.",
626
- inputSchema={
627
- "type": "object",
628
- "properties": {
629
- "query": {"type": "string"},
630
- "language": {"type": "string"},
631
- "kind": {"type": "string"},
632
- "limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100},
633
- },
634
- },
635
- ),
636
- Tool(
637
- name="devcouncil_cli",
638
- description="Run a safe DevCouncil CLI command for status, tasks, report, map, prompt, show, trace, lsp, or ast.",
639
- inputSchema={
640
- "type": "object",
641
- "properties": {
642
- "args": {
643
- "type": "array",
644
- "items": {"type": "string"},
645
- "description": "Arguments after the dev command, for example ['status','--json'].",
646
- }
647
- },
648
- "required": ["args"],
649
- },
650
- ),
651
- Tool(
652
- name="devcouncil_prepare_execution",
653
- description="Return a task prompt plus planned files and allowed commands for external execution tooling.",
654
- inputSchema={
655
- "type": "object",
656
- "properties": {
657
- "task_id": {"type": "string", "description": "The ID of the task, e.g. TASK-001"},
658
- },
659
- "required": ["task_id"],
660
- },
661
- ),
662
- Tool(
663
- name="devcouncil_checkout_task",
664
- description="Acquire a task lease and return scope for MCP write tools.",
665
- inputSchema={
666
- "type": "object",
667
- "properties": {
668
- "task_id": {"type": "string"},
669
- "client_id": {"type": "string"},
670
- "agent": {"type": "string"},
671
- "force": {"type": "boolean", "default": False},
672
- },
673
- "required": ["task_id", "client_id"],
674
- },
675
- ),
676
- Tool(
677
- name="devcouncil_release_task",
678
- description="Release a task lease using its token.",
679
- inputSchema={
680
- "type": "object",
681
- "properties": {
682
- "task_id": {"type": "string"},
683
- "lease_token": {"type": "string"},
684
- },
685
- "required": ["task_id", "lease_token"],
686
- },
687
- ),
688
- Tool(
689
- name="devcouncil_renew_lease",
690
- description=(
691
- "Extend a held task lease's TTL so a long-running agent does not lose it "
692
- "to expiry. Returns the new expires_at."
693
- ),
694
- inputSchema={
695
- "type": "object",
696
- "properties": {
697
- "task_id": {"type": "string"},
698
- "lease_token": {"type": "string"},
699
- "ttl_seconds": {"type": "integer"},
700
- },
701
- "required": ["task_id", "lease_token"],
702
- },
703
- ),
704
- Tool(
705
- name="devcouncil_list_leases",
706
- description=(
707
- "List task leases for fleet supervision — task_id, owner, agent, "
708
- "expires_at, and whether each is expired. Defaults to active leases."
709
- ),
710
- inputSchema={
711
- "type": "object",
712
- "properties": {"active_only": {"type": "boolean", "default": True}},
713
- },
714
- ),
715
- Tool(
716
- name="devcouncil_update_task_scope",
717
- description="Append unique expected tests or allowed commands for a leased task.",
718
- inputSchema={
719
- "type": "object",
720
- "properties": {
721
- "task_id": {"type": "string"},
722
- "lease_token": {"type": "string"},
723
- "expected_tests": {"type": "array", "items": {"type": "string"}},
724
- "allowed_commands": {"type": "array", "items": {"type": "string"}},
725
- },
726
- "required": ["task_id", "lease_token"],
727
- },
728
- ),
729
- Tool(
730
- name="devcouncil_append_evidence",
731
- description="Append command evidence for a leased task.",
732
- inputSchema={
733
- "type": "object",
734
- "properties": {
735
- "task_id": {"type": "string"},
736
- "lease_token": {"type": "string"},
737
- "command": {"type": "string"},
738
- "exit_code": {"type": "integer"},
739
- "summary": {"type": "string"},
740
- },
741
- "required": ["task_id", "lease_token", "command", "exit_code", "summary"],
742
- },
743
- ),
744
- Tool(
745
- name="devcouncil_record_command",
746
- description="Record a shell command event for a leased task.",
747
- inputSchema={
748
- "type": "object",
749
- "properties": {
750
- "task_id": {"type": "string"},
751
- "lease_token": {"type": "string"},
752
- "command": {"type": "string"},
753
- "status": {"type": "string", "enum": ["started", "finished", "failed", "blocked"]},
754
- "exit_code": {"type": "integer"},
755
- "reason": {"type": "string"},
756
- },
757
- "required": ["task_id", "lease_token", "command", "status"],
758
- },
759
- ),
760
- Tool(
761
- name="devcouncil_write_file",
762
- description=(
763
- "Write a file for a leased task through DevCouncil's policy gate. The write "
764
- "is checked against the task's scope BEFORE it lands (out-of-scope or "
765
- "protected paths are rejected), applied atomically, and recorded as a "
766
- "FileChangeEvent. Returns applied_files and rejected_files."
767
- ),
768
- inputSchema={
769
- "type": "object",
770
- "properties": {
771
- "task_id": {"type": "string"},
772
- "lease_token": {"type": "string"},
773
- "path": {"type": "string"},
774
- "content": {"type": "string"},
775
- },
776
- "required": ["task_id", "lease_token", "path", "content"],
777
- },
778
- ),
779
- Tool(
780
- name="devcouncil_apply_patch",
781
- description=(
782
- "Apply a unified diff for a leased task through DevCouncil's policy gate. "
783
- "EVERY target file is policy-checked first; if any is out of scope the whole "
784
- "patch is rejected (never partially applied). Applied atomically via git and "
785
- "each file recorded as a FileChangeEvent. Returns applied_files/rejected_files."
786
- ),
787
- inputSchema={
788
- "type": "object",
789
- "properties": {
790
- "task_id": {"type": "string"},
791
- "lease_token": {"type": "string"},
792
- "unified_diff": {"type": "string"},
793
- },
794
- "required": ["task_id", "lease_token", "unified_diff"],
795
- },
796
- ),
797
- Tool(
798
- name="devcouncil_verify_task",
799
- description="Run verification for a leased task (local sandbox).",
800
- inputSchema={
801
- "type": "object",
802
- "properties": {
803
- "task_id": {"type": "string"},
804
- "lease_token": {"type": "string"},
805
- "sandbox": {"type": "string", "enum": ["local"], "default": "local", "description": "Only 'local' is supported in this build."},
806
- },
807
- "required": ["task_id", "lease_token"],
808
- },
809
- ),
810
- Tool(
811
- name="devcouncil_handoff_agent",
812
- description="Hand off a task between coding CLI agents.",
813
- inputSchema={
814
- "type": "object",
815
- "properties": {
816
- "task_id": {"type": "string"},
817
- "lease_token": {"type": "string"},
818
- "from_agent": {"type": "string"},
819
- "to_agent": {"type": "string"},
820
- "instruction": {"type": "string"},
821
- },
822
- "required": ["task_id", "lease_token", "from_agent", "to_agent"],
823
- },
824
- ),
825
- Tool(
826
- name="devcouncil_read_file",
827
- description=(
828
- "Read a repository file (read-only, no lease required) so an MCP-only "
829
- "agent can inspect content before constructing a diff or overwriting it. "
830
- "Containment-checked against the project root and refuses secret/credential "
831
- "paths. Supports offset/limit or line_range windowing. Returns content "
832
- "(truncated), sha256, and line_count."
833
- ),
834
- inputSchema={
835
- "type": "object",
836
- "properties": {
837
- "path": {"type": "string", "description": "Repository-relative or absolute path inside the project."},
838
- "offset": {"type": "integer", "minimum": 0, "description": "0-based line offset to start from."},
839
- "limit": {"type": "integer", "minimum": 1, "description": "Max number of lines to return."},
840
- "line_range": {
841
- "type": "string",
842
- "description": "Inclusive 1-based line range like '10-40' (overrides offset/limit).",
843
- },
844
- },
845
- "required": ["path"],
846
- },
847
- ),
848
- Tool(
849
- name="devcouncil_get_diff",
850
- description=(
851
- "Return the working-tree diff for the project (requires a git repo). When "
852
- "task_id is given the diff is scoped to that task's planned/changed files. "
853
- "Set staged=true to include the staged (git diff --cached) changes. Returns "
854
- "per-file status with additions/deletions and the truncated unified diff."
855
- ),
856
- inputSchema={
857
- "type": "object",
858
- "properties": {
859
- "task_id": {"type": "string", "description": "Optional task to scope the diff to its files."},
860
- "paths": {
861
- "type": "array",
862
- "items": {"type": "string"},
863
- "description": "Optional explicit repo-relative paths to scope the diff to.",
864
- },
865
- "staged": {"type": "boolean", "default": False, "description": "Include staged changes."},
866
- },
867
- },
868
- ),
869
- Tool(
870
- name="devcouncil_get_evidence",
871
- description=(
872
- "Read persisted CommandResult evidence for a task and inline the truncated "
873
- "stdout/stderr from the stored log files (best-effort; tolerates missing "
874
- "files). Pairs with verification to close the diagnose leg of the loop."
875
- ),
876
- inputSchema={
877
- "type": "object",
878
- "properties": {
879
- "task_id": {"type": "string"},
880
- "command": {"type": "string", "description": "Optional substring filter on the recorded command."},
881
- "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
882
- },
883
- "required": ["task_id"],
884
- },
885
- ),
886
- Tool(
887
- name="devcouncil_run_command",
888
- description=(
889
- "Run a command for a leased task through DevCouncil's allowlist gate. The "
890
- "command must pass the task's allowed_commands policy (same gate as the "
891
- "hooks); otherwise it is refused and nothing runs. Executed with a clean "
892
- "subprocess env and a timeout, recorded as a ShellCommandEvent. Returns "
893
- "exit_code and truncated stdout/stderr."
894
- ),
895
- inputSchema={
896
- "type": "object",
897
- "properties": {
898
- "task_id": {"type": "string"},
899
- "lease_token": {"type": "string"},
900
- "command": {"type": "string"},
901
- },
902
- "required": ["task_id", "lease_token", "command"],
903
- },
904
- ),
905
- Tool(
906
- name="devcouncil_list_agent_runs",
907
- description=(
908
- "List recorded coding-agent runs (from .devcouncil/runs/*/agent-run.json), "
909
- "newest first. Each entry includes run_id, task, agent, profile, status, "
910
- "started time, and an orphaned flag for runs still marked running whose "
911
- "manifest has gone stale (executor likely crashed). Read-only."
912
- ),
913
- inputSchema={
914
- "type": "object",
915
- "properties": {
916
- "status": {"type": "string", "description": "Optional status filter (e.g. running, finished, failed, timeout)."},
917
- "limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 20},
918
- },
919
- },
920
- ),
921
- Tool(
922
- name="devcouncil_get_run",
923
- description=(
924
- "Get the full manifest for a single coding-agent run plus a redacted "
925
- "transcript tail when a transcript/log file exists in the run directory. "
926
- "Includes the resolved CLI invocation and an orphaned flag. Read-only."
927
- ),
928
- inputSchema={
929
- "type": "object",
930
- "properties": {
931
- "run_id": {"type": "string", "description": "The run id to inspect."},
932
- },
933
- "required": ["run_id"],
934
- },
935
- ),
936
- Tool(
937
- name="devcouncil_next_task",
938
- description=(
939
- "Return the highest-priority task that is unblocked (its depends_on are "
940
- "satisfied) and has no active lease, so an autonomous agent can bootstrap "
941
- "deterministically instead of racing list_tasks. Includes a blocking-gap "
942
- "summary and a ready_to_checkout flag."
943
- ),
944
- inputSchema={
945
- "type": "object",
946
- "properties": {
947
- "client_id": {"type": "string", "description": "Optional client id (informational)."},
948
- "status": {"type": "string", "description": "Optional status filter (default planned/ready)."},
949
- },
950
- },
951
- ),
952
- ]
953
-
954
- @app.list_resources()
955
- async def list_resources() -> list[Resource]:
956
- """Expose the DevCouncil corpus as browsable/subscribable MCP resources, so a host
957
- can read the report, task graph, gaps, and live-review state without a tool call."""
958
- root = _project_root()
959
- resources: list[Resource] = [
960
- Resource(uri=AnyUrl("devcouncil://report"), name="DevCouncil report",
961
- description="Coverage report, requirement/task mapping, and blocking gaps.",
962
- mimeType="text/markdown"),
963
- Resource(uri=AnyUrl("devcouncil://tasks"), name="Tasks",
964
- description="All planned tasks with scope and status.", mimeType="application/json"),
965
- Resource(uri=AnyUrl("devcouncil://gaps"), name="Gaps",
966
- description="All open verification gaps.", mimeType="application/json"),
967
- Resource(uri=AnyUrl("devcouncil://cards"), name="Live review",
968
- description="Live-review summary: cards, signals, and blockers.",
969
- mimeType="application/json"),
970
- ]
971
- db = get_db(root)
972
- if db:
973
- with db.get_session() as session:
974
- for task in TaskRepository(session).get_all():
975
- resources.append(Resource(
976
- uri=AnyUrl(f"devcouncil://task/{task.id}"),
977
- name=f"Task {task.id}: {task.title}",
978
- description=f"Scope, status, and gaps for {task.id}.",
979
- mimeType="application/json",
980
- ))
981
- return resources
982
-
983
-
984
- @app.read_resource()
985
- async def read_resource(uri: AnyUrl) -> str:
986
- root = _project_root()
987
- db = get_db(root)
988
- key = str(uri).rstrip("/")
989
-
990
- if key == "devcouncil://report":
991
- if not db:
992
- return "DevCouncil is not initialized in this directory."
993
- with db.get_session() as session:
994
- graph = ArtifactGraphRepository(session).load_graph()
995
- return ReportBuilder.build_markdown(graph, live_review=live_review_summary(root))
996
- if key == "devcouncil://tasks":
997
- if not db:
998
- return json.dumps({"tasks": []})
999
- with db.get_session() as session:
1000
- tasks = [t.model_dump() for t in TaskRepository(session).get_all()]
1001
- return json.dumps({"tasks": tasks}, indent=2)
1002
- if key == "devcouncil://gaps":
1003
- if not db:
1004
- return json.dumps({"gaps": []})
1005
- with db.get_session() as session:
1006
- gaps = [g.model_dump() for g in GapRepository(session).get_all()]
1007
- return json.dumps({"gaps": gaps}, indent=2)
1008
- if key == "devcouncil://cards":
1009
- return json.dumps(live_review_summary(root), indent=2)
1010
- if key.startswith("devcouncil://task/"):
1011
- task_id = key.rsplit("/", 1)[-1]
1012
- if not db:
1013
- return json.dumps({"ok": False, "error": "not initialized"})
1014
- with db.get_session() as session:
1015
- task = TaskRepository(session).get_by_id(task_id)
1016
- if not task:
1017
- return json.dumps({"ok": False, "error": f"Task {task_id} not found."})
1018
- gaps = [g.model_dump() for g in GapRepository(session).get_all() if g.task_id == task_id]
1019
- return json.dumps({"task": task.model_dump(), "gaps": gaps}, indent=2)
1020
-
1021
- raise ValueError(f"Unknown resource: {uri}")
1022
-
1023
-
1024
- @app.call_tool()
1025
- async def call_tool(name: str, arguments: dict) -> list[TextContent]:
1026
- arguments = _normalize_arguments(arguments)
1027
- root = _project_root()
1028
- db = get_db(root)
1029
- if name in _DB_REQUIRED_TOOLS and not db:
1030
- return _error_text("DevCouncil not initialized in this directory.", code="not_initialized")
1031
-
1032
- if name == "devcouncil_integration_status":
1033
- return _json_text(integration_status_summary(root))
1034
-
1035
- if name == "devcouncil_status":
1036
- assert db is not None
1037
- with db.get_session() as session:
1038
- graph_repo = ArtifactGraphRepository(session)
1039
- graph = graph_repo.load_graph()
1040
- summary = graph.coverage_summary()
1041
- state = StateRepository(session).get_state()
1042
- phase = compute_phase(graph, state.current_phase if state else None)
1043
-
1044
- status_str = f"Phase: {phase}\n"
1045
- status_str += f"Requirements: {summary['total_requirements']} ({summary['requirements_without_tasks']} unmapped)\n"
1046
- status_str += f"Tasks: {summary['total_tasks']} ({summary['tasks_without_requirements']} orphaned)\n"
1047
- status_str += f"Gaps: {summary['total_gaps']} ({summary['blocking_gaps']} blocking)\n"
1048
-
1049
- return [TextContent(type="text", text=status_str)]
1050
-
1051
- elif name == "devcouncil_report":
1052
- assert db is not None
1053
- with db.get_session() as session:
1054
- graph_repo = ArtifactGraphRepository(session)
1055
- graph = graph_repo.load_graph()
1056
- markdown_report = ReportBuilder.build_markdown(graph, live_review=live_review_summary(root))
1057
- return [TextContent(type="text", text=markdown_report)]
1058
-
1059
- elif name == "devcouncil_live_review":
1060
- task_id = _optional_string_argument(arguments, "task_id")
1061
- if task_id == "":
1062
- return _error_text("task_id must be a string", code="invalid_arguments", argument="task_id")
1063
- return [TextContent(
1064
- type="text",
1065
- text=json.dumps(live_review_summary(root, task_id=task_id), indent=2),
1066
- )]
1067
-
1068
- elif name == "devcouncil_live_cards":
1069
- task_id = _optional_string_argument(arguments, "task_id")
1070
- status = _optional_string_argument(arguments, "status")
1071
- verdict = _optional_string_argument(arguments, "verdict")
1072
- client = _optional_string_argument(arguments, "client")
1073
- for arg_name, value in [
1074
- ("task_id", task_id),
1075
- ("status", status),
1076
- ("verdict", verdict),
1077
- ("client", client),
1078
- ]:
1079
- if value == "":
1080
- return _error_text(f"{arg_name} must be a string", code="invalid_arguments", argument=arg_name)
1081
-
1082
- limit = _int_argument(arguments, "limit", 20, minimum=1, maximum=200)
1083
- filtered, filter_error, argument = filter_cards(
1084
- load_cards(root),
1085
- task_id=task_id,
1086
- status=status,
1087
- verdict=verdict,
1088
- client=client,
1089
- )
1090
- if filter_error:
1091
- return _error_text(filter_error, code="invalid_arguments", argument=argument)
1092
-
1093
- total = len(filtered)
1094
- return [TextContent(
1095
- type="text",
1096
- text=json.dumps({
1097
- "cards": [card.model_dump() for card in filtered[:limit]],
1098
- "filters": {
1099
- "task_id": task_id,
1100
- "status": status,
1101
- "verdict": verdict,
1102
- "client": client,
1103
- },
1104
- "limit": limit,
1105
- "total": total,
1106
- }, indent=2),
1107
- )]
1108
-
1109
- elif name == "devcouncil_live_repair_prompt":
1110
- card_id, arg_error = _required_string_argument(arguments, "card_id")
1111
- if arg_error:
1112
- return arg_error
1113
- assert card_id is not None
1114
- card = get_card(root, card_id)
1115
- if not card:
1116
- return _error_text(f"Critique card {card_id} not found.", code="not_found", card_id=card_id)
1117
- return [TextContent(
1118
- type="text",
1119
- text=json.dumps({
1120
- "card": card.model_dump(),
1121
- "prompt": build_live_repair_prompt(root, card),
1122
- }, indent=2),
1123
- )]
1124
-
1125
- elif name == "devcouncil_live_repair_all":
1126
- task_id = _optional_string_argument(arguments, "task_id")
1127
- if task_id == "":
1128
- return _error_text("task_id must be a string", code="invalid_arguments", argument="task_id")
1129
- summary = live_review_summary(root, task_id=task_id)
1130
- cards = [
1131
- get_card(root, item["id"])
1132
- for item in summary["blocking_cards"]
1133
- if isinstance(item.get("id"), str)
1134
- ]
1135
- resolved_cards = [card for card in cards if card is not None]
1136
- return [TextContent(
1137
- type="text",
1138
- text=json.dumps({
1139
- "scope_task_id": summary["scope_task_id"],
1140
- "cards": [card.model_dump() for card in resolved_cards],
1141
- "prompt": build_bulk_live_repair_prompt(root, resolved_cards),
1142
- }, indent=2),
1143
- )]
1144
-
1145
- elif name == "devcouncil_get_task":
1146
- assert db is not None
1147
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1148
- if arg_error:
1149
- return arg_error
1150
- assert task_id is not None
1151
-
1152
- with db.get_session() as session:
1153
- task_repo = TaskRepository(session)
1154
- task = task_repo.get_by_id(task_id)
1155
- if not task:
1156
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=str(task_id))
1157
-
1158
- return [TextContent(type="text", text=task.model_dump_json(indent=2))]
1159
-
1160
- elif name == "devcouncil_get_gaps":
1161
- assert db is not None
1162
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1163
- if arg_error:
1164
- return arg_error
1165
- blocking_only = bool(arguments.get("blocking_only", False))
1166
- with db.get_session() as session:
1167
- gaps = [g for g in GapRepository(session).get_all() if g.task_id == task_id]
1168
- if blocking_only:
1169
- gaps = [g for g in gaps if g.blocking]
1170
- return _json_text({
1171
- "ok": True,
1172
- "task_id": task_id,
1173
- "gaps": [g.model_dump() for g in gaps],
1174
- "blocking_count": sum(1 for g in gaps if g.blocking),
1175
- })
1176
-
1177
- elif name == "devcouncil_get_next_actions":
1178
- assert db is not None
1179
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1180
- if arg_error:
1181
- return arg_error
1182
- assert task_id is not None
1183
- with db.get_session() as session:
1184
- gaps = [g for g in GapRepository(session).get_all() if g.task_id == task_id]
1185
- task = TaskRepository(session).get_by_id(task_id)
1186
- blocking_actions, advisory_actions = split_next_actions(gaps)
1187
- has_blocking = any(g.blocking for g in gaps)
1188
- return _json_text({
1189
- "ok": True,
1190
- "task_id": task_id,
1191
- "next_actions": [a.model_dump() for a in blocking_actions],
1192
- "advisory_actions": [a.model_dump() for a in advisory_actions],
1193
- # Self-describing loop: computed from the task's status + blocking gaps so
1194
- # the agent is steered toward what this task actually needs next.
1195
- "allowed_next_tools": _allowed_next_tools(task.status if task else "planned", has_blocking),
1196
- })
1197
-
1198
- elif name == "devcouncil_get_task_provenance":
1199
- assert db is not None
1200
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1201
- if arg_error:
1202
- return arg_error
1203
- assert task_id is not None
1204
- with db.get_session() as session:
1205
- file_changes = [r.model_dump() for r in FileChangeRepository(session).list_for_task(task_id)]
1206
- verification_runs = [r.model_dump() for r in VerificationRunRepository(session).list_for_task(task_id)]
1207
- coverage = [
1208
- ev.model_dump()
1209
- for ev in EvidenceRepository(session).get_all()
1210
- if isinstance(ev, DiffCoverageEvidence) and ev.task_id == task_id
1211
- ]
1212
- correction_manifest = CorrectionManifestRepository(session).latest_for_task(task_id)
1213
- return _json_text({
1214
- "ok": True,
1215
- "task_id": task_id,
1216
- "file_changes": file_changes,
1217
- "verification_runs": verification_runs,
1218
- "diff_coverage": coverage,
1219
- "latest_correction_manifest": correction_manifest.model_dump() if correction_manifest else None,
1220
- })
1221
-
1222
- elif name == "devcouncil_list_tasks":
1223
- assert db is not None
1224
- status_filter = _optional_string_argument(arguments, "status")
1225
- if status_filter == "":
1226
- return _error_text("status must be a string", code="invalid_arguments", argument="status")
1227
- limit = _int_argument(arguments, "limit", 100, minimum=1, maximum=500)
1228
- offset = _int_argument(arguments, "offset", 0, minimum=0, maximum=1_000_000)
1229
- with db.get_session() as session:
1230
- all_tasks = TaskRepository(session).get_all()
1231
- if status_filter:
1232
- all_tasks = [t for t in all_tasks if t.status == status_filter]
1233
- total = len(all_tasks)
1234
- window = all_tasks[offset:offset + limit]
1235
- return _json_text({
1236
- "tasks": [task.model_dump() for task in window],
1237
- "total": total,
1238
- "offset": offset,
1239
- "limit": limit,
1240
- "returned": len(window),
1241
- })
1242
-
1243
- elif name == "devcouncil_get_prompt":
1244
- assert db is not None
1245
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1246
- if arg_error:
1247
- return arg_error
1248
- assert task_id is not None
1249
-
1250
- with db.get_session() as session:
1251
- task_repo = TaskRepository(session)
1252
- req_repo = RequirementRepository(session)
1253
- task = task_repo.get_by_id(task_id)
1254
- if not task:
1255
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=str(task_id))
1256
- prompt = PromptBuilder(root).build_task_prompt(task, req_repo.get_all())
1257
- return [TextContent(type="text", text=prompt)]
1258
-
1259
- elif name == "devcouncil_tail_trace":
1260
- limit = _int_argument(arguments, "limit", 20, minimum=1, maximum=200)
1261
- events = list(read_trace_events(root))[-limit:]
1262
-
1263
- return [TextContent(
1264
- type="text",
1265
- text=json.dumps({"events": [event.model_dump(by_alias=True) for event in events]}, indent=2),
1266
- )]
1267
-
1268
- elif name == "devcouncil_policy_check_write":
1269
- assert db is not None
1270
- path, arg_error = _required_string_argument(arguments, "path")
1271
- if arg_error:
1272
- return arg_error
1273
- assert path is not None
1274
- task_id = _optional_string_argument(arguments, "task_id")
1275
- if task_id == "":
1276
- return _error_text("task_id must be a string", code="invalid_arguments", argument="task_id")
1277
- with db.get_session() as session:
1278
- task_repo = TaskRepository(session)
1279
- if task_id:
1280
- task = task_repo.get_by_id(task_id)
1281
- else:
1282
- running = [task for task in task_repo.get_all() if task.status == "running"]
1283
- task = running[0] if running else None
1284
- decision = HookPolicy(project_root=root).evaluate_file_write(path, task)
1285
- return [TextContent(type="text", text=json.dumps({
1286
- "action": decision.action,
1287
- "allowed": decision.allowed,
1288
- "reason": decision.reason,
1289
- "target": decision.target,
1290
- "task_id": task.id if task else None,
1291
- }, indent=2))]
1292
-
1293
- elif name == "devcouncil_graph_context":
1294
- files = arguments.get("files", [])
1295
- if not isinstance(files, list):
1296
- files = []
1297
- context = CodeReviewGraphAdapter(root).get_context([file for file in files if isinstance(file, str)])
1298
- return [TextContent(type="text", text=context.model_dump_json(indent=2))]
1299
-
1300
- elif name == "devcouncil_lsp_status":
1301
- return [TextContent(type="text", text=LspInspector(root).summary_json())]
1302
-
1303
- elif name == "devcouncil_ast_match":
1304
- query = _optional_string_argument(arguments, "query")
1305
- language = _optional_string_argument(arguments, "language")
1306
- kind = _optional_string_argument(arguments, "kind")
1307
- for arg_name, value in [("query", query), ("language", language), ("kind", kind)]:
1308
- if value == "":
1309
- return _error_text(f"{arg_name} must be a string", code="invalid_arguments", argument=arg_name)
1310
- limit = _int_argument(arguments, "limit", 100, minimum=1, maximum=500)
1311
- matches = AstMatcher(root).match(
1312
- query=query or "",
1313
- language=language,
1314
- kind=kind,
1315
- limit=limit,
1316
- )
1317
- return [TextContent(type="text", text=json.dumps({"matches": [item.model_dump() for item in matches]}, indent=2))]
1318
-
1319
- elif name == "devcouncil_cli":
1320
- args = arguments.get("args")
1321
- if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args) or not args:
1322
- return _error_text("args must be a non-empty string array", code="invalid_arguments")
1323
- if args[0] not in _CLI_ALLOWED_ROOTS:
1324
- return _error_text(f"command {args[0]} is not allowed through MCP", code="command_not_allowed", command=args[0])
1325
- forbidden = _forbidden_cli_flags(args)
1326
- if forbidden:
1327
- return _error_text("forbidden flag(s) through MCP: " + ", ".join(forbidden), code="forbidden_flags", flags=forbidden)
1328
- try:
1329
- return _json_text(_run_cli_command(args, root))
1330
- except Exception as exc:
1331
- return _error_text(str(exc), code="cli_execution_error")
1332
-
1333
- elif name == "devcouncil_prepare_execution":
1334
- assert db is not None
1335
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1336
- if arg_error:
1337
- return arg_error
1338
- assert task_id is not None
1339
- with db.get_session() as session:
1340
- task_repo = TaskRepository(session)
1341
- req_repo = RequirementRepository(session)
1342
- task = task_repo.get_by_id(task_id)
1343
- if not task:
1344
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=str(task_id))
1345
- prompt = PromptBuilder(root).build_task_prompt(task, req_repo.get_all())
1346
- return [TextContent(type="text", text=json.dumps({
1347
- "task_id": task.id,
1348
- "prompt": prompt,
1349
- "planned_files": [file.model_dump() for file in task.planned_files],
1350
- "allowed_commands": task.allowed_commands,
1351
- "expected_tests": task.expected_tests,
1352
- }, indent=2))]
1353
-
1354
- elif name == "devcouncil_checkout_task":
1355
- assert db is not None
1356
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1357
- if arg_error:
1358
- return arg_error
1359
- client_id, arg_error = _required_string_argument(arguments, "client_id")
1360
- if arg_error:
1361
- return arg_error
1362
- assert task_id is not None and client_id is not None
1363
- agent = _optional_string_argument(arguments, "agent")
1364
- if agent == "":
1365
- return _error_text("agent must be a string", code="invalid_arguments", argument="agent")
1366
- force_value = arguments.get("force", False)
1367
- if not isinstance(force_value, bool):
1368
- return _error_text("force must be a boolean", code="invalid_arguments", argument="force")
1369
- force = force_value
1370
- with db.get_session() as session:
1371
- task_repo = TaskRepository(session)
1372
- task = task_repo.get_by_id(task_id)
1373
- if not task:
1374
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
1375
- lease_repo = TaskLeaseRepository(session)
1376
- try:
1377
- lease = lease_repo.acquire(
1378
- task_id,
1379
- owner=f"mcp:{client_id}",
1380
- agent=agent,
1381
- client_id=client_id,
1382
- ttl_seconds=_lease_ttl_seconds(root),
1383
- force=force,
1384
- )
1385
- except ValueError as exc:
1386
- return _error_text(str(exc), code="lease_conflict", task_id=task_id)
1387
- prompt = PromptBuilder(root).build_task_prompt(task, RequirementRepository(session).get_all())
1388
- semantic = None
1389
- semantic_path = root / ".devcouncil" / "semantic" / task_id / "before.json"
1390
- if semantic_path.exists():
1391
- semantic = json.loads(semantic_path.read_text(encoding="utf-8"))
1392
- return _json_text({
1393
- "ok": True,
1394
- "lease_token": lease.lease_token,
1395
- "task_id": task.id,
1396
- "status": task.status,
1397
- "expires_at": lease.expires_at,
1398
- "prompt": prompt,
1399
- "planned_files": [f.model_dump() for f in task.planned_files],
1400
- "allowed_commands": task.allowed_commands,
1401
- "expected_tests": task.expected_tests,
1402
- "semantic_context": semantic,
1403
- # The task is now leased and running-ready: surface the inner-loop tools.
1404
- "allowed_next_tools": _allowed_next_tools(
1405
- "running",
1406
- any(g.blocking for g in GapRepository(session).get_all() if g.task_id == task_id),
1407
- ),
1408
- })
1409
-
1410
- elif name == "devcouncil_release_task":
1411
- assert db is not None
1412
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1413
- if arg_error:
1414
- return arg_error
1415
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1416
- if arg_error:
1417
- return arg_error
1418
- assert task_id is not None and lease_token is not None
1419
- with db.get_session() as session:
1420
- released = TaskLeaseRepository(session).release(task_id, lease_token)
1421
- if not released:
1422
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
1423
- return _json_text({"ok": True, "task_id": task_id, "released": True})
1424
-
1425
- elif name == "devcouncil_renew_lease":
1426
- assert db is not None
1427
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1428
- if arg_error:
1429
- return arg_error
1430
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1431
- if arg_error:
1432
- return arg_error
1433
- assert task_id is not None and lease_token is not None
1434
- ttl_value = arguments.get("ttl_seconds")
1435
- if ttl_value is not None and (not isinstance(ttl_value, int) or isinstance(ttl_value, bool)):
1436
- return _error_text("ttl_seconds must be an integer", code="invalid_arguments", argument="ttl_seconds")
1437
- ttl_seconds = ttl_value if isinstance(ttl_value, int) and not isinstance(ttl_value, bool) else _lease_ttl_seconds(root)
1438
- with db.get_session() as session:
1439
- renewed_lease = TaskLeaseRepository(session).renew(task_id, lease_token, ttl_seconds)
1440
- if renewed_lease is None:
1441
- return _error_text("Invalid or expired lease.", code="invalid_lease", task_id=task_id)
1442
- return _json_text({
1443
- "ok": True,
1444
- "task_id": task_id,
1445
- "expires_at": renewed_lease.expires_at,
1446
- "ttl_seconds": ttl_seconds,
1447
- })
1448
-
1449
- elif name == "devcouncil_list_leases":
1450
- assert db is not None
1451
- active_only = arguments.get("active_only", True)
1452
- if not isinstance(active_only, bool):
1453
- return _error_text("active_only must be a boolean", code="invalid_arguments", argument="active_only")
1454
- with db.get_session() as session:
1455
- pairs = TaskLeaseRepository(session).list_leases(active_only=active_only)
1456
- leases = [
1457
- {
1458
- "task_id": lease.task_id,
1459
- "owner": lease.owner,
1460
- "agent": lease.agent,
1461
- "status": lease.status,
1462
- "expires_at": lease.expires_at,
1463
- "expired": expired,
1464
- }
1465
- for lease, expired in pairs
1466
- ]
1467
- return _json_text({"ok": True, "leases": leases, "count": len(leases)})
1468
-
1469
- elif name == "devcouncil_update_task_scope":
1470
- assert db is not None
1471
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1472
- if arg_error:
1473
- return arg_error
1474
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1475
- if arg_error:
1476
- return arg_error
1477
- assert task_id is not None and lease_token is not None
1478
- expected_tests, arg_error = _optional_string_list_argument(arguments, "expected_tests")
1479
- if arg_error:
1480
- return arg_error
1481
- allowed_commands, arg_error = _optional_string_list_argument(arguments, "allowed_commands")
1482
- if arg_error:
1483
- return arg_error
1484
- with db.get_session() as session:
1485
- lease_repo = TaskLeaseRepository(session)
1486
- if not lease_repo.validate(task_id, lease_token):
1487
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
1488
- task_repo = TaskRepository(session)
1489
- task = task_repo.get_by_id(task_id)
1490
- if not task:
1491
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
1492
- for cmd in allowed_commands:
1493
- if cmd not in task.allowed_commands:
1494
- task.allowed_commands.append(cmd)
1495
- for test in expected_tests:
1496
- if test not in task.expected_tests:
1497
- task.expected_tests.append(test)
1498
- task_repo.save(task)
1499
- return _json_text({
1500
- "ok": True,
1501
- "task_id": task_id,
1502
- "allowed_commands": task.allowed_commands,
1503
- "expected_tests": task.expected_tests,
1504
- })
1505
-
1506
- elif name == "devcouncil_append_evidence":
1507
- assert db is not None
1508
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1509
- if arg_error:
1510
- return arg_error
1511
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1512
- if arg_error:
1513
- return arg_error
1514
- command, arg_error = _required_string_argument(arguments, "command")
1515
- if arg_error:
1516
- return arg_error
1517
- summary_text, arg_error = _required_string_argument(arguments, "summary")
1518
- if arg_error:
1519
- return arg_error
1520
- assert task_id is not None and lease_token is not None
1521
- exit_code = arguments.get("exit_code", 0)
1522
- if not isinstance(exit_code, int) or isinstance(exit_code, bool):
1523
- return _error_text("exit_code must be an integer", code="invalid_arguments")
1524
- with db.get_session() as session:
1525
- if not TaskLeaseRepository(session).validate(task_id, lease_token):
1526
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
1527
- EvidenceRepository(session).save_command_result(
1528
- task_id,
1529
- CommandResult(
1530
- command=command or "",
1531
- exit_code=exit_code,
1532
- stdout_path="",
1533
- stderr_path="",
1534
- summary=summary_text or "",
1535
- ),
1536
- )
1537
- return _json_text({"ok": True, "task_id": task_id, "recorded": True})
1538
-
1539
- elif name == "devcouncil_record_command":
1540
- assert db is not None
1541
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1542
- if arg_error:
1543
- return arg_error
1544
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1545
- if arg_error:
1546
- return arg_error
1547
- command, arg_error = _required_string_argument(arguments, "command")
1548
- if arg_error:
1549
- return arg_error
1550
- status, arg_error = _required_string_argument(arguments, "status")
1551
- if arg_error:
1552
- return arg_error
1553
- assert task_id is not None and lease_token is not None
1554
- if status not in _RECORD_COMMAND_STATUSES:
1555
- return _error_text(
1556
- f"status must be one of {sorted(_RECORD_COMMAND_STATUSES)}",
1557
- code="invalid_arguments", argument="status",
1558
- )
1559
- with db.get_session() as session:
1560
- if not TaskLeaseRepository(session).validate(task_id, lease_token):
1561
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
1562
- exit_code = arguments.get("exit_code")
1563
- if exit_code is not None and (not isinstance(exit_code, int) or isinstance(exit_code, bool)):
1564
- return _error_text("exit_code must be an integer", code="invalid_arguments")
1565
- ShellCommandRepository(session).record(
1566
- task_id,
1567
- command or "",
1568
- status or "finished",
1569
- exit_code=exit_code if isinstance(exit_code, int) else None,
1570
- reason=str(arguments.get("reason") or ""),
1571
- )
1572
- return _json_text({"ok": True, "task_id": task_id, "recorded": True})
1573
-
1574
- elif name == "devcouncil_write_file":
1575
- assert db is not None
1576
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1577
- if arg_error:
1578
- return arg_error
1579
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1580
- if arg_error:
1581
- return arg_error
1582
- rel_path, arg_error = _required_string_argument(arguments, "path")
1583
- if arg_error:
1584
- return arg_error
1585
- content = arguments.get("content")
1586
- if not isinstance(content, str):
1587
- return _error_text("content must be a string", code="invalid_arguments", argument="content")
1588
- assert task_id is not None and lease_token is not None and rel_path is not None
1589
- with db.get_session() as session:
1590
- lease_record = TaskLeaseRepository(session).active_for_task(task_id)
1591
- if lease_record is None or lease_record.lease_token != lease_token:
1592
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
1593
- task = TaskRepository(session).get_by_id(task_id)
1594
- if not task:
1595
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
1596
-
1597
- decision = HookPolicy(project_root=root).evaluate_file_write(rel_path, task, content=content)
1598
- target = _within_root(root, rel_path)
1599
- if target is None:
1600
- FileChangeRepository(session).record(
1601
- rel_path, "write", False, task_id=task_id, lease_id=lease_record.id,
1602
- reason="path escapes the project root",
1603
- )
1604
- return _json_text({
1605
- "ok": False, "task_id": task_id, "applied_files": [],
1606
- "rejected_files": [{"path": rel_path, "reason": "path escapes the project root"}],
1607
- })
1608
- if not decision.allowed:
1609
- FileChangeRepository(session).record(
1610
- rel_path, "write", False, task_id=task_id, lease_id=lease_record.id, reason=decision.reason,
1611
- )
1612
- return _json_text({
1613
- "ok": False, "task_id": task_id, "applied_files": [],
1614
- "rejected_files": [{"path": rel_path, "reason": decision.reason}],
1615
- })
1616
- # Atomic write: stage to a sibling temp file, then replace.
1617
- try:
1618
- target.parent.mkdir(parents=True, exist_ok=True)
1619
- tmp = target.with_name(target.name + ".devcouncil-tmp")
1620
- tmp.write_text(content, encoding="utf-8")
1621
- os.replace(tmp, target)
1622
- except OSError as exc:
1623
- return _error_text(f"Write failed: {exc}", code="write_failed", task_id=task_id)
1624
- FileChangeRepository(session).record(
1625
- rel_path, "write", True, task_id=task_id, lease_id=lease_record.id, reason=decision.reason,
1626
- )
1627
- return _json_text({
1628
- "ok": True, "task_id": task_id, "applied_files": [rel_path], "rejected_files": [],
1629
- })
1630
-
1631
- elif name == "devcouncil_apply_patch":
1632
- assert db is not None
1633
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1634
- if arg_error:
1635
- return arg_error
1636
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1637
- if arg_error:
1638
- return arg_error
1639
- unified_diff = arguments.get("unified_diff")
1640
- if not isinstance(unified_diff, str) or not unified_diff.strip():
1641
- return _error_text("unified_diff must be a non-empty string", code="invalid_arguments", argument="unified_diff")
1642
- assert task_id is not None and lease_token is not None
1643
- if not _is_git_repo(root):
1644
- return _error_text(
1645
- "apply_patch requires a git repository. Use devcouncil_write_file instead.",
1646
- code="not_a_git_repo", task_id=task_id,
1647
- )
1648
- targets = _diff_target_paths(unified_diff)
1649
- if not targets:
1650
- return _error_text("No target files found in the diff.", code="empty_patch", task_id=task_id)
1651
- with db.get_session() as session:
1652
- lease_record = TaskLeaseRepository(session).active_for_task(task_id)
1653
- if lease_record is None or lease_record.lease_token != lease_token:
1654
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
1655
- task = TaskRepository(session).get_by_id(task_id)
1656
- if not task:
1657
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
1658
-
1659
- # Policy-check EVERY target before touching the tree. Any rejection aborts
1660
- # the whole patch — never a partial apply.
1661
- policy = HookPolicy(project_root=root)
1662
- rejected: list[dict[str, str]] = []
1663
- for path in targets:
1664
- if _within_root(root, path) is None:
1665
- rejected.append({"path": path, "reason": "path escapes the project root"})
1666
- continue
1667
- d = policy.evaluate_file_write(path, task)
1668
- if not d.allowed:
1669
- rejected.append({"path": path, "reason": d.reason})
1670
- if rejected:
1671
- for item in rejected:
1672
- FileChangeRepository(session).record(
1673
- item["path"], "apply_patch", False, task_id=task_id, lease_id=lease_record.id, reason=item["reason"],
1674
- )
1675
- return _json_text({
1676
- "ok": False, "task_id": task_id, "applied_files": [], "rejected_files": rejected,
1677
- })
1678
-
1679
- # Validate then apply atomically (git apply is all-or-nothing).
1680
- patch_path = root / ".devcouncil" / f"mcp-apply-{lease_record.id}.patch"
1681
- patch_path.parent.mkdir(parents=True, exist_ok=True)
1682
- patch_path.write_text(unified_diff, encoding="utf-8")
1683
- try:
1684
- check = subprocess.run(
1685
- ["git", "apply", "--check", "--ignore-whitespace", str(patch_path)],
1686
- cwd=root, capture_output=True, text=True,
1687
- )
1688
- if check.returncode != 0:
1689
- return _error_text(
1690
- f"Patch does not apply cleanly: {check.stderr.strip()}",
1691
- code="patch_rejected", task_id=task_id,
1692
- )
1693
- applied = subprocess.run(
1694
- ["git", "apply", "--ignore-whitespace", str(patch_path)],
1695
- cwd=root, capture_output=True, text=True,
1696
- )
1697
- if applied.returncode != 0:
1698
- return _error_text(
1699
- f"Patch apply failed: {applied.stderr.strip()}",
1700
- code="patch_failed", task_id=task_id,
1701
- )
1702
- finally:
1703
- try:
1704
- patch_path.unlink()
1705
- except OSError:
1706
- pass
1707
- for path in targets:
1708
- FileChangeRepository(session).record(
1709
- path, "apply_patch", True, task_id=task_id, lease_id=lease_record.id, reason="policy allowed",
1710
- )
1711
- return _json_text({
1712
- "ok": True, "task_id": task_id, "applied_files": targets, "rejected_files": [],
1713
- })
1714
-
1715
- elif name == "devcouncil_verify_task":
1716
- assert db is not None
1717
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1718
- if arg_error:
1719
- return arg_error
1720
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1721
- if arg_error:
1722
- return arg_error
1723
- assert task_id is not None and lease_token is not None
1724
- sandbox = _optional_string_argument(arguments, "sandbox") or "local"
1725
- if sandbox in {"docker", "nix"}:
1726
- return _json_text({
1727
- "ok": False,
1728
- "code": "unsupported_sandbox",
1729
- "reason": f"Sandbox {sandbox} is not available in this build.",
1730
- "sandbox": sandbox,
1731
- })
1732
- with db.get_session() as session:
1733
- if not TaskLeaseRepository(session).validate(task_id, lease_token):
1734
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
1735
- task_repo = TaskRepository(session)
1736
- task = task_repo.get_by_id(task_id)
1737
- if not task:
1738
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
1739
- from devcouncil.verification.verifier import Verifier
1740
-
1741
- GapRepository(session).delete_for_task(task_id)
1742
- EvidenceRepository(session).delete_for_task(task_id)
1743
- # Run the STRONG gate when a provider key is configured (compiled
1744
- # per-criterion checks); otherwise fall back to coarse mode and report it.
1745
- verifier = Verifier(root, router=_load_router(root))
1746
- evidence_gaps, evidence = await verifier.verify_task(
1747
- task, RequirementRepository(session).get_all()
1748
- )
1749
- gaps = evidence_gaps
1750
- for gap in gaps:
1751
- GapRepository(session).save(gap)
1752
- for ev in evidence:
1753
- if isinstance(ev, CommandResult):
1754
- EvidenceRepository(session).save_command_result(task_id, ev)
1755
- elif isinstance(ev, DiffCoverageEvidence):
1756
- EvidenceRepository(session).save_diff_coverage_evidence(ev)
1757
- elif isinstance(ev, DiffEvidence):
1758
- EvidenceRepository(session).save_diff_evidence(ev)
1759
- elif isinstance(ev, TestEvidence):
1760
- EvidenceRepository(session).save_test_evidence(ev, task_id)
1761
- task.status = "blocked" if any(g.blocking for g in gaps) else "verified"
1762
- task_repo.save(task)
1763
- blocking = [g.model_dump() for g in gaps if g.blocking]
1764
- # The typed next-actions contract: structured, routable steps the agent
1765
- # can act on to self-repair and re-verify without a human pasting prose.
1766
- # blocking_actions must be cleared to pass; advisory_actions (e.g. the
1767
- # diff↔coverage "tests passed but new code never ran" signal) are quality
1768
- # signals the agent can act on without confusing them with the gate.
1769
- blocking_actions, advisory_actions = split_next_actions(gaps)
1770
- outcome = verifier.last_outcome
1771
- return _json_text({
1772
- "ok": True,
1773
- "task_id": task_id,
1774
- "status": task.status,
1775
- "sandbox": sandbox,
1776
- "blocking_gaps": blocking,
1777
- "next_actions": [a.model_dump() for a in blocking_actions],
1778
- "advisory_actions": [a.model_dump() for a in advisory_actions],
1779
- # Computed from the post-verify status: verified -> release only,
1780
- # blocked -> the read/edit/test repair loop.
1781
- "allowed_next_tools": _allowed_next_tools(task.status, len(blocking) > 0),
1782
- "passed": len(blocking) == 0,
1783
- # Rigor of this run so the agent never reads passed==True as proven
1784
- # when the gate could not actually check.
1785
- "verification_mode": outcome.mode if outcome else "unknown",
1786
- "compiler_active": outcome.compiler_active if outcome else False,
1787
- "diff_empty": outcome.diff_empty if outcome else False,
1788
- "coverage_measured": outcome.coverage_measured if outcome else False,
1789
- "coverage_skipped_reason": outcome.coverage_skipped_reason if outcome else None,
1790
- })
1791
-
1792
- elif name == "devcouncil_handoff_agent":
1793
- assert db is not None
1794
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1795
- if arg_error:
1796
- return arg_error
1797
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1798
- if arg_error:
1799
- return arg_error
1800
- from_agent, arg_error = _required_string_argument(arguments, "from_agent")
1801
- if arg_error:
1802
- return arg_error
1803
- to_agent, arg_error = _required_string_argument(arguments, "to_agent")
1804
- if arg_error:
1805
- return arg_error
1806
- assert task_id is not None and lease_token is not None
1807
- with db.get_session() as session:
1808
- if not TaskLeaseRepository(session).validate(task_id, lease_token):
1809
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
1810
- try:
1811
- from devcouncil.execution.handoff import HandoffService
1812
-
1813
- manifest, handoff_path, run_id = HandoffService(root).create(
1814
- task_id,
1815
- from_agent or "",
1816
- to_agent or "",
1817
- instruction=str(arguments.get("instruction") or ""),
1818
- )
1819
- return _json_text({
1820
- "ok": True,
1821
- "task_id": task_id,
1822
- "manifest_path": str(handoff_path),
1823
- "run_id": run_id,
1824
- "manifest": manifest.model_dump(),
1825
- })
1826
- except ValueError as exc:
1827
- return _error_text(str(exc), code="handoff_failed", task_id=task_id)
1828
-
1829
- elif name == "devcouncil_read_file":
1830
- rel_path, arg_error = _required_string_argument(arguments, "path")
1831
- if arg_error:
1832
- return arg_error
1833
- assert rel_path is not None
1834
- if _is_secret_path(root, rel_path):
1835
- return _error_text(
1836
- "Refusing to read a secret/credential path.",
1837
- code="secret_path", path=rel_path,
1838
- )
1839
- target = _within_root(root, rel_path)
1840
- if target is None:
1841
- return _error_text("path escapes the project root", code="path_escape", path=rel_path)
1842
- if not target.exists() or not target.is_file():
1843
- return _error_text(f"File not found: {rel_path}", code="not_found", path=rel_path)
1844
- try:
1845
- raw = target.read_bytes()
1846
- except OSError as exc:
1847
- return _error_text(f"Read failed: {exc}", code="read_failed", path=rel_path)
1848
- sha256 = hashlib.sha256(raw).hexdigest()
1849
- text = raw.decode("utf-8", errors="replace")
1850
- all_lines = text.splitlines()
1851
- line_count = len(all_lines)
1852
- # Optional windowing: line_range ('10-40', 1-based inclusive) wins over offset/limit.
1853
- line_range = _optional_string_argument(arguments, "line_range")
1854
- if line_range == "":
1855
- return _error_text("line_range must be a string", code="invalid_arguments", argument="line_range")
1856
- selected = all_lines
1857
- if line_range:
1858
- try:
1859
- start_str, _, end_str = line_range.partition("-")
1860
- start = max(1, int(start_str))
1861
- end = int(end_str) if end_str else line_count
1862
- except ValueError:
1863
- return _error_text("line_range must look like '10-40'", code="invalid_arguments", argument="line_range")
1864
- selected = all_lines[start - 1:end]
1865
- else:
1866
- offset = _int_argument(arguments, "offset", 0, minimum=0, maximum=10_000_000)
1867
- limit_value = arguments.get("limit")
1868
- if isinstance(limit_value, int) and not isinstance(limit_value, bool):
1869
- limit = max(1, limit_value)
1870
- selected = all_lines[offset:offset + limit]
1871
- elif offset:
1872
- selected = all_lines[offset:]
1873
- windowed = "\n".join(selected)
1874
- content, truncated = _truncate_text(windowed)
1875
- return _json_text({
1876
- "ok": True,
1877
- "path": rel_path.replace("\\", "/"),
1878
- "content": content,
1879
- "sha256": sha256,
1880
- "line_count": line_count,
1881
- "truncated": truncated,
1882
- })
1883
-
1884
- elif name == "devcouncil_get_diff":
1885
- if not _is_git_repo(root):
1886
- return _error_text("get_diff requires a git repository.", code="not_a_git_repo")
1887
- task_id = _optional_string_argument(arguments, "task_id")
1888
- if task_id == "":
1889
- return _error_text("task_id must be a string", code="invalid_arguments", argument="task_id")
1890
- explicit_paths, arg_error = _optional_string_list_argument(arguments, "paths")
1891
- if arg_error:
1892
- return arg_error
1893
- staged_value = arguments.get("staged", False)
1894
- if not isinstance(staged_value, bool):
1895
- return _error_text("staged must be a boolean", code="invalid_arguments", argument="staged")
1896
- scope_paths: list[str] = list(explicit_paths)
1897
- if task_id and db:
1898
- with db.get_session() as session:
1899
- task = TaskRepository(session).get_by_id(task_id)
1900
- if task is None:
1901
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
1902
- for planned in task.planned_files:
1903
- p = planned.path.replace("\\", "/")
1904
- if p not in scope_paths:
1905
- scope_paths.append(p)
1906
- return _json_text(_git_diff(root, scope_paths, staged_value))
1907
-
1908
- elif name == "devcouncil_get_evidence":
1909
- assert db is not None
1910
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1911
- if arg_error:
1912
- return arg_error
1913
- assert task_id is not None
1914
- command_filter = _optional_string_argument(arguments, "command")
1915
- if command_filter == "":
1916
- return _error_text("command must be a string", code="invalid_arguments", argument="command")
1917
- limit = _int_argument(arguments, "limit", 20, minimum=1, maximum=100)
1918
- with db.get_session() as session:
1919
- results = EvidenceRepository(session).get_command_results_for_task(task_id)
1920
- evidence_rows: list[dict[str, object]] = []
1921
- for result in results:
1922
- if command_filter and command_filter not in result.command:
1923
- continue
1924
- stdout, stdout_truncated = _truncate_text(_read_log_file(result.stdout_path))
1925
- stderr, stderr_truncated = _truncate_text(_read_log_file(result.stderr_path))
1926
- evidence_rows.append({
1927
- "command": result.command,
1928
- "exit_code": result.exit_code,
1929
- "summary": result.summary,
1930
- "stdout": stdout,
1931
- "stderr": stderr,
1932
- "truncated": stdout_truncated or stderr_truncated,
1933
- })
1934
- if len(evidence_rows) >= limit:
1935
- break
1936
- return _json_text({"ok": True, "task_id": task_id, "evidence": evidence_rows})
1937
-
1938
- elif name == "devcouncil_run_command":
1939
- assert db is not None
1940
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1941
- if arg_error:
1942
- return arg_error
1943
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1944
- if arg_error:
1945
- return arg_error
1946
- command, arg_error = _required_string_argument(arguments, "command")
1947
- if arg_error:
1948
- return arg_error
1949
- assert task_id is not None and lease_token is not None and command is not None
1950
- normalized = " ".join(command.split())
1951
- with db.get_session() as session:
1952
- if not TaskLeaseRepository(session).validate(task_id, lease_token):
1953
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
1954
- task = TaskRepository(session).get_by_id(task_id)
1955
- if not task:
1956
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
1957
- from devcouncil.execution.policy_engine import TaskPolicyEngine
1958
-
1959
- policy_decision = TaskPolicyEngine(root).evaluate_command(normalized, task)
1960
- if policy_decision.action == "deny":
1961
- # Record nothing executed; the gate refused before any side effect.
1962
- ShellCommandRepository(session).record(
1963
- task_id, normalized, "blocked", reason=policy_decision.reason,
1964
- )
1965
- return _error_text(
1966
- policy_decision.reason or "Command is not in the task allowlist.",
1967
- code="command_not_allowed", task_id=task_id, command=normalized,
1968
- )
1969
- try:
1970
- import shlex
1971
-
1972
- args = shlex.split(normalized, posix=(os.name != "nt"))
1973
- completed = subprocess.run(
1974
- args,
1975
- cwd=root,
1976
- capture_output=True,
1977
- text=True,
1978
- encoding="utf-8",
1979
- errors="replace",
1980
- env=clean_subprocess_env(),
1981
- timeout=_CLI_TIMEOUT_SECONDS,
1982
- )
1983
- exit_code = completed.returncode
1984
- stdout, stdout_truncated = _truncate_text(completed.stdout)
1985
- stderr, stderr_truncated = _truncate_text(completed.stderr)
1986
- timed_out = False
1987
- except subprocess.TimeoutExpired as exc:
1988
- exit_code = None
1989
- stdout, stdout_truncated = _truncate_text(exc.output)
1990
- stderr, stderr_truncated = _truncate_text(exc.stderr)
1991
- timed_out = True
1992
- except (FileNotFoundError, OSError, ValueError) as exc:
1993
- ShellCommandRepository(session).record(
1994
- task_id, normalized, "failed", reason=str(exc),
1995
- )
1996
- return _error_text(f"Could not run command: {exc}", code="run_failed", task_id=task_id)
1997
- ShellCommandRepository(session).record(
1998
- task_id,
1999
- normalized,
2000
- "finished" if exit_code == 0 else "failed",
2001
- exit_code=exit_code,
2002
- )
2003
- return _json_text({
2004
- "ok": exit_code == 0,
2005
- "task_id": task_id,
2006
- "exit_code": exit_code,
2007
- "stdout": stdout,
2008
- "stderr": stderr,
2009
- "truncated": stdout_truncated or stderr_truncated,
2010
- "timed_out": timed_out,
2011
- })
2012
-
2013
- elif name == "devcouncil_next_task":
2014
- assert db is not None
2015
- status_filter = _optional_string_argument(arguments, "status")
2016
- if status_filter == "":
2017
- return _error_text("status must be a string", code="invalid_arguments", argument="status")
2018
- client_id = _optional_string_argument(arguments, "client_id")
2019
- if client_id == "":
2020
- return _error_text("client_id must be a string", code="invalid_arguments", argument="client_id")
2021
- with db.get_session() as session:
2022
- tasks = TaskRepository(session).get_all()
2023
- leased_task_ids = {
2024
- lease.task_id
2025
- for lease, expired in TaskLeaseRepository(session).list_leases(active_only=True)
2026
- if not expired
2027
- }
2028
- blocking_by_task: dict[str, int] = {}
2029
- for gap in GapRepository(session).get_all():
2030
- if gap.blocking and gap.task_id:
2031
- blocking_by_task[gap.task_id] = blocking_by_task.get(gap.task_id, 0) + 1
2032
- done_ids = {t.id for t in tasks if t.status in {"verified", "done"}}
2033
- # Candidate set: not finished, not actively leased, deps satisfied, matching the
2034
- # optional status filter (default to planned/ready bootstrap states).
2035
- wanted_statuses = {status_filter} if status_filter else {"planned", "ready"}
2036
- candidates = []
2037
- for task in tasks:
2038
- if task.status not in wanted_statuses:
2039
- continue
2040
- if task.id in leased_task_ids:
2041
- continue
2042
- if any(dep not in done_ids for dep in task.depends_on):
2043
- continue
2044
- candidates.append(task)
2045
- if not candidates:
2046
- return _json_text({
2047
- "ok": True,
2048
- "task": None,
2049
- "reason": "No unblocked, unleased task is available.",
2050
- })
2051
- # Deterministic "highest priority": fewest unmet deps, then task id order, so the
2052
- # same task is chosen on every call (no race with list_tasks ordering).
2053
- candidates.sort(key=lambda t: (len(t.depends_on), t.id))
2054
- chosen = candidates[0]
2055
- blocking_count = blocking_by_task.get(chosen.id, 0)
2056
- return _json_text({
2057
- "ok": True,
2058
- "task": chosen.model_dump(),
2059
- "blocking_gap_count": blocking_count,
2060
- "ready_to_checkout": blocking_count == 0,
2061
- "allowed_next_tools": _allowed_next_tools(chosen.status, blocking_count > 0),
2062
- })
2063
-
2064
- elif name == "devcouncil_list_agent_runs":
2065
- from devcouncil.cli.commands.runs import _collect_runs, _orphan_after_seconds
2066
-
2067
- status_filter = _optional_string_argument(arguments, "status")
2068
- if status_filter == "":
2069
- return _error_text("status must be a string", code="invalid_arguments", argument="status")
2070
- limit = _int_argument(arguments, "limit", 20, minimum=1, maximum=500)
2071
- run_rows = _collect_runs(root, orphan_after=_orphan_after_seconds(root))
2072
- if status_filter:
2073
- run_rows = [row for row in run_rows if row.get("status") == status_filter]
2074
- total = len(run_rows)
2075
- run_window = run_rows[:limit]
2076
- return _json_text({"ok": True, "runs": run_window, "total": total, "returned": len(run_window)})
2077
-
2078
- elif name == "devcouncil_get_run":
2079
- from devcouncil.cli.commands.runs import (
2080
- _find_transcript,
2081
- _is_orphaned,
2082
- _load_manifest,
2083
- _orphan_after_seconds,
2084
- _runs_dir,
2085
- _transcript_tail,
2086
- )
2087
- import time as _time
2088
-
2089
- target_run_id, arg_error = _required_string_argument(arguments, "run_id")
2090
- if arg_error:
2091
- return arg_error
2092
- assert target_run_id is not None
2093
- run_dir = _runs_dir(root) / target_run_id
2094
- manifest_path = run_dir / "agent-run.json"
2095
- run_manifest = _load_manifest(manifest_path)
2096
- if run_manifest is None:
2097
- return _error_text(f"Run {target_run_id} not found.", code="not_found", run_id=target_run_id)
2098
- orphaned = _is_orphaned(
2099
- run_manifest, manifest_path, orphan_after=_orphan_after_seconds(root), now=_time.time()
2100
- )
2101
- transcript_path = _find_transcript(run_dir, run_manifest)
2102
- transcript_tail = _transcript_tail(transcript_path) if transcript_path else ""
2103
- tail, truncated = _truncate_text(transcript_tail)
2104
- return _json_text({
2105
- "ok": True,
2106
- "run_id": target_run_id,
2107
- "manifest": run_manifest,
2108
- "orphaned": orphaned,
2109
- "transcript_path": str(transcript_path) if transcript_path else None,
2110
- "transcript_tail": tail,
2111
- "transcript_truncated": truncated,
2112
- })
2113
-
2114
- return _error_text(f"Unknown tool: {name}", code="unknown_tool", tool=name)
2115
-
2116
- async def run():
2117
- # Use stdio to communicate
2118
- async with stdio_server() as (read_stream, write_stream):
2119
- await app.run(read_stream, write_stream, app.create_initialization_options())
2120
-
2121
- if __name__ == "__main__":
2122
- asyncio.run(run())