From 4609abacd85ffb885f01fd35ecee190f4b933af5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 10:17:20 -0400 Subject: [PATCH] feat(plan): start_planning MCP tool + get_task rules augmentation + instructions --- src/fabledassistant/mcp/server.py | 6 +++ src/fabledassistant/mcp/tools/tasks.py | 33 +++++++++++++- tests/test_mcp_tool_planning.py | 59 ++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/test_mcp_tool_planning.py diff --git a/src/fabledassistant/mcp/server.py b/src/fabledassistant/mcp/server.py index 563ad36..561b93d 100644 --- a/src/fabledassistant/mcp/server.py +++ b/src/fabledassistant/mcp/server.py @@ -30,6 +30,12 @@ text (Why / How-to-apply) is available via get_rule(id). You may create new rules via create_rule when you notice a pattern worth codifying — coordinate with the operator on whether it belongs in an existing rulebook+topic or a new one. + +Plans are tasks with kind=plan. Begin a plan with start_planning(project_id, +title) — that seeds a plan template and returns the project's applicable_rules. +Maintain the plan with the normal task tools (update_task for the body, +add_task_log for progress); its work-logs are the build record. Build plans in +Scribe via start_planning, not in local .md files. """ diff --git a/src/fabledassistant/mcp/tools/tasks.py b/src/fabledassistant/mcp/tools/tasks.py index 8c7bbfe..a1ce839 100644 --- a/src/fabledassistant/mcp/tools/tasks.py +++ b/src/fabledassistant/mcp/tools/tasks.py @@ -20,6 +20,8 @@ from __future__ import annotations from fabledassistant.mcp._context import current_user_id from fabledassistant.services import notes as notes_svc +from fabledassistant.services import planning as planning_svc +from fabledassistant.services import rulebooks as rulebooks_svc from fabledassistant.services import task_logs as task_logs_svc @@ -56,7 +58,9 @@ async def get_task(task_id: int) -> dict: """Fetch a single Scribe task by ID. Returns id, title, body, status, priority, tags, project_id, milestone_id, - parent_id, parent_title, due_date, created_at, updated_at. + parent_id, parent_title, due_date, created_at, updated_at. For kind=plan + tasks, the response also includes applicable_rules + subscribed_rulebooks + from the task's project's rulebook subscriptions. """ uid = current_user_id() note = await notes_svc.get_note(uid, task_id) @@ -69,6 +73,14 @@ async def get_task(task_id: int) -> dict: if parent is not None: parent_title = parent.title data["parent_title"] = parent_title + + if data.get("task_kind") == "plan" and note.project_id: + applicable = await rulebooks_svc.get_applicable_rules( + project_id=note.project_id, user_id=uid, + ) + data["applicable_rules"] = applicable["rules"] + data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"] + data["applicable_rules_truncated"] = applicable["truncated"] return data @@ -167,6 +179,24 @@ async def add_task_log(task_id: int, content: str) -> dict: } +async def start_planning(project_id: int, title: str) -> dict: + """Begin a plan in Scribe (the preferred home for plans — not a local .md file). + + Creates a plan-task (a task with kind=plan) seeded with a plan template under + the given project, and returns it together with the project's applicable + Rulebook rules and brief context. Maintain the plan afterwards with the normal + task tools (update_task to edit the body, add_task_log to record progress). + + Args: + project_id: The project this plan is for. + title: A short title for the plan. + """ + uid = current_user_id() + return await planning_svc.start_planning( + user_id=uid, project_id=project_id, title=title, + ) + + def register(mcp) -> None: for fn in ( list_tasks, @@ -174,5 +204,6 @@ def register(mcp) -> None: create_task, update_task, add_task_log, + start_planning, ): mcp.tool(name=fn.__name__)(fn) diff --git a/tests/test_mcp_tool_planning.py b/tests/test_mcp_tool_planning.py new file mode 100644 index 0000000..ef003df --- /dev/null +++ b/tests/test_mcp_tool_planning.py @@ -0,0 +1,59 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from fabledassistant.mcp._context import _user_id_ctx + + +@pytest.fixture(autouse=True) +def _bind_user(): + token = _user_id_ctx.set(7) + yield + _user_id_ctx.reset(token) + + +@pytest.mark.asyncio +async def test_start_planning_tool_delegates_to_service(): + payload = {"task": {"id": 5}, "applicable_rules": [], "subscribed_rulebooks": [], + "applicable_rules_truncated": False, "project_goal": "", "open_task_count": 0} + with patch("fabledassistant.mcp.tools.tasks.planning_svc.start_planning", + AsyncMock(return_value=payload)) as mock: + from fabledassistant.mcp.tools.tasks import start_planning + out = await start_planning(project_id=3, title="Plan it") + assert out["task"]["id"] == 5 + assert mock.call_args.kwargs == {"user_id": 7, "project_id": 3, "title": "Plan it"} + + +@pytest.mark.asyncio +async def test_get_task_augments_plan_with_rules(): + note = MagicMock() + note.parent_id = None + note.project_id = 3 + note.to_dict.return_value = {"id": 9, "task_kind": "plan", "project_id": 3} + applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False, + "subscribed_rulebooks": [{"id": 2, "title": "rb"}]} + with patch("fabledassistant.mcp.tools.tasks.notes_svc.get_note", + AsyncMock(return_value=note)), \ + patch("fabledassistant.mcp.tools.tasks.rulebooks_svc.get_applicable_rules", + AsyncMock(return_value=applicable)): + from fabledassistant.mcp.tools.tasks import get_task + out = await get_task(task_id=9) + assert out["applicable_rules"] == [{"id": 1, "title": "r"}] + assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}] + assert out["applicable_rules_truncated"] is False + + +@pytest.mark.asyncio +async def test_get_task_work_kind_has_no_rules(): + note = MagicMock() + note.parent_id = None + note.project_id = 3 + note.to_dict.return_value = {"id": 9, "task_kind": "work", "project_id": 3} + with patch("fabledassistant.mcp.tools.tasks.notes_svc.get_note", + AsyncMock(return_value=note)), \ + patch("fabledassistant.mcp.tools.tasks.rulebooks_svc.get_applicable_rules", + AsyncMock()) as mock_rules: + from fabledassistant.mcp.tools.tasks import get_task + out = await get_task(task_id=9) + assert "applicable_rules" not in out + assert not mock_rules.called