devcouncil 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -6
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/cli/commands/watch.py +7 -10
- package/src/devcouncil/indexing/viz.py +11 -7
- 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 +26 -26
|
@@ -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
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
7
8
|
|
|
8
9
|
from mcp.types import TextContent
|
|
9
10
|
|
|
@@ -12,48 +13,108 @@ from devcouncil.integrations.mcp.util import (
|
|
|
12
13
|
int_argument,
|
|
13
14
|
json_text,
|
|
14
15
|
optional_string_argument,
|
|
16
|
+
parse_cli_json,
|
|
15
17
|
required_string_argument,
|
|
16
18
|
run_cli_command,
|
|
19
|
+
run_cli_json,
|
|
17
20
|
)
|
|
18
21
|
|
|
22
|
+
# Default MCP list/status projections target a ~32 KB agent context budget.
|
|
23
|
+
MCP_CONTEXT_BUDGET_CHARS = 32_000
|
|
24
|
+
|
|
25
|
+
_LIST_TASK_FIELDS = ("id", "title", "status", "priority", "requirement_ids", "lease")
|
|
26
|
+
_GAP_FIELDS = (
|
|
27
|
+
"id",
|
|
28
|
+
"severity",
|
|
29
|
+
"gap_type",
|
|
30
|
+
"blocking",
|
|
31
|
+
"task_id",
|
|
32
|
+
"requirement_id",
|
|
33
|
+
"file",
|
|
34
|
+
"line",
|
|
35
|
+
"acceptance_criterion_id",
|
|
36
|
+
)
|
|
37
|
+
_ACTION_FIELDS = (
|
|
38
|
+
"gap_id",
|
|
39
|
+
"gap_type",
|
|
40
|
+
"category",
|
|
41
|
+
"severity",
|
|
42
|
+
"blocking",
|
|
43
|
+
"action",
|
|
44
|
+
"file",
|
|
45
|
+
"line",
|
|
46
|
+
"suggested_command",
|
|
47
|
+
"acceptance_criterion_id",
|
|
48
|
+
)
|
|
19
49
|
|
|
20
|
-
def _cli_json(root: Path, args: list[str]) -> tuple[dict | None, list[TextContent] | None]:
|
|
21
|
-
result = run_cli_command(args, root)
|
|
22
|
-
if not result.get("ok"):
|
|
23
|
-
stderr = str(result.get("stderr") or "CLI command failed")
|
|
24
|
-
return None, error_text(stderr, code="cli_failed")
|
|
25
|
-
try:
|
|
26
|
-
return json.loads(str(result.get("stdout") or "{}")), None
|
|
27
|
-
except json.JSONDecodeError:
|
|
28
|
-
return None, error_text("CLI command returned invalid JSON", code="cli_parse_error")
|
|
29
50
|
|
|
51
|
+
def _compact_task_row(task: object) -> dict[str, Any]:
|
|
52
|
+
if not isinstance(task, dict):
|
|
53
|
+
return {}
|
|
54
|
+
row: dict[str, Any] = {key: task.get(key) for key in _LIST_TASK_FIELDS}
|
|
55
|
+
row["requirements"] = task.get("requirement_ids") or []
|
|
56
|
+
row.pop("requirement_ids", None)
|
|
57
|
+
return row
|
|
30
58
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
59
|
+
|
|
60
|
+
def _compact_gap_row(gap: object) -> dict[str, Any]:
|
|
61
|
+
if not isinstance(gap, dict):
|
|
62
|
+
return {}
|
|
63
|
+
return {key: gap.get(key) for key in _GAP_FIELDS}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _compact_action_row(action: object) -> dict[str, Any]:
|
|
67
|
+
if not isinstance(action, dict):
|
|
68
|
+
return {}
|
|
69
|
+
return {key: action.get(key) for key in _ACTION_FIELDS}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _status_cli_error(cli_error: list[TextContent]) -> list[TextContent]:
|
|
37
73
|
try:
|
|
38
|
-
|
|
39
|
-
except json.JSONDecodeError:
|
|
74
|
+
err = json.loads(cli_error[0].text)
|
|
75
|
+
except (IndexError, json.JSONDecodeError, TypeError):
|
|
76
|
+
return cli_error
|
|
77
|
+
code = err.get("code")
|
|
78
|
+
if code == "cli_parse_error":
|
|
40
79
|
return error_text("status command returned invalid JSON", code="cli_parse_error")
|
|
80
|
+
if code == "cli_failed":
|
|
81
|
+
return error_text(str(err.get("error") or "status command failed"), code="cli_failed")
|
|
82
|
+
return cli_error
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def handle_status(root: Path, db: object, arguments: dict) -> list[TextContent]:
|
|
86
|
+
del db, arguments
|
|
87
|
+
payload, cli_error = parse_cli_json(run_cli_command(["status", "--json"], root, truncate=False))
|
|
88
|
+
if cli_error:
|
|
89
|
+
return _status_cli_error(cli_error)
|
|
90
|
+
assert payload is not None
|
|
41
91
|
if not payload.get("initialized"):
|
|
42
92
|
return error_text("DevCouncil not initialized in this directory.", code="not_initialized")
|
|
43
93
|
summary = payload.get("coverage_summary") or {}
|
|
94
|
+
live = payload.get("live_review") or {}
|
|
44
95
|
phase = payload.get("phase", "UNKNOWN")
|
|
45
|
-
|
|
46
|
-
f"Phase: {phase}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
96
|
+
lines = [
|
|
97
|
+
f"Phase: {phase}",
|
|
98
|
+
(
|
|
99
|
+
f"Requirements: {summary.get('total_requirements', 0)} "
|
|
100
|
+
f"({summary.get('requirements_without_tasks', 0)} unmapped)"
|
|
101
|
+
),
|
|
102
|
+
(
|
|
103
|
+
f"Tasks: {summary.get('total_tasks', 0)} "
|
|
104
|
+
f"({summary.get('tasks_without_requirements', 0)} orphaned)"
|
|
105
|
+
),
|
|
106
|
+
(
|
|
107
|
+
f"Gaps: {summary.get('total_gaps', 0)} "
|
|
108
|
+
f"({summary.get('blocking_gaps', 0)} blocking)"
|
|
109
|
+
),
|
|
110
|
+
f"Live signals: {live.get('pending_signals', 0)}",
|
|
111
|
+
]
|
|
112
|
+
return [TextContent(type="text", text="\n".join(lines) + "\n")]
|
|
52
113
|
|
|
53
114
|
|
|
54
115
|
async def handle_report(root: Path, db: object, arguments: dict) -> list[TextContent]:
|
|
55
|
-
del db
|
|
56
|
-
result = run_cli_command(["report"], root)
|
|
116
|
+
del db, arguments
|
|
117
|
+
result = run_cli_command(["report"], root, truncate=True)
|
|
57
118
|
if not result.get("ok"):
|
|
58
119
|
stderr = str(result.get("stderr") or "report command failed")
|
|
59
120
|
return error_text(stderr, code="cli_failed")
|
|
@@ -61,7 +122,7 @@ async def handle_report(root: Path, db: object, arguments: dict) -> list[TextCon
|
|
|
61
122
|
|
|
62
123
|
|
|
63
124
|
async def handle_get_gaps(root: Path, db: object, arguments: dict) -> list[TextContent]:
|
|
64
|
-
del db
|
|
125
|
+
del db
|
|
65
126
|
task_id, arg_error = required_string_argument(arguments, "task_id")
|
|
66
127
|
if arg_error:
|
|
67
128
|
return arg_error
|
|
@@ -70,22 +131,29 @@ async def handle_get_gaps(root: Path, db: object, arguments: dict) -> list[TextC
|
|
|
70
131
|
cli_args = ["gaps", "--json", "--task-id", task_id]
|
|
71
132
|
if blocking_only:
|
|
72
133
|
cli_args.append("--blocking-only")
|
|
73
|
-
payload, cli_error =
|
|
134
|
+
payload, cli_error = run_cli_json(cli_args, root)
|
|
74
135
|
if cli_error:
|
|
75
136
|
return cli_error
|
|
76
137
|
assert payload is not None
|
|
77
138
|
if not payload.get("initialized", True):
|
|
78
139
|
return error_text("DevCouncil not initialized in this directory.", code="not_initialized")
|
|
140
|
+
gaps = payload.get("gaps")
|
|
141
|
+
if isinstance(gaps, list):
|
|
142
|
+
payload = {
|
|
143
|
+
**payload,
|
|
144
|
+
"gaps": [_compact_gap_row(gap) for gap in gaps],
|
|
145
|
+
"gap_count": len(gaps),
|
|
146
|
+
}
|
|
79
147
|
return json_text(payload)
|
|
80
148
|
|
|
81
149
|
|
|
82
150
|
async def handle_get_next_actions(root: Path, db: object, arguments: dict) -> list[TextContent]:
|
|
83
|
-
del db
|
|
151
|
+
del db
|
|
84
152
|
task_id, arg_error = required_string_argument(arguments, "task_id")
|
|
85
153
|
if arg_error:
|
|
86
154
|
return arg_error
|
|
87
155
|
assert task_id is not None
|
|
88
|
-
payload, cli_error =
|
|
156
|
+
payload, cli_error = run_cli_json(["gaps", "--json", "--task-id", task_id, "--next-actions"], root)
|
|
89
157
|
if cli_error:
|
|
90
158
|
return cli_error
|
|
91
159
|
assert payload is not None
|
|
@@ -94,11 +162,22 @@ async def handle_get_next_actions(root: Path, db: object, arguments: dict) -> li
|
|
|
94
162
|
str(payload.get("error") or "next-actions command failed"),
|
|
95
163
|
code="cli_failed",
|
|
96
164
|
)
|
|
165
|
+
next_actions = payload.get("next_actions")
|
|
166
|
+
advisory = payload.get("advisory_actions")
|
|
167
|
+
updates: dict[str, Any] = {}
|
|
168
|
+
if isinstance(next_actions, list):
|
|
169
|
+
updates["next_actions"] = [_compact_action_row(a) for a in next_actions]
|
|
170
|
+
updates["next_action_count"] = len(next_actions)
|
|
171
|
+
if isinstance(advisory, list):
|
|
172
|
+
updates["advisory_actions"] = [_compact_action_row(a) for a in advisory]
|
|
173
|
+
updates["advisory_action_count"] = len(advisory)
|
|
174
|
+
if updates:
|
|
175
|
+
payload = {**payload, **updates}
|
|
97
176
|
return json_text(payload)
|
|
98
177
|
|
|
99
178
|
|
|
100
179
|
async def handle_list_tasks(root: Path, db: object, arguments: dict) -> list[TextContent]:
|
|
101
|
-
del db
|
|
180
|
+
del db
|
|
102
181
|
status_filter = optional_string_argument(arguments, "status")
|
|
103
182
|
if status_filter == "":
|
|
104
183
|
return error_text("status must be a string", code="invalid_arguments", argument="status")
|
|
@@ -107,8 +186,14 @@ async def handle_list_tasks(root: Path, db: object, arguments: dict) -> list[Tex
|
|
|
107
186
|
cli_args = ["tasks", "--json", "--limit", str(limit), "--offset", str(offset)]
|
|
108
187
|
if status_filter:
|
|
109
188
|
cli_args.extend(["--status", status_filter])
|
|
110
|
-
payload, cli_error =
|
|
189
|
+
payload, cli_error = run_cli_json(cli_args, root)
|
|
111
190
|
if cli_error:
|
|
112
191
|
return cli_error
|
|
113
192
|
assert payload is not None
|
|
193
|
+
tasks = payload.get("tasks")
|
|
194
|
+
if isinstance(tasks, list):
|
|
195
|
+
payload = {
|
|
196
|
+
**payload,
|
|
197
|
+
"tasks": [_compact_task_row(task) for task in tasks],
|
|
198
|
+
}
|
|
114
199
|
return json_text(payload)
|
|
@@ -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
|
|
@@ -11,29 +10,18 @@ from devcouncil.integrations.mcp.util import (
|
|
|
11
10
|
error_text,
|
|
12
11
|
json_text,
|
|
13
12
|
required_string_argument,
|
|
14
|
-
|
|
13
|
+
run_cli_json,
|
|
15
14
|
)
|
|
16
15
|
from devcouncil.utils.json_persist import dump_json
|
|
17
16
|
|
|
18
17
|
|
|
19
|
-
def _cli_json(root: Path, args: list[str]) -> tuple[dict | None, list[TextContent] | None]:
|
|
20
|
-
result = run_cli_command(args, root)
|
|
21
|
-
if not result.get("ok"):
|
|
22
|
-
stderr = str(result.get("stderr") or "CLI command failed")
|
|
23
|
-
return None, error_text(stderr, code="cli_failed")
|
|
24
|
-
try:
|
|
25
|
-
return json.loads(str(result.get("stdout") or "{}")), None
|
|
26
|
-
except json.JSONDecodeError:
|
|
27
|
-
return None, error_text("CLI command returned invalid JSON", code="cli_parse_error")
|
|
28
|
-
|
|
29
|
-
|
|
30
18
|
async def handle_get_task(root: Path, db: object, arguments: dict) -> list[TextContent]:
|
|
31
19
|
del db # routed through CLI service layer
|
|
32
20
|
task_id, arg_error = required_string_argument(arguments, "task_id")
|
|
33
21
|
if arg_error:
|
|
34
22
|
return arg_error
|
|
35
23
|
assert task_id is not None
|
|
36
|
-
payload, cli_error =
|
|
24
|
+
payload, cli_error = run_cli_json(["show", task_id, "--json"], root)
|
|
37
25
|
if cli_error:
|
|
38
26
|
return cli_error
|
|
39
27
|
assert payload is not None
|
|
@@ -49,7 +37,7 @@ async def handle_get_prompt(root: Path, db: object, arguments: dict) -> list[Tex
|
|
|
49
37
|
if arg_error:
|
|
50
38
|
return arg_error
|
|
51
39
|
assert task_id is not None
|
|
52
|
-
payload, cli_error =
|
|
40
|
+
payload, cli_error = run_cli_json(["prompt", task_id, "--json"], root)
|
|
53
41
|
if cli_error:
|
|
54
42
|
return cli_error
|
|
55
43
|
assert payload is not None
|
|
@@ -71,14 +59,14 @@ async def handle_prepare_execution(root: Path, db: object, arguments: dict) -> l
|
|
|
71
59
|
if arg_error:
|
|
72
60
|
return arg_error
|
|
73
61
|
assert task_id is not None
|
|
74
|
-
show_payload, show_error =
|
|
62
|
+
show_payload, show_error = run_cli_json(["show", task_id, "--json"], root)
|
|
75
63
|
if show_error:
|
|
76
64
|
return show_error
|
|
77
65
|
assert show_payload is not None
|
|
78
66
|
task = show_payload.get("task")
|
|
79
67
|
if not isinstance(task, dict):
|
|
80
68
|
return error_text(f"Task {task_id} not found.", code="not_found", task_id=str(task_id))
|
|
81
|
-
prompt_payload, prompt_error =
|
|
69
|
+
prompt_payload, prompt_error = run_cli_json(["prompt", task_id, "--json"], root)
|
|
82
70
|
if prompt_error:
|
|
83
71
|
return prompt_error
|
|
84
72
|
assert prompt_payload is not None
|
|
@@ -14,7 +14,11 @@ def all_tools() -> list[Tool]:
|
|
|
14
14
|
*debug_tools(),
|
|
15
15
|
Tool(
|
|
16
16
|
name="devcouncil_status",
|
|
17
|
-
description=
|
|
17
|
+
description=(
|
|
18
|
+
"Get a compact project status summary (phase, requirement/task/gap counts). "
|
|
19
|
+
"Default responses stay within a ~32 KB agent context budget; use "
|
|
20
|
+
"devcouncil_get_task / get_gaps / get_next_actions for detail by ID."
|
|
21
|
+
),
|
|
18
22
|
inputSchema={
|
|
19
23
|
"type": "object",
|
|
20
24
|
"properties": {}
|
|
@@ -50,9 +54,10 @@ def all_tools() -> list[Tool]:
|
|
|
50
54
|
Tool(
|
|
51
55
|
name="devcouncil_get_gaps",
|
|
52
56
|
description=(
|
|
53
|
-
"Read
|
|
54
|
-
"verification.
|
|
55
|
-
"
|
|
57
|
+
"Read persisted verification gaps for a task WITHOUT re-running "
|
|
58
|
+
"verification. Returns a compact projection (IDs, severity, type, "
|
|
59
|
+
"file/line) within a ~32 KB context budget; use get_task for full "
|
|
60
|
+
"detail. Cheap and idempotent for resume/repair decisions."
|
|
56
61
|
),
|
|
57
62
|
inputSchema={
|
|
58
63
|
"type": "object",
|
|
@@ -66,9 +71,10 @@ def all_tools() -> list[Tool]:
|
|
|
66
71
|
Tool(
|
|
67
72
|
name="devcouncil_get_next_actions",
|
|
68
73
|
description=(
|
|
69
|
-
"Get the typed
|
|
70
|
-
"
|
|
71
|
-
"
|
|
74
|
+
"Get the typed next-actions contract for a task from persisted gaps, "
|
|
75
|
+
"WITHOUT re-verifying. Compact by default (gap_id/category/action/file; "
|
|
76
|
+
"~32 KB context budget). Returns blocking next_actions, advisory_actions, "
|
|
77
|
+
"and allowed_next_tools."
|
|
72
78
|
),
|
|
73
79
|
inputSchema={
|
|
74
80
|
"type": "object",
|
|
@@ -166,7 +172,11 @@ def all_tools() -> list[Tool]:
|
|
|
166
172
|
),
|
|
167
173
|
Tool(
|
|
168
174
|
name="devcouncil_list_tasks",
|
|
169
|
-
description=
|
|
175
|
+
description=(
|
|
176
|
+
"List DevCouncil tasks as compact rows (id/title/status/priority/"
|
|
177
|
+
"requirements/lease) within a ~32 KB context budget. Supports status "
|
|
178
|
+
"filter and limit/offset paging; use get_task for full detail."
|
|
179
|
+
),
|
|
170
180
|
inputSchema={
|
|
171
181
|
"type": "object",
|
|
172
182
|
"properties": {
|
|
@@ -710,13 +720,21 @@ def all_tools() -> list[Tool]:
|
|
|
710
720
|
"Read a repository file (read-only, no lease required) so an MCP-only "
|
|
711
721
|
"agent can inspect content before constructing a diff or overwriting it. "
|
|
712
722
|
"Containment-checked against the project root and refuses secret/credential "
|
|
713
|
-
"paths.
|
|
714
|
-
"(
|
|
723
|
+
"paths. When task_id is set, the path must intersect that task's planned "
|
|
724
|
+
"files (fail-closed; never broadens scope). Supports offset/limit or "
|
|
725
|
+
"line_range windowing. Returns content (truncated), sha256, and line_count."
|
|
715
726
|
),
|
|
716
727
|
inputSchema={
|
|
717
728
|
"type": "object",
|
|
718
729
|
"properties": {
|
|
719
730
|
"path": {"type": "string", "description": "Repository-relative or absolute path inside the project."},
|
|
731
|
+
"task_id": {
|
|
732
|
+
"type": "string",
|
|
733
|
+
"description": (
|
|
734
|
+
"Optional task scope. When set, only planned-file paths for "
|
|
735
|
+
"that task may be read."
|
|
736
|
+
),
|
|
737
|
+
},
|
|
720
738
|
"offset": {"type": "integer", "minimum": 0, "description": "0-based line offset to start from."},
|
|
721
739
|
"limit": {"type": "integer", "minimum": 1, "description": "Max number of lines to return."},
|
|
722
740
|
"line_range": {
|
|
@@ -10,6 +10,7 @@ from devcouncil.integrations.mcp.util import (
|
|
|
10
10
|
error_text,
|
|
11
11
|
json_text,
|
|
12
12
|
optional_string_argument,
|
|
13
|
+
parse_cli_json,
|
|
13
14
|
run_cli_command,
|
|
14
15
|
truncate_text,
|
|
15
16
|
)
|
|
@@ -29,18 +30,10 @@ def _apply_body_truncation(payload: dict) -> dict:
|
|
|
29
30
|
async def handle_wiki_page(root: Path, arguments: dict) -> list[TextContent]:
|
|
30
31
|
page = optional_string_argument(arguments, "page")
|
|
31
32
|
query = optional_string_argument(arguments, "query")
|
|
32
|
-
|
|
33
|
-
_wiki_cli_args(page=page, query=query),
|
|
34
|
-
root,
|
|
33
|
+
payload, _cli_error = parse_cli_json(
|
|
34
|
+
run_cli_command(_wiki_cli_args(page=page, query=query), root, truncate=False),
|
|
35
35
|
)
|
|
36
|
-
if
|
|
37
|
-
import json
|
|
38
|
-
|
|
39
|
-
try:
|
|
40
|
-
payload = json.loads(str(result.get("stdout") or "{}"))
|
|
41
|
-
except json.JSONDecodeError:
|
|
42
|
-
payload = read_wiki_page(root, page=page, query=query)
|
|
43
|
-
else:
|
|
36
|
+
if payload is None:
|
|
44
37
|
payload = read_wiki_page(root, page=page, query=query)
|
|
45
38
|
if not payload.get("ok", True):
|
|
46
39
|
return error_text(
|
|
@@ -329,7 +329,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
329
329
|
return await handoff_handlers.handle_handoff_agent(root, db, arguments)
|
|
330
330
|
|
|
331
331
|
if name == "devcouncil_read_file":
|
|
332
|
-
return await read_handlers.handle_read_file(root, arguments)
|
|
332
|
+
return await read_handlers.handle_read_file(root, arguments, db=db)
|
|
333
333
|
|
|
334
334
|
if name == "devcouncil_get_diff":
|
|
335
335
|
return await git_handlers.handle_get_diff(root, db, arguments)
|
|
@@ -243,7 +243,24 @@ def parse_cli_json(result: dict[str, object]) -> tuple[dict | None, list[TextCon
|
|
|
243
243
|
return None, error_text("CLI command returned invalid JSON", code="cli_parse_error")
|
|
244
244
|
|
|
245
245
|
|
|
246
|
-
def
|
|
246
|
+
def _cli_stream_text(value: str | bytes | None, *, truncate: bool) -> tuple[str, bool]:
|
|
247
|
+
"""Optionally truncate a CLI stream for external raw-text boundaries."""
|
|
248
|
+
if truncate:
|
|
249
|
+
return truncate_text(value)
|
|
250
|
+
if value is None:
|
|
251
|
+
return "", False
|
|
252
|
+
if isinstance(value, bytes):
|
|
253
|
+
return value.decode("utf-8", errors="replace"), False
|
|
254
|
+
return value, False
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def run_cli_command(args: list[str], root: Path, *, truncate: bool = False) -> dict[str, object]:
|
|
258
|
+
"""Run ``devcouncil`` CLI and return a structured subprocess payload.
|
|
259
|
+
|
|
260
|
+
By default stdout/stderr are kept intact so structured JSON handlers can
|
|
261
|
+
parse large payloads. Pass ``truncate=True`` only for external raw-text
|
|
262
|
+
surfaces such as ``devcouncil_cli`` and report previews.
|
|
263
|
+
"""
|
|
247
264
|
command = [sys.executable, "-m", "devcouncil", *args, "--project-root", str(root)]
|
|
248
265
|
try:
|
|
249
266
|
result = subprocess.run(
|
|
@@ -255,8 +272,8 @@ def run_cli_command(args: list[str], root: Path) -> dict[str, object]:
|
|
|
255
272
|
errors="replace",
|
|
256
273
|
timeout=_CLI_TIMEOUT_SECONDS,
|
|
257
274
|
)
|
|
258
|
-
stdout, stdout_truncated =
|
|
259
|
-
stderr, stderr_truncated =
|
|
275
|
+
stdout, stdout_truncated = _cli_stream_text(result.stdout, truncate=truncate)
|
|
276
|
+
stderr, stderr_truncated = _cli_stream_text(result.stderr, truncate=truncate)
|
|
260
277
|
return {
|
|
261
278
|
"ok": result.returncode == 0,
|
|
262
279
|
"returncode": result.returncode,
|
|
@@ -267,8 +284,8 @@ def run_cli_command(args: list[str], root: Path) -> dict[str, object]:
|
|
|
267
284
|
"timed_out": False,
|
|
268
285
|
}
|
|
269
286
|
except subprocess.TimeoutExpired as exc:
|
|
270
|
-
stdout, stdout_truncated =
|
|
271
|
-
stderr, stderr_truncated =
|
|
287
|
+
stdout, stdout_truncated = _cli_stream_text(exc.output, truncate=truncate)
|
|
288
|
+
stderr, stderr_truncated = _cli_stream_text(exc.stderr, truncate=truncate)
|
|
272
289
|
return {
|
|
273
290
|
"ok": False,
|
|
274
291
|
"returncode": None,
|
|
@@ -281,6 +298,11 @@ def run_cli_command(args: list[str], root: Path) -> dict[str, object]:
|
|
|
281
298
|
}
|
|
282
299
|
|
|
283
300
|
|
|
301
|
+
def run_cli_json(args: list[str], root: Path) -> tuple[dict | None, list[TextContent] | None]:
|
|
302
|
+
"""Run CLI without truncating stdout, then parse structured JSON."""
|
|
303
|
+
return parse_cli_json(run_cli_command(args, root, truncate=False))
|
|
304
|
+
|
|
305
|
+
|
|
284
306
|
GIT_APPLY_TIMEOUT_SECONDS = _GIT_APPLY_TIMEOUT_SECONDS
|
|
285
307
|
|
|
286
308
|
|
|
@@ -3,17 +3,35 @@ from __future__ import annotations
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
5
|
from devcouncil.live.cards import load_cards, unresolved_blocking_cards
|
|
6
|
-
from devcouncil.live.signals import load_signals
|
|
6
|
+
from devcouncil.live.signals import ReviewSignal, load_signals
|
|
7
7
|
from devcouncil.live.tasks import active_task_id
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def
|
|
10
|
+
def _compact_signal_item(signal: ReviewSignal) -> dict:
|
|
11
|
+
"""IDs/counts-safe projection for general status (no PII / absolute paths)."""
|
|
12
|
+
signal_id = Path(signal.path).name if signal.path else None
|
|
13
|
+
return {
|
|
14
|
+
"id": signal_id,
|
|
15
|
+
"client": signal.client,
|
|
16
|
+
"task_id": signal.task_id,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def live_review_summary(
|
|
21
|
+
project_root: Path,
|
|
22
|
+
task_id: str | None = None,
|
|
23
|
+
*,
|
|
24
|
+
include_signal_details: bool = False,
|
|
25
|
+
) -> dict:
|
|
11
26
|
cards = load_cards(project_root)
|
|
12
27
|
signals = load_signals(project_root)
|
|
13
28
|
active_id = active_task_id(project_root)
|
|
14
29
|
scoped_task_id = task_id or active_id
|
|
15
30
|
blockers = unresolved_blocking_cards(project_root, task_id=scoped_task_id, cards=cards)
|
|
16
|
-
|
|
31
|
+
if include_signal_details:
|
|
32
|
+
pending_signal_items = [signal.model_dump() for signal in signals]
|
|
33
|
+
else:
|
|
34
|
+
pending_signal_items = [_compact_signal_item(signal) for signal in signals]
|
|
17
35
|
open_count = 0
|
|
18
36
|
resolved_count = 0
|
|
19
37
|
ignored_count = 0
|