From 1f6c5922261623ba02e57723571b1af89f55e397 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 14 Jun 2026 12:22:22 -0400 Subject: [PATCH] feat(plans): milestone-as-plan-container; retire kind=plan (T3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The milestone becomes the plan container: a new nullable milestones.body holds the design/intent (Goal/Approach/Verification) and individual steps live as first-class child tasks (milestone_id) instead of checkboxes crammed into one kind=plan task body. start_planning now creates a MILESTONE seeded with the body template (not a kind=plan task) and returns it with applicable rules; a new get_milestone MCP tool reads the plan back (body + steps + rules). kind=plan is hard-retired going forward — start_planning never creates one. The 'plan' task_kind enum value stays valid so the 11 historical plan-tasks remain readable in place; no body-shredding backfill (corpus review showed auto-splitting their checklists into tasks would be lossy: embedded code blocks, a non-binary [~] state, tables, ID-encoded hierarchy). - migration 0066: add milestones.body - model/service/route/MCP: body passthrough on create+update; get_milestone - server _INSTRUCTIONS: "plan" = milestone w/ body + child step-tasks - UI: ProjectView shows/edits a milestone's plan body; start_planning expands the new milestone and opens its plan editor - tests updated to the milestone contract + new body/get_milestone coverage Co-Authored-By: Claude Opus 4.8 (1M context) --- alembic/versions/0066_milestone_body.py | 32 ++++++++ frontend/src/types/task.ts | 12 ++- frontend/src/views/ProjectView.vue | 105 +++++++++++++++++++++++- src/scribe/mcp/server.py | 36 +++++--- src/scribe/mcp/tools/milestones.py | 57 ++++++++++++- src/scribe/mcp/tools/tasks.py | 20 +++-- src/scribe/models/milestone.py | 5 ++ src/scribe/routes/milestones.py | 3 +- src/scribe/services/milestones.py | 2 + src/scribe/services/planning.py | 31 ++++--- tests/test_mcp_tool_milestones.py | 60 +++++++++++++- tests/test_mcp_tool_planning.py | 4 +- tests/test_services_planning.py | 19 +++-- 13 files changed, 336 insertions(+), 50 deletions(-) create mode 100644 alembic/versions/0066_milestone_body.py diff --git a/alembic/versions/0066_milestone_body.py b/alembic/versions/0066_milestone_body.py new file mode 100644 index 0000000..5f3fb0f --- /dev/null +++ b/alembic/versions/0066_milestone_body.py @@ -0,0 +1,32 @@ +"""milestone-as-plan-container: milestones.body holds the plan/design + +Revision ID: 0066 +Revises: 0065 +Create Date: 2026-06-14 + +T3 of plan #819. The milestone becomes the plan container: its `body` holds +the design/intent/purpose (markdown), `description` stays the one-liner, and +individual steps live as first-class child tasks (milestone_id) instead of +checkboxes crammed into a kind=plan task body. start_planning is reworked to +create a milestone instead of a kind=plan task (hard retirement going forward; +the 'plan' task_kind enum value stays valid so the historical plan-tasks are +left readable in place — no body-shredding backfill). + +Schema change is just one nullable column; no data migration. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0066" +down_revision = "0065" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("milestones", sa.Column("body", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("milestones", "body") diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts index 8b55262..93d6aeb 100644 --- a/frontend/src/types/task.ts +++ b/frontend/src/types/task.ts @@ -5,8 +5,18 @@ export interface TaskListResponse { total: number; } +// start_planning now creates a MILESTONE (the plan container), not a kind=plan +// task. The milestone's `body` holds the design; steps live as child tasks. export interface StartPlanningResult { - task: import("./note").Note; + milestone: { + id: number; + project_id: number; + title: string; + description: string | null; + body: string | null; + status: string; + order_index: number; + }; applicable_rules: { id: number; title: string; diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index 5290cce..4e2d9ea 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -5,6 +5,7 @@ import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client"; import { useToastStore } from "@/stores/toast"; import { useTasksStore } from "@/stores/tasks"; import { relativeTime } from "@/composables/useRelativeTime"; +import { renderMarkdown } from "@/utils/markdown"; import ShareDialog from "@/components/ShareDialog.vue"; import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue"; import SystemsSection from "@/components/SystemsSection.vue"; @@ -22,6 +23,8 @@ import { interface Milestone { id: number; title: string; + description: string | null; + body: string | null; status: string; order_index: number; pct: number; @@ -77,10 +80,15 @@ async function confirmStartPlanning() { if (!title || !project.value) return; planningBusy.value = true; try { + // start_planning creates a MILESTONE (the plan container). Reload milestones, + // make sure the new one is expanded, and open its plan editor. const result = await tasksStore.startPlanning(project.value.id, title); planTitle.value = ""; showStartPlanning.value = false; - router.push(`/tasks/${result.task.id}`); + await loadMilestones(); + collapsedMilestones.value.delete(result.milestone.id); + startEditPlan(result.milestone.id, result.milestone.body); + toast.show("Plan started"); } finally { planningBusy.value = false; } @@ -225,6 +233,39 @@ async function commitRenameMilestone(ms: Milestone) { } } +// Plan body editing — a milestone IS the plan; its `body` holds the design. +const editingPlanId = ref(null); +const editPlanBody = ref(""); +const savingPlan = ref(false); + +function startEditPlan(id: number, body: string | null) { + editingPlanId.value = id; + editPlanBody.value = body ?? ""; +} + +function cancelEditPlan() { + editingPlanId.value = null; + editPlanBody.value = ""; +} + +async function commitEditPlan(ms: Milestone) { + if (savingPlan.value) return; + savingPlan.value = true; + try { + await apiPatch(`/api/projects/${projectId.value}/milestones/${ms.id}`, { + body: editPlanBody.value, + }); + await loadMilestones(); + editingPlanId.value = null; + editPlanBody.value = ""; + toast.show("Plan saved"); + } catch { + toast.show("Failed to save plan", "error"); + } finally { + savingPlan.value = false; + } +} + async function confirmDeleteMilestone() { const ms = deletingMilestone.value; if (!ms) return; @@ -545,6 +586,9 @@ async function confirmDelete() { {{ group.milestone.pct }}%
+ @@ -555,6 +599,31 @@ async function confirmDelete() {
+ +
+ +
+
+
@@ -1082,6 +1151,40 @@ async function confirmDelete() { .milestone-header.clickable { cursor: pointer; } .milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); } +/* Plan body: the milestone's design/intent, shown above its task columns. */ +.ms-plan { + padding: 0.6rem 0.85rem; + background: color-mix(in srgb, var(--color-primary) 3%, var(--color-bg-card)); + border-bottom: 1px solid var(--color-border); +} +.ms-plan-rendered { font-size: 0.85rem; color: var(--color-text); cursor: text; } +.ms-plan-rendered:hover { background: color-mix(in srgb, var(--color-primary) 4%, transparent); } +.ms-plan-editor { + width: 100%; + font-family: var(--font-mono, monospace); + font-size: 0.8rem; + line-height: 1.5; + padding: 0.5rem; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg); + color: var(--color-text); + resize: vertical; + box-sizing: border-box; +} +.ms-plan-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 0.5rem; } +.ms-plan-actions .btn-primary, +.ms-plan-actions .btn-secondary { + font-size: 0.8rem; + padding: 0.3rem 0.75rem; + border-radius: 6px; + cursor: pointer; + border: 1px solid var(--color-border); +} +.ms-plan-actions .btn-primary { background: var(--color-primary); color: #fff; border-color: var(--color-primary); } +.ms-plan-actions .btn-primary:disabled { opacity: 0.6; cursor: default; } +.ms-plan-actions .btn-secondary { background: var(--color-bg-card); color: var(--color-text); } + .ms-chevron { display: flex; align-items: center; color: var(--color-text-muted); flex-shrink: 0; } .ms-name { font-weight: 500; color: var(--color-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ms-count { diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 1c5142c..c15f12f 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -17,14 +17,21 @@ Hierarchy: Project -> Milestone -> Task/Note. What each part is for, and when to reach for it: - Project: the top-level container for a body of work. - Milestone: groups related tasks within a project toward a goal (status - active/done). Use one when a chunk of work needs its own arc. + active/done). A milestone is ALSO the home of a plan — its `body` holds the + design/intent (Goal/Approach/Verification) and its child tasks are the steps. + Use one when a chunk of work needs its own arc. - Task: a unit of actionable work with a lifecycle (status todo/in_progress/done/cancelled, optional priority). A task is a note with a status — reach for one when there is something to DO. Record progress over time with work-logs (add_task_log) rather than rewriting the body. -- Plan: a task with kind=plan — HOW you'll execute a chunk of work. The body - holds the design + step checklist; work-logs record progress. Start one with - start_planning when beginning non-trivial work, before you dive in. +- Plan: a MILESTONE acting as a plan container — HOW you'll execute a chunk of + work. The design/intent lives in the milestone `body`; each step is its own + child task (create_task(milestone_id=...)), tracked with status + work-logs — + NOT a checkbox buried in the body. Start one with start_planning when + beginning non-trivial work, before you dive in; read it back with + get_milestone (body + steps). (The old kind=plan task is retired — some + historical plan-tasks still exist and remain readable, but don't create new + ones.) - Note: durable free-form knowledge — reference material, decisions, logs of what happened. No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping. @@ -147,14 +154,17 @@ adopting or creating — never do either silently, and never guess a project int 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 -seeds the plan body, returns the project's applicable_rules, and gives you the -task id you'll write into. If a habit tells you to save a plan or spec to a local -`.md` file, that's superseded here: put the spec/plan content in the kind=plan -task's body via update_task, and record progress with add_task_log. Local .md -files are not the record — the task is. +A plan is a MILESTONE, and Scribe is the canonical home for it. When you begin +non-trivial work, call start_planning(project_id, title) FIRST — before any +brainstorming, design, or plan-writing skill runs. start_planning creates the +milestone, seeds its `body` with the design template, returns the project's +applicable_rules, and gives you the milestone id you'll write into. Put the +design/intent in the milestone body via update_milestone(milestone_id, body=...); +create each step as a child task with create_task(milestone_id=...) and track it +with status + add_task_log — do NOT list steps as checkboxes in the body. Read +the plan back with get_milestone (body + steps). If a habit tells you to save a +plan or spec to a local `.md` file, that's superseded here: the milestone is the +record, not a local file. Deletes are recoverable: every delete_* tool moves the entity (and its descendants) to the trash and returns a deleted_batch_id. Use list_trash() to @@ -180,7 +190,7 @@ operator. "Works for one user" is not done. # until explicitly classified here. _READ_ONLY_TOOLS = frozenset({ "get_event", "get_note", "get_project", "get_rule", "get_rulebook", - "get_task", "get_recent", "enter_project", + "get_task", "get_milestone", "get_recent", "enter_project", "list_events", "list_lists", "list_milestones", "list_notes", "list_persons", "list_places", "list_projects", "list_rulebooks", "list_rules", "list_tags", "list_tasks", "list_topics", "list_trash", diff --git a/src/scribe/mcp/tools/milestones.py b/src/scribe/mcp/tools/milestones.py index d4973ab..26463e6 100644 --- a/src/scribe/mcp/tools/milestones.py +++ b/src/scribe/mcp/tools/milestones.py @@ -13,32 +13,75 @@ from __future__ import annotations from scribe.mcp._context import current_user_id from scribe.services import milestones as milestones_svc +from scribe.services import notes as notes_svc +from scribe.services import rulebooks as rulebooks_svc from scribe.services import trash as trash_svc async def list_milestones(project_id: int) -> dict: """List milestones for a Scribe project, ordered by order_index. - Returns id, title, description, status (active/done), order_index, - and task counts. + Returns id, title, description, body (the plan/design), status + (active/done), order_index, and task counts. """ uid = current_user_id() rows = await milestones_svc.get_project_milestone_summary(uid, project_id) return {"milestones": rows} +async def get_milestone(milestone_id: int) -> dict: + """Fetch a milestone (the plan container) with its step-tasks and rules. + + A milestone IS a plan: its `body` holds the design/intent, and its steps + are the child tasks listed here. Use this to read a plan top-to-bottom — + the body for the design, `steps` for the trackable units of work. Mirrors + the planning context that start_planning returns (applicable rules), so the + rules surface again on recall. + + Returns: milestone (incl. body), progress, steps (its tasks ordered by + status then update), and applicable_rules / subscribed_rulebooks. + """ + uid = current_user_id() + milestone = await milestones_svc.get_milestone(uid, milestone_id) + if milestone is None: + raise ValueError(f"milestone {milestone_id} not found") + progress = await milestones_svc.get_milestone_progress(milestone_id) + steps, _ = await notes_svc.list_notes( + uid, is_task=True, milestone_id=milestone_id, sort="status", limit=200, + ) + applicable = await rulebooks_svc.get_applicable_rules( + project_id=milestone.project_id, user_id=uid, + ) + out = milestone.to_dict() + out.update(progress) + return { + "milestone": out, + "steps": [t.to_dict() for t in steps], + "applicable_rules": applicable["rules"], + "subscribed_rulebooks": applicable["subscribed_rulebooks"], + "applicable_rules_truncated": applicable["truncated"], + } + + async def create_milestone( project_id: int, title: str, description: str = "", + body: str = "", status: str = "active", ) -> dict: """Create a milestone within a Scribe project. + A milestone can serve as a plan container — put the design/intent in `body` + and track each step as a child task (create_task(milestone_id=...)). For a + fresh plan, prefer start_planning, which seeds the body template + surfaces + the project's rules. + Args: project_id: The project this milestone belongs to (required). title: Milestone name (required). - description: Optional description of what this milestone covers. + description: Optional one-line summary of what this milestone covers. + body: Optional plan/design (markdown) — the milestone's full plan text. status: active (default) or done. """ uid = current_user_id() @@ -47,6 +90,7 @@ async def create_milestone( project_id=project_id, title=title, description=description or None, + body=body or None, status=status, ) return milestone.to_dict() @@ -57,6 +101,7 @@ async def update_milestone( milestone_id: int, title: str = "", description: str = "", + body: str = "", status: str = "", order_index: int = -1, ) -> dict: @@ -67,7 +112,8 @@ async def update_milestone( ownership scoping is enforced by user_id at the service layer). milestone_id: ID of the milestone to update. title: New title, or omit to leave unchanged. - description: New description, or omit to leave unchanged. + description: New one-line summary, or omit to leave unchanged. + body: New plan/design (markdown), or omit to leave unchanged. status: New status — active or done. order_index: New display position (0-based). Use -1 to leave unchanged. """ @@ -77,6 +123,8 @@ async def update_milestone( fields["title"] = title if description: fields["description"] = description + if body: + fields["body"] = body if status: fields["status"] = status if order_index >= 0: @@ -101,6 +149,7 @@ async def delete_milestone(milestone_id: int) -> dict: def register(mcp) -> None: for fn in ( list_milestones, + get_milestone, create_milestone, update_milestone, delete_milestone, diff --git a/src/scribe/mcp/tools/tasks.py b/src/scribe/mcp/tools/tasks.py index 98c7045..629a12c 100644 --- a/src/scribe/mcp/tools/tasks.py +++ b/src/scribe/mcp/tools/tasks.py @@ -233,14 +233,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). + Creates a MILESTONE that IS the plan: its `body` is seeded with a design + template (Goal/Approach/Verification) under the given project, and the call + returns it together with the project's applicable Rulebook rules and brief + context. The milestone is the plan container — the individual steps live as + first-class child tasks under it, not as checkboxes in the body. + + Afterwards: + - Edit the plan/design with update_milestone(milestone_id, body=...). + - Create each step as its own task with create_task(milestone_id=); + track it with status + add_task_log. Do NOT put steps as checkboxes in the + milestone body. + + (kind=plan tasks are retired — use this instead. Existing historical + plan-tasks remain readable but new planning goes through milestones.) Args: project_id: The project this plan is for. - title: A short title for the plan. + title: A short title for the plan/milestone. """ uid = current_user_id() return await planning_svc.start_planning( diff --git a/src/scribe/models/milestone.py b/src/scribe/models/milestone.py index ee36a2c..a449204 100644 --- a/src/scribe/models/milestone.py +++ b/src/scribe/models/milestone.py @@ -13,6 +13,10 @@ class Milestone(Base, TimestampMixin, SoftDeleteMixin): project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id", ondelete="CASCADE")) title: Mapped[str] = mapped_column(Text, default="") description: Mapped[str | None] = mapped_column(Text, nullable=True) + # The plan: design/intent/purpose (markdown). The milestone is the plan + # container; its steps live as first-class child tasks (milestone_id), not + # as checkboxes in this body. `description` stays the one-line summary. + body: Mapped[str | None] = mapped_column(Text, nullable=True) status: Mapped[str] = mapped_column(Text, default="active") order_index: Mapped[int] = mapped_column(Integer, default=0) @@ -23,6 +27,7 @@ class Milestone(Base, TimestampMixin, SoftDeleteMixin): "project_id": self.project_id, "title": self.title, "description": self.description, + "body": self.body, "status": self.status, "order_index": self.order_index, "created_at": self.created_at.isoformat(), diff --git a/src/scribe/routes/milestones.py b/src/scribe/routes/milestones.py index 02d76e1..36811a5 100644 --- a/src/scribe/routes/milestones.py +++ b/src/scribe/routes/milestones.py @@ -62,6 +62,7 @@ async def create_milestone_route(project_id: int): project_id, title=data["title"], description=data.get("description"), + body=data.get("body"), order_index=data.get("order_index", 0), status=status, ) @@ -92,7 +93,7 @@ async def update_milestone_route(project_id: int, milestone_id: int): if milestone is None: return not_found("Milestone") data = await request.get_json() - allowed = {"title", "description", "status", "order_index"} + allowed = {"title", "description", "body", "status", "order_index"} fields = {k: v for k, v in data.items() if k in allowed} if "status" in fields and fields["status"] not in ("active", "done"): return jsonify({"error": "status must be 'active' or 'done'"}), 400 diff --git a/src/scribe/services/milestones.py b/src/scribe/services/milestones.py index d36174e..a37295d 100644 --- a/src/scribe/services/milestones.py +++ b/src/scribe/services/milestones.py @@ -16,6 +16,7 @@ async def create_milestone( project_id: int, title: str, description: str | None = None, + body: str | None = None, order_index: int = 0, status: str = "active", ) -> Milestone: @@ -25,6 +26,7 @@ async def create_milestone( project_id=project_id, title=title, description=description, + body=body, status=status, order_index=order_index, ) diff --git a/src/scribe/services/planning.py b/src/scribe/services/planning.py index 413247b..c2634b6 100644 --- a/src/scribe/services/planning.py +++ b/src/scribe/services/planning.py @@ -1,31 +1,37 @@ -"""Planning service — start_planning aggregates plan-task creation with the -project's applicable Rulebook rules and a little context, so planning happens -in Scribe and rules surface at the planning moment. +"""Planning service — start_planning creates a MILESTONE seeded as the plan +container, surfacing the project's applicable Rulebook rules at the planning +moment so rules land before any work. + +The milestone IS the plan: its `body` holds the design/intent (Goal/Approach/ +Verification), and the individual steps live as first-class child tasks +(milestone_id) rather than checkboxes crammed into one body. The legacy +kind=plan task is retired going forward — start_planning never creates one. """ from __future__ import annotations +from scribe.services import milestones as milestones_svc from scribe.services import notes as notes_svc from scribe.services import projects as projects_svc from scribe.services import rulebooks as rulebooks_svc +# The plan body template — design only. Steps are NOT checkboxes here; each +# step becomes its own child task under this milestone (status, work-logs, +# priority of its own). PLAN_TEMPLATE = """## Goal ## Approach -## Steps -- [ ] - ## Verification """ async def start_planning(user_id: int, project_id: int, title: str) -> dict: - """Create a plan-task seeded with the plan template and return it with the + """Create a milestone seeded as a plan container and return it with the project's applicable rules + brief context. Returns: { - "task": , + "milestone": , "applicable_rules": [...], "subscribed_rulebooks": [...], "applicable_rules_truncated": bool, @@ -37,13 +43,12 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict: if project is None: raise ValueError(f"project {project_id} not found") - note = await notes_svc.create_note( + milestone = await milestones_svc.create_milestone( user_id, + project_id=project_id, title=title, body=PLAN_TEMPLATE, - status="todo", - task_kind="plan", - project_id=project_id, + status="active", ) applicable = await rulebooks_svc.get_applicable_rules( @@ -54,7 +59,7 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict: ) return { - "task": note.to_dict(), + "milestone": milestone.to_dict(), "applicable_rules": applicable["rules"], "subscribed_rulebooks": applicable["subscribed_rulebooks"], "applicable_rules_truncated": applicable["truncated"], diff --git a/tests/test_mcp_tool_milestones.py b/tests/test_mcp_tool_milestones.py index b63bb3f..9000dfd 100644 --- a/tests/test_mcp_tool_milestones.py +++ b/tests/test_mcp_tool_milestones.py @@ -5,7 +5,7 @@ import pytest from scribe.mcp._context import _user_id_ctx from scribe.mcp.tools.milestones import ( - list_milestones, create_milestone, update_milestone, + list_milestones, get_milestone, create_milestone, update_milestone, ) @@ -57,6 +57,64 @@ async def test_create_milestone_empty_description_becomes_none(): assert mock.call_args.kwargs["description"] is None +@pytest.mark.asyncio +async def test_create_milestone_passes_body_through(): + """The milestone-as-plan body is forwarded to the service.""" + m = _fake_ms(id=5) + mock = AsyncMock(return_value=m) + with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock): + await create_milestone(project_id=1, title="t", body="## Goal\n\nship") + assert mock.call_args.kwargs["body"] == "## Goal\n\nship" + + +@pytest.mark.asyncio +async def test_create_milestone_empty_body_becomes_none(): + m = _fake_ms() + mock = AsyncMock(return_value=m) + with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock): + await create_milestone(project_id=1, title="t", body="") + assert mock.call_args.kwargs["body"] is None + + +@pytest.mark.asyncio +async def test_update_milestone_sends_body(): + m = _fake_ms() + mock = AsyncMock(return_value=m) + with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock): + await update_milestone(project_id=1, milestone_id=5, body="new plan") + assert mock.call_args.kwargs == {"body": "new plan"} + + +@pytest.mark.asyncio +async def test_get_milestone_returns_body_steps_and_rules(): + m = _fake_ms(id=5, project_id=3, body="## Goal") + step = MagicMock() + step.to_dict.return_value = {"id": 9, "title": "step 1", "status": "todo"} + applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False, + "subscribed_rulebooks": [{"id": 2, "title": "rb"}]} + with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone", + AsyncMock(return_value=m)), \ + patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone_progress", + AsyncMock(return_value={"total": 1, "completed": 0, "pct": 0.0})), \ + patch("scribe.mcp.tools.milestones.notes_svc.list_notes", + AsyncMock(return_value=([step], 1))), \ + patch("scribe.mcp.tools.milestones.rulebooks_svc.get_applicable_rules", + AsyncMock(return_value=applicable)): + out = await get_milestone(milestone_id=5) + assert out["milestone"]["body"] == "## Goal" + assert out["milestone"]["total"] == 1 + assert out["steps"] == [{"id": 9, "title": "step 1", "status": "todo"}] + assert out["applicable_rules"] == [{"id": 1, "title": "r"}] + + +@pytest.mark.asyncio +async def test_get_milestone_raises_when_not_found(): + with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone", + AsyncMock(return_value=None)): + with pytest.raises(ValueError, match="milestone 999 not found"): + await get_milestone(milestone_id=999) + + @pytest.mark.asyncio async def test_update_milestone_only_sends_non_default_fields(): m = _fake_ms() diff --git a/tests/test_mcp_tool_planning.py b/tests/test_mcp_tool_planning.py index 7d6134f..d079e5d 100644 --- a/tests/test_mcp_tool_planning.py +++ b/tests/test_mcp_tool_planning.py @@ -14,13 +14,13 @@ def _bind_user(): @pytest.mark.asyncio async def test_start_planning_tool_delegates_to_service(): - payload = {"task": {"id": 5}, "applicable_rules": [], "subscribed_rulebooks": [], + payload = {"milestone": {"id": 5}, "applicable_rules": [], "subscribed_rulebooks": [], "applicable_rules_truncated": False, "project_goal": "", "open_task_count": 0} with patch("scribe.mcp.tools.tasks.planning_svc.start_planning", AsyncMock(return_value=payload)) as mock: from scribe.mcp.tools.tasks import start_planning out = await start_planning(project_id=3, title="Plan it") - assert out["task"]["id"] == 5 + assert out["milestone"]["id"] == 5 assert mock.call_args.kwargs == {"user_id": 7, "project_id": 3, "title": "Plan it"} diff --git a/tests/test_services_planning.py b/tests/test_services_planning.py index a483ee5..90f4db5 100644 --- a/tests/test_services_planning.py +++ b/tests/test_services_planning.py @@ -4,17 +4,19 @@ import pytest @pytest.mark.asyncio -async def test_start_planning_creates_plan_task_and_returns_rules(): - fake_note = MagicMock() - fake_note.to_dict.return_value = {"id": 5, "title": "Plan it", "task_kind": "plan"} +async def test_start_planning_creates_milestone_and_returns_rules(): + # start_planning now creates a MILESTONE (the plan container), not a + # kind=plan task; its body holds the seeded design template. + fake_milestone = MagicMock() + fake_milestone.to_dict.return_value = {"id": 5, "title": "Plan it", "status": "active"} applicable = { "rules": [{"id": 1, "title": "dev is home", "statement": "...", "topic_title": "git-workflow", "rulebook_title": "FabledSword family"}], "truncated": False, "subscribed_rulebooks": [{"id": 2, "title": "FabledSword family"}], } - with patch("scribe.services.planning.notes_svc.create_note", - AsyncMock(return_value=fake_note)) as mock_create, \ + with patch("scribe.services.planning.milestones_svc.create_milestone", + AsyncMock(return_value=fake_milestone)) as mock_create, \ patch("scribe.services.planning.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable)), \ patch("scribe.services.planning.notes_svc.list_notes", @@ -24,14 +26,13 @@ async def test_start_planning_creates_plan_task_and_returns_rules(): from scribe.services.planning import start_planning out = await start_planning(user_id=7, project_id=3, title="Plan it") - # Created a plan-task (status set => task, kind=plan) + # Created a milestone with the seeded plan-body template. kwargs = mock_create.call_args.kwargs - assert kwargs["task_kind"] == "plan" - assert kwargs["status"] == "todo" assert kwargs["project_id"] == 3 + assert kwargs["status"] == "active" assert "## Goal" in kwargs["body"] # seeded template # Returned shape - assert out["task"]["id"] == 5 + assert out["milestone"]["id"] == 5 assert out["applicable_rules"][0]["title"] == "dev is home" assert out["subscribed_rulebooks"] == [{"id": 2, "title": "FabledSword family"}] assert out["open_task_count"] == 3