feat(plans): milestone-as-plan-container; retire kind=plan (T3)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 59s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 12:22:22 -04:00
parent c972af2690
commit 1f6c592226
13 changed files with 336 additions and 50 deletions
+53 -4
View File
@@ -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,