feat(plans): milestone-as-plan-container; retire kind=plan (T3)
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:
+23
-13
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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=<this 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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user