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.
@@ -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
- async def handle_status(root: Path, db: object, arguments: dict) -> list[TextContent]:
32
- del db # routed through CLI service layer
33
- result = run_cli_command(["status", "--json"], root)
34
- if not result.get("ok"):
35
- stderr = str(result.get("stderr") or "status command failed")
36
- return error_text(stderr, code="cli_failed")
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
- payload = json.loads(str(result.get("stdout") or "{}"))
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
- status_str = (
46
- f"Phase: {phase}\n"
47
- f"Requirements: {summary.get('total_requirements', 0)} ({summary.get('requirements_without_tasks', 0)} unmapped)\n"
48
- f"Tasks: {summary.get('total_tasks', 0)} ({summary.get('tasks_without_requirements', 0)} orphaned)\n"
49
- f"Gaps: {summary.get('total_gaps', 0)} ({summary.get('blocking_gaps', 0)} blocking)\n"
50
- )
51
- return [TextContent(type="text", text=status_str)]
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 # routed through CLI service layer
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 # routed through CLI service layer
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 = _cli_json(root, cli_args)
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 # routed through CLI service layer
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 = _cli_json(root, ["gaps", "--json", "--task-id", task_id, "--next-actions"])
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 # routed through CLI service layer
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 = _cli_json(root, cli_args)
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
- run_cli_command,
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 = _cli_json(root, ["show", task_id, "--json"])
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 = _cli_json(root, ["prompt", task_id, "--json"])
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 = _cli_json(root, ["show", task_id, "--json"])
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 = _cli_json(root, ["prompt", task_id, "--json"])
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="Get the current status of the DevCouncil project, including phase, tasks, and gaps.",
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 the persisted verification gaps for a task WITHOUT re-running "
54
- "verification. Cheap and idempotent use it to resume after a "
55
- "reconnect or to inspect outstanding work before deciding to repair."
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, machine-routable next-actions contract for a task from "
70
- "its persisted gaps, WITHOUT re-verifying. Returns blocking next_actions "
71
- "plus advisory_actions and the tools allowed next."
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="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.",
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. Supports offset/limit or line_range windowing. Returns content "
714
- "(truncated), sha256, and line_count."
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
- result = run_cli_command(
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 result.get("ok"):
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 run_cli_command(args: list[str], root: Path) -> dict[str, object]:
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 = truncate_text(result.stdout)
259
- stderr, stderr_truncated = truncate_text(result.stderr)
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 = truncate_text(exc.output)
271
- stderr, stderr_truncated = truncate_text(exc.stderr)
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 live_review_summary(project_root: Path, task_id: str | None = None) -> dict:
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
- pending_signal_items = [signal.model_dump() for signal in signals]
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
package/uv.lock CHANGED
@@ -383,7 +383,7 @@ wheels = [
383
383
 
384
384
  [[package]]
385
385
  name = "devcouncil"
386
- version = "0.4.0"
386
+ version = "0.4.2"
387
387
  source = { editable = "." }
388
388
  dependencies = [
389
389
  { name = "gepa" },
@@ -430,7 +430,7 @@ semantic = [
430
430
  requires-dist = [
431
431
  { name = "devcouncil-codeintel-grammars", marker = "extra == 'codeintel-full'", editable = "packages/codeintel-grammars" },
432
432
  { name = "gepa", specifier = ">=0.1.1" },
433
- { name = "gitpython", specifier = ">=3.1.49" },
433
+ { name = "gitpython", specifier = ">=3.1.53" },
434
434
  { name = "httpx", specifier = ">=0.27.0" },
435
435
  { name = "mcp", specifier = ">=1.27.2" },
436
436
  { name = "networkx", specifier = ">=3.3" },
@@ -535,14 +535,14 @@ wheels = [
535
535
 
536
536
  [[package]]
537
537
  name = "gitpython"
538
- version = "3.1.50"
538
+ version = "3.1.53"
539
539
  source = { registry = "https://pypi.org/simple" }
540
540
  dependencies = [
541
541
  { name = "gitdb" },
542
542
  ]
543
- sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" }
543
+ sdist = { url = "https://files.pythonhosted.org/packages/17/24/0e0c12cb6f7cb864779a9d2fefee9ca91838f6db402c8780c9d28a8d7ebe/gitpython-3.1.53.tar.gz", hash = "sha256:06ae8d9623b0ed0d67b8adeac5c7008d0a5a404b087a9e0d0c7163bdd3a6b497", size = 224597, upload-time = "2026-07-20T13:41:52.839Z" }
544
544
  wheels = [
545
- { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" },
545
+ { url = "https://files.pythonhosted.org/packages/cf/a6/bff12b3238885eeef7d28ef908b24e0cba91c476c31cb876a00a0986ce2c/gitpython-3.1.53-py3-none-any.whl", hash = "sha256:187885556b64ab357bd4ea84e2c4cce2861a613a7f4268b3f7f7ba05f2ce4ab0", size = 216237, upload-time = "2026-07-20T13:41:51.473Z" },
546
546
  ]
547
547
 
548
548
  [[package]]