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
co-authored by Claude Opus 4.8
parent c972af2690
commit 1f6c592226
13 changed files with 336 additions and 50 deletions
+2
View File
@@ -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,
)
+18 -13
View File
@@ -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": <task dict>,
"milestone": <milestone dict>,
"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"],