devcouncil 0.4.0 → 0.4.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.
- package/README.md +48 -6
- package/examples/build-week-demo/README.md +13 -0
- package/examples/build-week-demo/broken_calc.py +11 -0
- package/examples/build-week-demo/calc.py +11 -0
- package/examples/build-week-demo/test_calc.py +21 -0
- package/package.json +7 -2
- package/pyproject.toml +2 -2
- package/scripts/build-week-demo.sh +200 -0
- package/src/devcouncil/cli/commands/watch.py +7 -10
- package/src/devcouncil/indexing/viz.py +40 -12
- package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +1 -1
- package/src/devcouncil/integrations/mcp/handlers/git.py +199 -17
- package/src/devcouncil/integrations/mcp/handlers/graph.py +6 -8
- package/src/devcouncil/integrations/mcp/handlers/knowledge.py +6 -8
- package/src/devcouncil/integrations/mcp/handlers/provenance.py +25 -39
- package/src/devcouncil/integrations/mcp/handlers/read.py +30 -1
- package/src/devcouncil/integrations/mcp/handlers/runs.py +3 -18
- package/src/devcouncil/integrations/mcp/handlers/status.py +117 -32
- package/src/devcouncil/integrations/mcp/handlers/task.py +5 -17
- package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +28 -10
- package/src/devcouncil/integrations/mcp/handlers/wiki.py +4 -11
- package/src/devcouncil/integrations/mcp/server.py +1 -1
- package/src/devcouncil/integrations/mcp/util.py +27 -5
- package/src/devcouncil/live/summary.py +21 -3
- package/uv.lock +5 -5
|
@@ -20,12 +20,139 @@ from devcouncil.integrations.mcp.util import (
|
|
|
20
20
|
from devcouncil.storage.db import Database
|
|
21
21
|
from devcouncil.storage.repositories import TaskRepository
|
|
22
22
|
|
|
23
|
+
_BINARY_PROBE_BYTES = 8192
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _run_git(root: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
27
|
+
return subprocess.run(
|
|
28
|
+
args,
|
|
29
|
+
cwd=root,
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
encoding="utf-8",
|
|
33
|
+
errors="replace",
|
|
34
|
+
timeout=CLI_TIMEOUT_SECONDS,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_name_status_z(data: str) -> dict[str, str]:
|
|
39
|
+
"""Parse ``git diff --name-status -z`` into ``{path: status}``.
|
|
40
|
+
|
|
41
|
+
Rename/copy records are ``STATUS\\0OLD\\0NEW\\0``; other records are
|
|
42
|
+
``STATUS\\0PATH\\0``. The new path is the authoritative key for renames.
|
|
43
|
+
"""
|
|
44
|
+
status_by_path: dict[str, str] = {}
|
|
45
|
+
parts = data.split("\0")
|
|
46
|
+
i = 0
|
|
47
|
+
while i < len(parts):
|
|
48
|
+
status = parts[i]
|
|
49
|
+
if not status:
|
|
50
|
+
i += 1
|
|
51
|
+
continue
|
|
52
|
+
kind = status[0]
|
|
53
|
+
if kind in "RC" and i + 2 < len(parts):
|
|
54
|
+
new_path = parts[i + 2].replace("\\", "/")
|
|
55
|
+
if new_path:
|
|
56
|
+
status_by_path[new_path] = status
|
|
57
|
+
i += 3
|
|
58
|
+
continue
|
|
59
|
+
if i + 1 < len(parts):
|
|
60
|
+
path = parts[i + 1].replace("\\", "/")
|
|
61
|
+
if path:
|
|
62
|
+
status_by_path[path] = status
|
|
63
|
+
i += 2
|
|
64
|
+
continue
|
|
65
|
+
break
|
|
66
|
+
return status_by_path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _format_untracked_file_diff(rel_path: str, full_path: Path) -> tuple[str, int]:
|
|
70
|
+
"""Return ``(unified_diff_fragment, addition_count)`` for a new untracked file."""
|
|
71
|
+
try:
|
|
72
|
+
raw = full_path.read_bytes()
|
|
73
|
+
except OSError:
|
|
74
|
+
return "", 0
|
|
75
|
+
|
|
76
|
+
header = [
|
|
77
|
+
f"diff --git a/{rel_path} b/{rel_path}",
|
|
78
|
+
"new file mode 100644",
|
|
79
|
+
"--- /dev/null",
|
|
80
|
+
f"+++ b/{rel_path}",
|
|
81
|
+
]
|
|
82
|
+
if b"\0" in raw[:_BINARY_PROBE_BYTES]:
|
|
83
|
+
return "\n".join([*header, f"Binary files /dev/null and b/{rel_path} differ"]), 0
|
|
84
|
+
|
|
85
|
+
text = raw.decode("utf-8", errors="replace")
|
|
86
|
+
if not text:
|
|
87
|
+
return "\n".join(header) + "\n", 0
|
|
88
|
+
|
|
89
|
+
lines = text.splitlines()
|
|
90
|
+
if text.endswith(("\n", "\r")):
|
|
91
|
+
line_count = len(lines)
|
|
92
|
+
else:
|
|
93
|
+
line_count = max(len(lines), 1)
|
|
94
|
+
|
|
95
|
+
diff_lines = [*header, f"@@ -0,0 +1,{line_count} @@"]
|
|
96
|
+
diff_lines.extend(f"+{line}" for line in lines)
|
|
97
|
+
return "\n".join(diff_lines), line_count
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _list_untracked(root: Path, paths: list[str]) -> tuple[list[str], str | None]:
|
|
101
|
+
"""List untracked paths; return ``(paths, error)`` when the Git call fails."""
|
|
102
|
+
args = ["git", "ls-files", "--others", "--exclude-standard", "-z"]
|
|
103
|
+
if paths:
|
|
104
|
+
args.append("--")
|
|
105
|
+
args.extend(paths)
|
|
106
|
+
try:
|
|
107
|
+
proc = _run_git(root, args)
|
|
108
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
109
|
+
return [], str(exc)
|
|
110
|
+
if proc.returncode != 0:
|
|
111
|
+
detail = (proc.stderr or proc.stdout or f"git ls-files exited {proc.returncode}").strip()
|
|
112
|
+
return [], detail or f"git ls-files exited {proc.returncode}"
|
|
113
|
+
return [p.replace("\\", "/") for p in proc.stdout.split("\0") if p.strip()], None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _collect_untracked(
|
|
117
|
+
root: Path,
|
|
118
|
+
paths: list[str],
|
|
119
|
+
*,
|
|
120
|
+
known_paths: set[str],
|
|
121
|
+
) -> tuple[list[dict[str, object]], str, str | None]:
|
|
122
|
+
"""Build file entries and unified diff for untracked files in scope."""
|
|
123
|
+
untracked, err = _list_untracked(root, paths)
|
|
124
|
+
if err is not None:
|
|
125
|
+
return [], "", err
|
|
126
|
+
|
|
127
|
+
files: list[dict[str, object]] = []
|
|
128
|
+
fragments: list[str] = []
|
|
129
|
+
for rel in untracked:
|
|
130
|
+
if rel in known_paths:
|
|
131
|
+
continue
|
|
132
|
+
full = root / rel
|
|
133
|
+
if not full.is_file():
|
|
134
|
+
continue
|
|
135
|
+
fragment, additions = _format_untracked_file_diff(rel, full)
|
|
136
|
+
if not fragment:
|
|
137
|
+
continue
|
|
138
|
+
files.append({
|
|
139
|
+
"path": rel,
|
|
140
|
+
"status": "A",
|
|
141
|
+
"additions": additions,
|
|
142
|
+
"deletions": 0,
|
|
143
|
+
})
|
|
144
|
+
fragments.append(fragment.rstrip("\n"))
|
|
145
|
+
unified = "\n".join(fragments)
|
|
146
|
+
if unified:
|
|
147
|
+
unified += "\n"
|
|
148
|
+
return files, unified, None
|
|
149
|
+
|
|
23
150
|
|
|
24
151
|
async def git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, object]:
|
|
25
152
|
"""Compute a (optionally path-scoped, optionally staged) git diff."""
|
|
26
153
|
diff_args = ["git", "diff"]
|
|
27
154
|
numstat_args = ["git", "diff", "--numstat"]
|
|
28
|
-
namestatus_args = ["git", "diff", "--name-status"]
|
|
155
|
+
namestatus_args = ["git", "diff", "--name-status", "-z"]
|
|
29
156
|
if staged:
|
|
30
157
|
for args in (diff_args, numstat_args, namestatus_args):
|
|
31
158
|
args.append("--cached")
|
|
@@ -35,10 +162,7 @@ async def git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, obje
|
|
|
35
162
|
args.extend(paths)
|
|
36
163
|
|
|
37
164
|
def _run(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
38
|
-
return
|
|
39
|
-
args, cwd=root, capture_output=True, text=True,
|
|
40
|
-
encoding="utf-8", errors="replace", timeout=CLI_TIMEOUT_SECONDS,
|
|
41
|
-
)
|
|
165
|
+
return _run_git(root, args)
|
|
42
166
|
|
|
43
167
|
try:
|
|
44
168
|
loop = asyncio.get_event_loop()
|
|
@@ -50,11 +174,23 @@ async def git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, obje
|
|
|
50
174
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
51
175
|
return {"ok": False, "files": [], "unified_diff": "", "truncated": False, "error": str(exc)}
|
|
52
176
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
177
|
+
for proc, label in (
|
|
178
|
+
(diff_proc, "git diff"),
|
|
179
|
+
(numstat_proc, "git diff --numstat"),
|
|
180
|
+
(namestatus_proc, "git diff --name-status"),
|
|
181
|
+
):
|
|
182
|
+
if proc.returncode != 0:
|
|
183
|
+
detail = (proc.stderr or proc.stdout or f"{label} exited {proc.returncode}").strip()
|
|
184
|
+
return {
|
|
185
|
+
"ok": False,
|
|
186
|
+
"files": [],
|
|
187
|
+
"unified_diff": "",
|
|
188
|
+
"truncated": False,
|
|
189
|
+
"error": detail or f"{label} exited {proc.returncode}",
|
|
190
|
+
"staged": staged,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
status_by_path = _parse_name_status_z(namestatus_proc.stdout)
|
|
58
194
|
|
|
59
195
|
files: list[dict[str, object]] = []
|
|
60
196
|
for line in numstat_proc.stdout.splitlines():
|
|
@@ -62,6 +198,9 @@ async def git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, obje
|
|
|
62
198
|
if len(parts) < 3:
|
|
63
199
|
continue
|
|
64
200
|
added_str, deleted_str, file_path = parts[0], parts[1], parts[-1]
|
|
201
|
+
# Rename numstat without -z may use "old => new"; prefer the new side.
|
|
202
|
+
if " => " in file_path:
|
|
203
|
+
file_path = file_path.split(" => ", 1)[-1]
|
|
65
204
|
file_path = file_path.replace("\\", "/")
|
|
66
205
|
files.append({
|
|
67
206
|
"path": file_path,
|
|
@@ -70,10 +209,37 @@ async def git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, obje
|
|
|
70
209
|
"deletions": int(deleted_str) if deleted_str.isdigit() else 0,
|
|
71
210
|
})
|
|
72
211
|
|
|
73
|
-
|
|
212
|
+
unified_parts = [diff_proc.stdout.rstrip("\n")] if diff_proc.stdout else []
|
|
213
|
+
if not staged:
|
|
214
|
+
known = {str(entry["path"]) for entry in files}
|
|
215
|
+
untracked_files, untracked_diff, untracked_err = await asyncio.get_event_loop().run_in_executor(
|
|
216
|
+
None,
|
|
217
|
+
lambda: _collect_untracked(root, paths, known_paths=known),
|
|
218
|
+
)
|
|
219
|
+
if untracked_err is not None:
|
|
220
|
+
return {
|
|
221
|
+
"ok": False,
|
|
222
|
+
"files": [],
|
|
223
|
+
"unified_diff": "",
|
|
224
|
+
"truncated": False,
|
|
225
|
+
"error": untracked_err,
|
|
226
|
+
"staged": staged,
|
|
227
|
+
}
|
|
228
|
+
files.extend(untracked_files)
|
|
229
|
+
if untracked_diff:
|
|
230
|
+
unified_parts.append(untracked_diff.rstrip("\n"))
|
|
231
|
+
|
|
232
|
+
combined = "\n".join(part for part in unified_parts if part)
|
|
233
|
+
if combined:
|
|
234
|
+
combined += "\n"
|
|
235
|
+
unified_diff, truncated = truncate_text(combined)
|
|
74
236
|
return {"ok": True, "files": files, "unified_diff": unified_diff, "truncated": truncated, "staged": staged}
|
|
75
237
|
|
|
76
238
|
|
|
239
|
+
def _empty_diff_payload(staged: bool) -> dict[str, object]:
|
|
240
|
+
return {"ok": True, "files": [], "unified_diff": "", "truncated": False, "staged": staged}
|
|
241
|
+
|
|
242
|
+
|
|
77
243
|
async def handle_get_diff(root: Path, db: Database | None, arguments: dict) -> list[TextContent]:
|
|
78
244
|
if not is_git_repo(root):
|
|
79
245
|
return error_text("get_diff requires a git repository.", code="not_a_git_repo")
|
|
@@ -86,14 +252,30 @@ async def handle_get_diff(root: Path, db: Database | None, arguments: dict) -> l
|
|
|
86
252
|
staged_value = arguments.get("staged", False)
|
|
87
253
|
if not isinstance(staged_value, bool):
|
|
88
254
|
return error_text("staged must be a boolean", code="invalid_arguments", argument="staged")
|
|
89
|
-
|
|
90
|
-
|
|
255
|
+
|
|
256
|
+
scope_paths: list[str] = [p.replace("\\", "/") for p in explicit_paths]
|
|
257
|
+
task_scoped = False
|
|
258
|
+
if task_id:
|
|
259
|
+
if db is None:
|
|
260
|
+
return error_text(
|
|
261
|
+
"DevCouncil not initialized in this directory.",
|
|
262
|
+
code="not_initialized",
|
|
263
|
+
)
|
|
91
264
|
with db.get_session() as session:
|
|
92
265
|
task = TaskRepository(session).get_by_id(task_id)
|
|
93
266
|
if task is None:
|
|
94
267
|
return error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
|
|
95
|
-
for
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
268
|
+
planned = [pf.path.replace("\\", "/") for pf in task.planned_files]
|
|
269
|
+
planned_set = set(planned)
|
|
270
|
+
task_scoped = True
|
|
271
|
+
if explicit_paths:
|
|
272
|
+
# Intersect only — explicit paths must never broaden task scope.
|
|
273
|
+
scope_paths = [p for p in scope_paths if p in planned_set]
|
|
274
|
+
else:
|
|
275
|
+
scope_paths = planned
|
|
276
|
+
|
|
277
|
+
if task_scoped and not scope_paths:
|
|
278
|
+
# Empty planned scope or empty intersection: fail closed to empty, never full repo.
|
|
279
|
+
return json_text(_empty_diff_payload(staged_value))
|
|
280
|
+
|
|
99
281
|
return json_text(await git_diff(root, scope_paths, staged_value))
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import json
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
|
|
8
7
|
from mcp.types import TextContent
|
|
9
8
|
|
|
10
9
|
from devcouncil.integrations.mcp.util import (
|
|
11
10
|
json_text,
|
|
11
|
+
parse_cli_json,
|
|
12
12
|
run_cli_command,
|
|
13
13
|
)
|
|
14
14
|
|
|
@@ -21,13 +21,11 @@ async def handle_graph_context(root: Path, arguments: dict) -> list[TextContent]
|
|
|
21
21
|
for item in files:
|
|
22
22
|
if isinstance(item, str) and item:
|
|
23
23
|
file_args.extend(["--file", item])
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
except json.JSONDecodeError:
|
|
30
|
-
pass
|
|
24
|
+
payload, _cli_error = parse_cli_json(
|
|
25
|
+
run_cli_command(["graph-context", "--json", *file_args], root, truncate=False),
|
|
26
|
+
)
|
|
27
|
+
if payload is not None:
|
|
28
|
+
return json_text(payload)
|
|
31
29
|
from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
|
|
32
30
|
|
|
33
31
|
context = CodeReviewGraphAdapter(root).get_context(
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import json
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
|
|
8
7
|
from mcp.types import TextContent
|
|
9
8
|
|
|
10
9
|
from devcouncil.integrations.mcp.util import (
|
|
11
10
|
json_text,
|
|
11
|
+
parse_cli_json,
|
|
12
12
|
required_string_argument,
|
|
13
13
|
run_cli_command,
|
|
14
14
|
)
|
|
@@ -20,11 +20,9 @@ async def handle_select_knowledge(root: Path, arguments: dict) -> list[TextConte
|
|
|
20
20
|
if arg_error:
|
|
21
21
|
return arg_error
|
|
22
22
|
assert goal is not None
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
except json.JSONDecodeError:
|
|
29
|
-
pass
|
|
23
|
+
payload, _cli_error = parse_cli_json(
|
|
24
|
+
run_cli_command(["okf", "select", "--json", "--goal", goal], root, truncate=False),
|
|
25
|
+
)
|
|
26
|
+
if payload is not None:
|
|
27
|
+
return json_text(payload)
|
|
30
28
|
return json_text(select_knowledge_payload(root, goal))
|
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import json
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
|
|
8
7
|
from mcp.types import Resource, TextContent
|
|
9
8
|
from pydantic import AnyUrl
|
|
10
9
|
|
|
11
|
-
from devcouncil.integrations.mcp.util import
|
|
10
|
+
from devcouncil.integrations.mcp.util import (
|
|
11
|
+
json_text,
|
|
12
|
+
parse_cli_json,
|
|
13
|
+
required_string_argument,
|
|
14
|
+
run_cli_command,
|
|
15
|
+
run_cli_json,
|
|
16
|
+
)
|
|
12
17
|
from devcouncil.knowledge.resource_discovery import (
|
|
13
18
|
discover_knowledge_sources,
|
|
14
19
|
knowledge_source_uri,
|
|
@@ -27,28 +32,12 @@ __all__ = [
|
|
|
27
32
|
from devcouncil.knowledge.resource_discovery import knowledge_settings # noqa: E402
|
|
28
33
|
|
|
29
34
|
|
|
30
|
-
def _cli_json(root: Path, args: list[str]) -> tuple[dict | None, list[TextContent] | None]:
|
|
31
|
-
result = run_cli_command(args, root)
|
|
32
|
-
stdout = str(result.get("stdout") or "").strip()
|
|
33
|
-
if stdout:
|
|
34
|
-
try:
|
|
35
|
-
return json.loads(stdout), None
|
|
36
|
-
except json.JSONDecodeError:
|
|
37
|
-
pass
|
|
38
|
-
if not result.get("ok"):
|
|
39
|
-
stderr = str(result.get("stderr") or "CLI command failed")
|
|
40
|
-
from devcouncil.integrations.mcp.util import error_text
|
|
41
|
-
return None, error_text(stderr, code="cli_failed")
|
|
42
|
-
from devcouncil.integrations.mcp.util import error_text
|
|
43
|
-
return None, error_text("CLI command returned invalid JSON", code="cli_parse_error")
|
|
44
|
-
|
|
45
|
-
|
|
46
35
|
async def handle_get_task_provenance(root: Path, db: object, arguments: dict) -> list[TextContent]:
|
|
47
36
|
task_id, arg_error = required_string_argument(arguments, "task_id")
|
|
48
37
|
if arg_error:
|
|
49
38
|
return arg_error
|
|
50
39
|
assert task_id is not None
|
|
51
|
-
payload, cli_error =
|
|
40
|
+
payload, cli_error = run_cli_json(["provenance", task_id, "--json"], root)
|
|
52
41
|
if cli_error:
|
|
53
42
|
return cli_error
|
|
54
43
|
assert payload is not None
|
|
@@ -57,25 +46,22 @@ async def handle_get_task_provenance(root: Path, db: object, arguments: dict) ->
|
|
|
57
46
|
|
|
58
47
|
async def list_resources(root: Path) -> list[Resource]:
|
|
59
48
|
"""Expose the DevCouncil corpus as browsable MCP resources."""
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
]
|
|
77
|
-
except json.JSONDecodeError:
|
|
78
|
-
pass
|
|
49
|
+
payload, _cli_error = parse_cli_json(
|
|
50
|
+
run_cli_command(["resource", "list", "--json"], root, truncate=False),
|
|
51
|
+
)
|
|
52
|
+
if payload is not None:
|
|
53
|
+
descriptors = payload.get("resources") or []
|
|
54
|
+
if isinstance(descriptors, list):
|
|
55
|
+
return [
|
|
56
|
+
Resource(
|
|
57
|
+
uri=AnyUrl(item["uri"]),
|
|
58
|
+
name=item["name"],
|
|
59
|
+
description=item["description"],
|
|
60
|
+
mimeType=item["mimeType"],
|
|
61
|
+
)
|
|
62
|
+
for item in descriptors
|
|
63
|
+
if isinstance(item, dict) and item.get("uri")
|
|
64
|
+
]
|
|
79
65
|
# Fallback when CLI is unavailable (e.g. during early init).
|
|
80
66
|
from devcouncil.reporting.mcp_resources import list_mcp_resource_uris
|
|
81
67
|
|
|
@@ -92,7 +78,7 @@ async def list_resources(root: Path) -> list[Resource]:
|
|
|
92
78
|
|
|
93
79
|
async def read_resource(root: Path, uri: AnyUrl) -> str:
|
|
94
80
|
key = str(uri).rstrip("/")
|
|
95
|
-
result = run_cli_command(["resource", "read", key], root)
|
|
81
|
+
result = run_cli_command(["resource", "read", key], root, truncate=False)
|
|
96
82
|
stdout = result.get("stdout")
|
|
97
83
|
if result.get("ok") and stdout is not None:
|
|
98
84
|
return str(stdout)
|
|
@@ -17,13 +17,42 @@ from devcouncil.integrations.mcp.util import (
|
|
|
17
17
|
truncate_text,
|
|
18
18
|
within_root,
|
|
19
19
|
)
|
|
20
|
+
from devcouncil.storage.db import Database
|
|
21
|
+
from devcouncil.storage.repositories import TaskRepository
|
|
20
22
|
|
|
21
23
|
|
|
22
|
-
async def handle_read_file(
|
|
24
|
+
async def handle_read_file(
|
|
25
|
+
root: Path,
|
|
26
|
+
arguments: dict,
|
|
27
|
+
db: Database | None = None,
|
|
28
|
+
) -> list[TextContent]:
|
|
23
29
|
rel_path, arg_error = required_string_argument(arguments, "path")
|
|
24
30
|
if arg_error:
|
|
25
31
|
return arg_error
|
|
26
32
|
assert rel_path is not None
|
|
33
|
+
task_id = optional_string_argument(arguments, "task_id")
|
|
34
|
+
if task_id == "":
|
|
35
|
+
return error_text("task_id must be a string", code="invalid_arguments", argument="task_id")
|
|
36
|
+
if task_id:
|
|
37
|
+
if db is None:
|
|
38
|
+
return error_text(
|
|
39
|
+
"DevCouncil not initialized in this directory.",
|
|
40
|
+
code="not_initialized",
|
|
41
|
+
)
|
|
42
|
+
with db.get_session() as session:
|
|
43
|
+
task = TaskRepository(session).get_by_id(task_id)
|
|
44
|
+
if task is None:
|
|
45
|
+
return error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
|
|
46
|
+
planned = {pf.path.replace("\\", "/") for pf in task.planned_files}
|
|
47
|
+
normalized = rel_path.replace("\\", "/")
|
|
48
|
+
if normalized not in planned:
|
|
49
|
+
# Fail closed: never broaden task scope to arbitrary repo paths.
|
|
50
|
+
return error_text(
|
|
51
|
+
f"Path {normalized} is outside task {task_id} planned-file scope.",
|
|
52
|
+
code="out_of_scope",
|
|
53
|
+
path=normalized,
|
|
54
|
+
task_id=task_id,
|
|
55
|
+
)
|
|
27
56
|
if is_secret_path(root, rel_path):
|
|
28
57
|
return error_text(
|
|
29
58
|
"Refusing to read a secret/credential path.",
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import json
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
|
|
8
7
|
from mcp.types import TextContent
|
|
@@ -13,25 +12,11 @@ from devcouncil.integrations.mcp.util import (
|
|
|
13
12
|
json_text,
|
|
14
13
|
optional_string_argument,
|
|
15
14
|
required_string_argument,
|
|
16
|
-
|
|
15
|
+
run_cli_json,
|
|
17
16
|
truncate_text,
|
|
18
17
|
)
|
|
19
18
|
|
|
20
19
|
|
|
21
|
-
def _cli_json(root: Path, args: list[str]) -> tuple[dict | None, list[TextContent] | None]:
|
|
22
|
-
result = run_cli_command(args, root)
|
|
23
|
-
stdout = str(result.get("stdout") or "").strip()
|
|
24
|
-
if stdout:
|
|
25
|
-
try:
|
|
26
|
-
return json.loads(stdout), None
|
|
27
|
-
except json.JSONDecodeError:
|
|
28
|
-
pass
|
|
29
|
-
if not result.get("ok"):
|
|
30
|
-
stderr = str(result.get("stderr") or "CLI command failed")
|
|
31
|
-
return None, error_text(stderr, code="cli_failed")
|
|
32
|
-
return None, error_text("CLI command returned invalid JSON", code="cli_parse_error")
|
|
33
|
-
|
|
34
|
-
|
|
35
20
|
async def handle_list_agent_runs(root: Path, arguments: dict) -> list[TextContent]:
|
|
36
21
|
status_filter = optional_string_argument(arguments, "status")
|
|
37
22
|
if status_filter == "":
|
|
@@ -40,7 +25,7 @@ async def handle_list_agent_runs(root: Path, arguments: dict) -> list[TextConten
|
|
|
40
25
|
cli_args = ["runs", "list", "--json", "--limit", str(limit)]
|
|
41
26
|
if status_filter:
|
|
42
27
|
cli_args.extend(["--status", status_filter])
|
|
43
|
-
payload, cli_error =
|
|
28
|
+
payload, cli_error = run_cli_json(cli_args, root)
|
|
44
29
|
if cli_error:
|
|
45
30
|
return cli_error
|
|
46
31
|
assert payload is not None
|
|
@@ -61,7 +46,7 @@ async def handle_get_run(root: Path, arguments: dict) -> list[TextContent]:
|
|
|
61
46
|
if arg_error:
|
|
62
47
|
return arg_error
|
|
63
48
|
assert run_id is not None
|
|
64
|
-
payload, cli_error =
|
|
49
|
+
payload, cli_error = run_cli_json(["runs", "show", run_id, "--json"], root)
|
|
65
50
|
if cli_error:
|
|
66
51
|
return cli_error
|
|
67
52
|
assert payload is not None
|