diff --git a/src/fabledassistant/mcp/server.py b/src/fabledassistant/mcp/server.py index 81fe11e..e360897 100644 --- a/src/fabledassistant/mcp/server.py +++ b/src/fabledassistant/mcp/server.py @@ -36,7 +36,20 @@ Mechanics: - Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave unchanged on updates. - For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean - "not set". + "not set". On update_task, -1 clears an existing FK (e.g. milestone_id=-1 + removes the task from its milestone); 0 leaves it unchanged. + +Keep task state honest — this is what makes the project a trustworthy record: +- When you begin working a task, set it to in_progress (update_task + status=in_progress). +- Log progress as you go with add_task_log — at meaningful steps, not saved up + for the end. +- The moment a task's work is complete, set it done. Never leave finished work + at todo/in_progress — an out-of-date status makes Scribe misrepresent what's + left to do. +- At a significant landing (a merge, a shipped feature, a finished plan), write + a short dated dev-log note on the project (create_note) summarizing what + landed, and mark the plan/task done. Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry an actionable statement plus optional Why and How-to-apply context. At the @@ -56,6 +69,19 @@ creating a rule, call list_always_on_rules and list_rules(project_id=...) to avoid duplicates. Coordinate with the operator on whether a new rule belongs in a project, an existing rulebook+topic, or a new rulebook. +That boundary cuts the other way too. Because rules are pull-only, a fresh +session won't reach for them unless its always-loaded context says to. So +when the current project subscribes to a rulebook (enter_project / +get_project reports subscribed_rulebooks), make sure the host's persistent +memory — the CLAUDE.md / AGENTS.md / ~/.claude memory the client loads at +startup — carries a short pointer: that the engineering and workflow rules +for this work live in Scribe and must be loaded via list_always_on_rules (or +enter_project when a project is in scope), plus a one-line note of what the +current project is and what is in flight. Add or refresh that pointer when +it's missing or stale; never copy the rules' content into memory — the +pointer plus project context is the whole job. This is what lets the next +session reach for Scribe instead of trusting a stale local copy. + When you are working on a specific project, call enter_project(project_id) ONCE at session start (or whenever the active project changes). It returns the project, its applicable_rules + project_rules + subscribed_rulebooks, milestone @@ -63,6 +89,17 @@ summary, open tasks, and recent notes — everything you need to know the lay of the land before mutating. Don't call get_project + get_applicable_rules + a search separately when enter_project already composes them. +Don't wait to be told which project you're in. At the start of a session that +touches Scribe — or the moment work clearly belongs to a project but none is in +scope — bootstrap project context proactively: search for a related existing +project (search / list_projects, matching on the work's subject, the repo or +directory name, and recent activity). If you find a confident match, propose it +and call enter_project once the operator confirms. If nothing matches, offer to +create a project, confirming its name and goal first. Always confirm before +adopting or creating — never do either silently, and never guess a project into +existence. Once a project is in scope, the enter_project handshake and the +host-memory pointer step above both apply. + Plans are tasks with kind=plan, and Scribe is the canonical home for them. When you begin non-trivial work, call start_planning(project_id, title) FIRST — before any brainstorming, design, or plan-writing skill runs. start_planning diff --git a/src/fabledassistant/mcp/tools/tasks.py b/src/fabledassistant/mcp/tools/tasks.py index 056c769..c7aae85 100644 --- a/src/fabledassistant/mcp/tools/tasks.py +++ b/src/fabledassistant/mcp/tools/tasks.py @@ -14,7 +14,7 @@ Sentinels (preserved from existing fable-mcp): what makes a Note a Task) - priority="none" sets explicit no-priority; priority="" is "leave unchanged" - project_id=0 / milestone_id=0 / parent_id=0 → "no association" on create, - "leave unchanged" on update + "leave unchanged" on update; on update, -1 clears the FK (sets it NULL) """ from __future__ import annotations @@ -143,10 +143,14 @@ async def update_task( task_id: ID of the task to update. title: New title, or omit to leave unchanged. body: New markdown body, or omit to leave unchanged. - status: New status — one of: todo, in_progress, done, cancelled. + status: New status — one of: todo, in_progress, done, cancelled. Drive + the lifecycle: set in_progress when you start, done when complete — + don't leave finished work at todo. priority: New priority — one of: none, low, medium, high. - project_id: New project. Omit (0) to leave unchanged. - milestone_id: New milestone. Omit (0) to leave unchanged. + project_id: New project. 0 = leave unchanged, -1 = clear (remove from + its project; also clears the milestone), positive = set. + milestone_id: New milestone. 0 = leave unchanged, -1 = clear (remove + from its milestone), positive = set. """ uid = current_user_id() fields: dict = {} @@ -158,9 +162,15 @@ async def update_task( fields["status"] = status if priority: fields["priority"] = priority - if project_id: + # Optional FKs: 0 = leave unchanged, -1 = clear (set NULL), positive = set. + if project_id == -1: + fields["project_id"] = None + fields["milestone_id"] = None # a milestone can't outlive its project + elif project_id: fields["project_id"] = project_id - if milestone_id: + if milestone_id == -1: + fields["milestone_id"] = None + elif milestone_id: fields["milestone_id"] = milestone_id note = await notes_svc.update_note(uid, task_id, **fields) if note is None: diff --git a/tests/test_mcp_tool_tasks.py b/tests/test_mcp_tool_tasks.py index aa2f5ec..44052c5 100644 --- a/tests/test_mcp_tool_tasks.py +++ b/tests/test_mcp_tool_tasks.py @@ -164,6 +164,46 @@ async def test_update_task_raises_when_not_found(): await update_task(task_id=999, status="done") +@pytest.mark.asyncio +async def test_update_task_milestone_zero_is_omitted(): + """milestone_id=0 is 'leave unchanged' — must not reach the service.""" + fake = _fake_task() + mock = AsyncMock(return_value=fake) + with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock): + await update_task(task_id=1, milestone_id=0) + assert "milestone_id" not in mock.call_args.kwargs + + +@pytest.mark.asyncio +async def test_update_task_milestone_positive_is_set(): + fake = _fake_task() + mock = AsyncMock(return_value=fake) + with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock): + await update_task(task_id=1, milestone_id=42) + assert mock.call_args.kwargs["milestone_id"] == 42 + + +@pytest.mark.asyncio +async def test_update_task_milestone_negative_one_clears(): + """milestone_id=-1 clears the milestone (sets the column NULL).""" + fake = _fake_task() + mock = AsyncMock(return_value=fake) + with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock): + await update_task(task_id=1, milestone_id=-1) + assert mock.call_args.kwargs["milestone_id"] is None + + +@pytest.mark.asyncio +async def test_update_task_clearing_project_also_clears_milestone(): + """project_id=-1 clears the project and, with it, the milestone.""" + fake = _fake_task() + mock = AsyncMock(return_value=fake) + with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock): + await update_task(task_id=1, project_id=-1) + assert mock.call_args.kwargs["project_id"] is None + assert mock.call_args.kwargs["milestone_id"] is None + + @pytest.mark.asyncio async def test_add_task_log_returns_log_dict(): log = MagicMock()