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.
@@ -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 subprocess.run(
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
- status_by_path: dict[str, str] = {}
54
- for line in namestatus_proc.stdout.splitlines():
55
- parts = line.split("\t")
56
- if len(parts) >= 2:
57
- status_by_path[parts[-1].replace("\\", "/")] = parts[0]
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
- unified_diff, truncated = truncate_text(diff_proc.stdout)
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
- scope_paths: list[str] = list(explicit_paths)
90
- if task_id and db:
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 planned in task.planned_files:
96
- p = planned.path.replace("\\", "/")
97
- if p not in scope_paths:
98
- scope_paths.append(p)
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
- result = run_cli_command(["graph-context", "--json", *file_args], root)
25
- if result.get("ok"):
26
- try:
27
- payload = json.loads(str(result.get("stdout") or "{}"))
28
- return json_text(payload)
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
- result = run_cli_command(["okf", "select", "--json", "--goal", goal], root)
24
- if result.get("ok"):
25
- try:
26
- payload = json.loads(str(result.get("stdout") or "{}"))
27
- return json_text(payload)
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 json_text, required_string_argument, run_cli_command
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 = _cli_json(root, ["provenance", task_id, "--json"])
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
- result = run_cli_command(["resource", "list", "--json"], root)
61
- stdout = str(result.get("stdout") or "").strip()
62
- if stdout:
63
- try:
64
- payload = json.loads(stdout)
65
- descriptors = payload.get("resources") or []
66
- if isinstance(descriptors, list):
67
- return [
68
- Resource(
69
- uri=AnyUrl(item["uri"]),
70
- name=item["name"],
71
- description=item["description"],
72
- mimeType=item["mimeType"],
73
- )
74
- for item in descriptors
75
- if isinstance(item, dict) and item.get("uri")
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(root: Path, arguments: dict) -> list[TextContent]:
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
- run_cli_command,
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 = _cli_json(root, cli_args)
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 = _cli_json(root, ["runs", "show", run_id, "--json"])
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