From e023c21aa1ed551fab828e97390c2bb902487195 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 09:24:26 -0400 Subject: [PATCH 1/4] docs(mcp): instruct agents to drive task lifecycle + log work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'keep task state honest' directive to the MCP _INSTRUCTIONS: set in_progress on start, log progress with add_task_log as you go, set done the moment work completes (never leave finished work at todo), and write a dated dev-log note on the project at significant landings. Reinforced in the update_task status docstring. App-layer + always-loaded, no rule/config needed — closes the gap where finished work (e.g. a shipped plan) sat open because the lifecycle was available but never prescribed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/fabledassistant/mcp/server.py | 12 ++++++++++++ src/fabledassistant/mcp/tools/tasks.py | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/fabledassistant/mcp/server.py b/src/fabledassistant/mcp/server.py index 81fe11e..79de92b 100644 --- a/src/fabledassistant/mcp/server.py +++ b/src/fabledassistant/mcp/server.py @@ -38,6 +38,18 @@ Mechanics: - For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean "not set". +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 start of any session that touches Scribe, call list_always_on_rules() to diff --git a/src/fabledassistant/mcp/tools/tasks.py b/src/fabledassistant/mcp/tools/tasks.py index 056c769..ce6b81c 100644 --- a/src/fabledassistant/mcp/tools/tasks.py +++ b/src/fabledassistant/mcp/tools/tasks.py @@ -143,7 +143,9 @@ 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. From 8c9ca45479b0bbbfc1f1c481110a76afcd6cd46a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 10:42:23 -0400 Subject: [PATCH 2/4] feat(mcp): instruct agents to keep a Scribe-rules pointer in host memory When a project subscribes to a rulebook, the agent should ensure the host's persistent memory carries a pointer that engineering/workflow rules live in Scribe (loaded via list_always_on_rules / enter_project), plus a one-line note of the current project's work. Pairs with the existing 'don't duplicate rules into memory' directive: memory holds the pointer + project context, Scribe holds the rules. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/fabledassistant/mcp/server.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/fabledassistant/mcp/server.py b/src/fabledassistant/mcp/server.py index 79de92b..d6b1a5b 100644 --- a/src/fabledassistant/mcp/server.py +++ b/src/fabledassistant/mcp/server.py @@ -68,6 +68,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 From 82d6812c7fbabca5455a01054596ddd09cb3aef8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 10:59:47 -0400 Subject: [PATCH 3/4] feat(mcp): milestone_id=-1 clears a task's milestone (update_task) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional FKs on update_task previously had no way to express 'remove' — 0 meant leave-unchanged and any positive int meant set, so a milestone (or project) could only be cleared via the web UI. Now -1 clears the FK (NULL); clearing project_id also clears milestone_id since a milestone can't outlive its project. update_note already NULLs on None, so the change is confined to the tool wrapper. Closes scribe task #586. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/fabledassistant/mcp/server.py | 3 +- src/fabledassistant/mcp/tools/tasks.py | 18 ++++++++---- tests/test_mcp_tool_tasks.py | 40 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/fabledassistant/mcp/server.py b/src/fabledassistant/mcp/server.py index d6b1a5b..bfd1cd3 100644 --- a/src/fabledassistant/mcp/server.py +++ b/src/fabledassistant/mcp/server.py @@ -36,7 +36,8 @@ 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 diff --git a/src/fabledassistant/mcp/tools/tasks.py b/src/fabledassistant/mcp/tools/tasks.py index ce6b81c..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 @@ -147,8 +147,10 @@ async def update_task( 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 = {} @@ -160,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() From f446573c3dd33ac944db5544b87f2500495d9887 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 11:01:55 -0400 Subject: [PATCH 4/4] feat(mcp): proactive project bootstrapping at session start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an always-on _INSTRUCTIONS directive: when work touches Scribe and no project is in scope, search for a related project and propose enter_project (confirm first), or offer to create one (confirm name/goal first) — never silently adopt or create. Pairs with the enter_project handshake and the host-memory pointer directive. Closes scribe task #585. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/fabledassistant/mcp/server.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/fabledassistant/mcp/server.py b/src/fabledassistant/mcp/server.py index bfd1cd3..e360897 100644 --- a/src/fabledassistant/mcp/server.py +++ b/src/fabledassistant/mcp/server.py @@ -89,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